Menu

Tutorial

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:

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.
  • state is a typed, serializable fact. The JsonValue constraint 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 popovertarget attributes 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:

ts
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:

ts
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="{&quot;saved&quot;: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:

ts
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:

ts
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.