Menu

Tutorial

View as Markdown

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 renders the authored TSX component into the product page. The compiler may emit transient generated artifacts for inspection during a build, but those are outputs to verify, not source you author, commit, or import by hand:

tsx
export const productRoute = route('/products/:id', {
  access: publicAccess('tutorial storefront browsing'),
  params: s.object({ id: s.string() }),
  page({ params }) {
    const product = catalog.find((item) => item.id === params.id);
    if (!product) return notFound();
    return (
      <html>
        <head>
          <title>{product.name} · Kovo Shop</title>
        </head>
        <body>
          <main>
            <h1>{product.name}</h1>
            <p>{formatPrice(product.unitPrice)}</p>
            <ProductActions />
            <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"');

  // 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 root = mkdtempSync(join(tmpdir(), 'kovo-tutorial-compile-'));

  try {
    const { lowered } = compileProductActions(root);
    const element = new FakeElement({
      'kovo-state': attributeFrom(lowered, 'kovo-state'),
      'on:click': attributeFrom(lowered, 'on:click'),
    });
    const importedUrls: string[] = [];
    const importModule = async (url: string) => {
      importedUrls.push(url);
      return {
        ProductActions$button_click(_event: unknown, context: { state: { saved: number } }) {
          context.state.saved += 1;
        },
      };
    };

    await dispatchTutorialEvent({ target: element, type: 'click' }, importModule);
    await dispatchTutorialEvent({ target: element, type: 'click' }, importModule);

    expect(importedUrls[0]).toContain('/c/__v/');
    expect(importedUrls[0]).toContain('/tutorial/steps/02-islands/');
    expect(element.getAttribute('kovo-state')).toBe('{"saved":2}');
  } finally {
    rmSync(root, { force: true, recursive: true });
  }
});

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-'));

  try {
    const { lowered, output } = compileProductActions(root);

    expect(output).toContain(
      'WARN KV210 file="site/tutorial/steps/02-islands/src/components/product-actions.tsx"',
    );
    expect(output).toContain('SUMMARY artifacts=2 diagnostics=1');
    expect(lowered).toContain('popovertarget="size-guide"');
    expect(lowered).toContain('popovertargetaction="toggle"');
    expect(lowered).toMatch(
      /on:click="\/c\/__v\/[0-9a-f][0-9a-f-]*\/(?:site\/)?tutorial\/steps\/02-islands\/src\/components\/product-actions\.client\.js#ProductActions\$button_click"/,
    );
  } 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.

Next#

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. Generated lowered IR is a verification artifact, not app-authored source: 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.