Components & islands
In this chapter you add two interactions to the product page — a size-guide popover and a "save
for later" button — written as one TSX component. You'll see the compiler decide, per
interaction, what actually ships to the browser, and you'll test both behaviors from strings.
Step state: site/tutorial/steps/02-islands/.
Write the component#
Kovo ranks every interaction on a fixed ladder and uses the lowest layer that works. L0 is
platform behavior — popovers, dialogs, <details> — costing zero JavaScript. L1 is a pure
client island whose handler module loads on first interaction. You don't pick the layer; the
compiler does. You write TSX:
export const ProductActions = component({
state: (): ProductActionsState => ({ saved: 0 }),
render: (_queries: Record<string, never>, state: ProductActionsState) => (
<product-actions>
<button type="button" onClick={() => document.getElementById('size-guide')!.togglePopover()}>
Size guide
</button>
<div id="size-guide" popover="auto">
<p>Kettle height 24cm, base diameter 12cm.</p>
</div>
<button
type="button"
style={productActionStyles.saveButton}
onClick={() => {
state.saved += 1;
}}
>
Save for later
</button>
</product-actions>
),
});Three things to notice in what you wrote — and didn't:
- No stamps. You never hand-write
kovo-c,kovo-state, or binding attributes; the compiler derives them. Hand-writing one is a lint warning, and one that disagrees with the typed expression it wraps is a compile error. stateis a typed, serializable fact. TheJsonValueconstraint makes unserializable state a compile error: island state lives in the document, not in a JavaScript heap, so it has to survive serialization.- Two closures, two fates. The compiler proves your size-guide closure equivalent to a
platform invoker and emits
popovertargetattributes instead of JavaScript. Your save closure becomes a named export in a per-component client module that loads on first click. To see what got emitted, Compiler internals shows real captured output; the served page below tells you what you need here.
Render it into the page#
The app imports the component's compiled lowered IR — the compiled, still-authorable form under
src/generated/ — and renders it into the product page:
export function renderProductPage(product: Product): string {
const actions = ProductActions.definition.render({}, ProductActions.definition.state());
return `<!doctype html><html><head><title>${product.name} · Kovo Shop</title></head><body><main><h1>${product.name}</h1><p>${formatPrice(product.unitPrice)}</p>${actions}<a href="/">Back to the shop</a></main></body></html>`;
}The step's first test reads the served HTML the way you'd read it in the Elements panel: the popover wired as plain attributes, the handler as a full URL plus a named export, and the island's state right there in the markup. Names are load-bearing, so minification can't mangle them:
it('serves the island as self-describing attributes, zero eager JS', async () => {
const response = await renderProductRoute('p1');
const html = bodyText(response.body);
// L0: the size-guide closure was proven equivalent to a platform invoker
// and lowered to attributes — no JavaScript ships for it.
expect(html).toContain('popovertarget="size-guide"');
expect(html).toContain('popovertargetaction="toggle"');
// L1: the save button names its handler module and export in markup; the
// module loads on first interaction, not at page load.
expect(html).toMatch(
/on:click="\/c\/__v\/[0-9a-f]{8}\/site\/tutorial\/steps\/02-islands\/src\/components\/product-actions\.client\.js#ProductActions\$button_click"/,
);
// Island state is serialized in the markup, not hidden in a JS heap.
expect(html).toContain('kovo-state="{"saved":0}"');
});Someone who has never seen this codebase can answer "what does this button do?" from devtools alone — the answer is an attribute, not a stack trace through framework internals.
Run the handler without a browser#
Handlers are named exports with the signature (event, ctx). The loader's job — delegate the
event, import the module, invoke the export, persist state — is mechanical, so the test does
exactly what the loader does, against the real emitted handler module:
it('runs the named handler export against island state without a browser', async () => {
const response = await renderProductRoute('p1');
const html = bodyText(response.body);
const element = new FakeElement({
'kovo-state': attributeFrom(html, 'kovo-state'),
'on:click': attributeFrom(html, 'on:click'),
});
const importedUrls: string[] = [];
const importModule = async (url: string) => {
importedUrls.push(url);
return import('./generated-fixtures.js').then((module) => module.productActionsClient);
};
await dispatchDelegatedEvent({ target: element, type: 'click' }, importModule);
await dispatchDelegatedEvent({ target: element, type: 'click' }, importModule);
expect(importedUrls[0]).toContain('/c/__v/');
expect(importedUrls[0]).toContain('/site/tutorial/steps/02-islands/');
expect(element.getAttribute('kovo-state')).toBe('{"saved":2}');
});Two clicks, state 0 → 2, persisted back into the kovo-state attribute. Note what loaded when:
nothing at page load, the module on first interaction. Zero JS before interaction is the default
the markup declares, not an optimization you turn on.
Read the compiler's diagnostics#
The step's last test compiles the authored source and pins the diagnostics: the anonymous save closure earns a naming nudge, and the popover substitution is recorded:
it('checks the authored TSX through the public kovo compile command', () => {
const root = mkdtempSync(join(tmpdir(), 'kovo-tutorial-compile-'));
const loweredPath = join(root, 'product-actions.tsx');
try {
const output = execFileSync(
kovoBin,
[
'compile',
'component',
productActionsSourcePath,
'--out',
loweredPath,
'--file-name',
'site/tutorial/steps/02-islands/src/components/product-actions.tsx',
'--allow-diagnostic',
'KV210',
],
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
);
const lowered = readFileSync(loweredPath, 'utf8');
expect(output).toContain(
'WARN KV210 file="site/tutorial/steps/02-islands/src/components/product-actions.tsx"',
);
expect(output).toContain('SUMMARY artifacts=1 diagnostics=1');
expect(lowered).toContain('popovertarget="size-guide"');
expect(lowered).toContain('popovertargetaction="toggle"');
} finally {
rmSync(root, { force: true, recursive: true });
}
});Every Kovo diagnostic shows what would have been generated, why it can't be, and the fix menu. The reading kovo check guide tours the diagnostic registry.
You now have a free popover, a lazy island, and tests proving both from strings. Next: real data — queries, and the bindings the compiler derives from them.
Spec & diagnostics
Interaction ladder and lowest-layer rule: SPEC §7. Compiler-derived stamps: SPEC §4.1, §4.8;
hand-written stamp is KV223, a stamp that disagrees with its typed expression is KV222.
Serializable island state: SPEC §4.1. Popover lowering and naming nudge: SPEC §5.2 rule 4,
KV210. Committed lowered IR under src/generated/: Constitution #3. Legible served HTML and
load-bearing names: SPEC §4.2, rules/v1-acceptance.md, Constitution #1. Handler signature and lazy load: SPEC §4.3, §4.4.