From fb2ea77f112ace7acfc9fdfef33bfda4bc32f157 Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 11 Aug 2026 19:45:31 +0530 Subject: [PATCH] docs: teach agents to size an interactive island, not just place one The skill said "all interactivity lives in a component" in eleven places and never once said how much markup to put in it. Worse, the ownership rules pushed the wrong way with no stopping rule: "if you are writing a selector to find markup another file rendered, write the component that renders it instead" never terminates, because there is always more surrounding markup a component could render. The fact that would have stopped it was buried as the last bullet of the elidability blocker list, "being rendered by a component that itself ships", and components.md explicitly told the reader elision was "inspectable rather than something to reason about". So an agent had no reason not to wrap a page section in a component to make one button work, and nothing downstream would flag it. Adds the stopping rule (a component owns the markup its own behaviour reads or writes), the byte-cost argument behind it, a too-big/right-size before-and-after, a walk-the-template test, and the ownership-wins exception. Promotes the propagation fact from a blocker-list entry into a stated design consequence, since it is the reason the cost of an oversized island is not linear in what was moved. Also defines "island", which the skill used twice and never explained. Same guidance on the four surfaces that carry it: the skill (which create.js copies verbatim, so every scaffolded app gets it), the root AGENTS.md execution model, and /docs/components. --- .agents/skills/webjs/SKILL.md | 5 ++- .agents/skills/webjs/references/components.md | 40 +++++++++++++++++++ .../webjs/references/routing-and-pages.md | 24 +++++++++++ AGENTS.md | 2 + website/app/docs/components/page.ts | 15 +++++++ 5 files changed, 85 insertions(+), 1 deletion(-) diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index 5f069802f..efa2c38fd 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -24,6 +24,8 @@ WebJs is an AI-first, web-components-first framework with **no build step**: sou **Progressive enhancement is the default architecture.** With JS off, content reads, `` navigates, and a `
` submits to its server action. JS is opt-in per interactive behaviour. Never write a first paint that depends on hydration. +**Islands, and why their size is a decision.** A WebJs page is server-rendered HTML with small interactive components embedded in it, each hydrating on its own when the browser upgrades its tag. That is the islands model, and what makes it pay is that the sea is free: static markup a page renders costs the browser nothing, because a page never hydrates. So an island is not a unit of code organisation, it is a unit of shipped JavaScript, and it should wrap the interactive part and stop. Absorbing a page's static markup into a component to keep things tidy converts free HTML into shipped JavaScript, and it takes every display-only child down with it, because a component rendered by a component that ships can no longer be elided. Size the island to the behaviour, not to the section of the design it happens to sit in. `references/components.md` has the stopping rule and a worked before/after. + ## When To Use This Skill - New features or refactors touching pages, routes, actions, components, data, auth, sessions, styling, or tests @@ -66,7 +68,7 @@ Common bundles: 2. **Start from the server.** Add the page/route and its server action or query before wiring interactive UI. A page render or a `` POST should already return correct HTML before any component hydrates. 3. **Put code in the narrowest owner.** Route-local first (`modules//`), promote to `lib/` or `components/` only when reuse is real. 4. **Keep server-only code behind `.server.ts`.** The DB driver, secrets, and `node:*` never belong in a page, layout, or component. -5. **Add interactivity per behaviour.** Reach for a component (and a signal or `@event`) only where the UI is genuinely interactive. A display-only component is elided from the browser. +5. **Add interactivity per behaviour.** Reach for a component (and a signal or `@event`) only where the UI is genuinely interactive. A display-only component is elided from the browser. Then wrap the interactive part and STOP: the static markup around it stays in the page, where it costs nothing. 6. **Validate input at the boundary.** Declare `export const validate` on an action; the RPC and `route()` boundaries run it. 7. **Default mutations to optimistic UI** where the client can predict the result (`optimistic()` from `@webjsdev/core`). 8. **Type every boundary from its source, never `unknown` or `any`.** The row type comes from the schema (`typeof todos.$inferSelect`), the action's input from a named `interface` and its result from `ActionResult`, the routing files from `PageProps` / `LayoutProps` / `RouteHandlerContext`. `unknown` belongs on a payload nothing has vouched for yet that the next line narrows, and on a parameter of your own helper that forwards into an `html` template hole. Everywhere else, including a layout's `children`, it is a missing type. See `references/typescript.md`. @@ -231,6 +233,7 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a ## Common Mistakes To Avoid - Treating a page or layout like a React component and expecting its markup to hydrate. It runs server-only; put interactivity in a component. +- Promoting a whole page section to a component so that one control inside it can be interactive. The island should wrap the control and the state it reads. An oversized island ships its own JS AND un-elides every display-only component inside it, so the cost is not linear in what you moved. - Importing a `.server.ts` utility (no `'use server'`) directly into a shipping component. Its browser stub throws at load; reach it through a `'use server'` action. - Using a `static properties` block or a class-field initializer for reactive props instead of the `WebComponent({ ... })` factory. - Quoting an event / property / boolean hole (`@click="${fn}"`). diff --git a/.agents/skills/webjs/references/components.md b/.agents/skills/webjs/references/components.md index d2c70684a..e124096b0 100644 --- a/.agents/skills/webjs/references/components.md +++ b/.agents/skills/webjs/references/components.md @@ -101,6 +101,44 @@ NavDrawer.register('nav-drawer'); This repo's own website is the worked example. Before commit `b80de906` the docs drawer and the header menu were exactly the first shape, and every accessibility bug their tests now pin came out of the split. `website/components/docs-drawer.ts` and `website/components/site-nav-menu.ts` are the second shape, and `website/AGENTS.md` records the app-level version of these rules under "What stays inline script in the root layout". +## Sizing an island: own the behaviour, not the section + +The ownership rules above answer "what must not be split apart". They do NOT answer "how much to pull in", and read alone they push in one direction only: rule 1 says that if you are reaching for a selector, write the component that renders that markup instead. Applied without a stopping rule, that argument never terminates, because there is always more surrounding markup a component could render. + +Here is the stopping rule. **A component owns the markup its own behaviour reads or writes.** Static markup that no handler touches, no state change re-renders, and no template hole depends on belongs to the page, not to the island. + +This is a byte-cost rule, not a taste one. A page never hydrates, so markup it renders is free in the browser. Markup an island renders is not: the island's module is fetched, `@webjsdev/core` comes with it, and on upgrade the component re-renders, replacing the server's DOM for that subtree. Moving static markup across that boundary converts free HTML into shipped JavaScript that reproduces markup the server already sent. + +```ts +// TOO BIG. One button's worth of behaviour, a whole page's worth of markup. +class ProductPage extends WebComponent({ product: prop(Object) }) { + render() { + return html` +

${this.product.name}

+

${this.product.description}

+ + + + `; + } +} +``` + +```ts +// RIGHT SIZE. The page renders the static markup. The island is the button. +class AddToCart extends WebComponent({ productId: String }) { + render() { + return html``; + } +} +``` + +The first version ships three modules instead of one: itself, plus `` and ``, which were display-only and elidable until a shipping component rendered them (see "Display-only elision" below). The second ships one small module, and the heading, description, spec table, and reviews stay HTML the browser never pays for. + +**How to tell, on a component you are about to write.** Walk its template and ask of each element: does a handler in this class touch it, does a state or property change alter it, or does it sit in a template hole? If the answer is no for all three, that element is a passenger. A template that is mostly passengers is an island that wants splitting, and the split is usually "hoist the static markup back to the page and keep the interactive fragment". + +**The exception, and it is a real one.** Markup that is static *today* but is the thing a near-term behaviour will read is fine to keep, because the alternative is a component that reaches outward for it later, which is what rule 1 forbids. Judge the behaviour you are building, not one you are speculating about. When those genuinely collide, ownership wins over bytes: a coherent component that ships a little extra markup beats a split feature that a selector holds together. + ## Reactive properties: the base-class factory Reactive properties are declared by passing their shape into `WebComponent({ ... })`. The types flow automatically to `this.`, so there is NO `static properties` block and NO `declare` line (a `static properties` block throws at runtime, caught by `no-static-properties`). @@ -356,6 +394,8 @@ A component that does no client-side work renders the same SSR'd HTML with or wi - the dynamic slot READ surface (`slotchange`, `assignedNodes` / `assignedElements` / `assignedSlot`); merely RENDERING a `` does not ship (the SSR output carries the placed children, so a display-only slotted wrapper is byte-identical without its JS; native-write liveness is consumer-driven and the consumer's tag reference forces the ship) - being rendered by a component that itself ships +That last blocker is the one to DESIGN around rather than merely inspect, because it is the only one that is not about the component you are looking at. Elision propagates downward from whatever ships, so the size of your islands decides how much of the tree stays elidable. Ten display-only components rendered by a page are ten modules the browser never fetches. The same ten rendered by one oversized interactive wrapper all ship, and nothing about any of them changed. This is why "Sizing an island" above is a byte-cost rule and not a tidiness preference. + A bare `async render()` (no other signal, light DOM) is elided too: the SSR'd data is the complete first paint. Force shipping with `static interactive = true` when interactivity is invisible to static analysis. `static shadow = true` always ships (Declarative Shadow DOM re-attaches only during parsing). Turn elision off app-wide with `{ "webjs": { "elide": false } }` or `WEBJS_ELIDE=0`. ### What `static interactive = true` does and does not rescue diff --git a/.agents/skills/webjs/references/routing-and-pages.md b/.agents/skills/webjs/references/routing-and-pages.md index baa4d1583..81e0e5431 100644 --- a/.agents/skills/webjs/references/routing-and-pages.md +++ b/.agents/skills/webjs/references/routing-and-pages.md @@ -18,6 +18,30 @@ Pages and layouts run **only on the server** to produce HTML. They do NOT hydrat `route.ts` is the one routing file that is NOT isomorphic: a server-only HTTP handler, never shipped to the client. +### How much of the page belongs in the component + +"Put every interactive behaviour in a component" is not "put the section containing it in a component". Because a page never hydrates, the markup it renders costs the browser nothing, so a page that keeps its static content and delegates only the interactive fragment is both the cheapest and the conventional shape: + +```ts +// app/products/[id]/page.ts +export default async function Product({ params }: PageProps<'/products/[id]'>) { + const product = await getProduct(params.id); + return html` +
+

${product.name}

+

${product.description}

+ + + + + +
+ `; +} +``` + +Only `` ships. `` and `` are display-only, so the framework elides them and the browser fetches neither. Wrapping the whole article in a `` component to "own the page" would ship all three, because a component rendered by a component that ships can no longer be elided. The sizing rule and a before/after are in `components.md` under "Sizing an island". + ## Pages (`app/**/page.ts`) The default export is a possibly-async function receiving `{ params, searchParams, url, actionData }`. It returns a `TemplateResult`; it never calls `render()` itself. diff --git a/AGENTS.md b/AGENTS.md index 22167b637..cf91902be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,6 +145,8 @@ WebJs has **no server/client component split.** There is no RSC render tree, no - **Components hydrate.** The module loads in the browser, registers the custom element, the browser upgrades the SSR'd tag, and `render()` / lifecycle / `@event` / signals run client-side. Per-element, islands-style. **All interactivity lives here.** - **Pages and layouts do NOT hydrate.** Their function runs only on the server to produce HTML and is never re-invoked in the browser. So a page/layout cannot be interactive in its own markup (an `@click` in a page template is dropped at SSR; a signal read in a page body never re-renders). For interactivity, render a component's tag. +**An island is a unit of shipped JavaScript, so its SIZE is a decision.** Because a page never hydrates, markup a page renders is free in the browser and markup an island renders is not. So a component should wrap the interactive part and stop: static markup that no handler touches, no state change re-renders, and no template hole depends on belongs to the page. The cost of getting this wrong is not linear in what you moved, because elision propagates downward from whatever ships, so an oversized island un-elides every display-only component inside it (ten display-only components rendered by a page are ten modules the browser never fetches, and the same ten rendered by one oversized wrapper all ship). Where a coherent component genuinely collides with this, ownership wins over bytes: a component that ships a little extra markup beats a feature a class selector holds together. Full rule, worked before/after, and the walk-the-template test in `references/components.md` under "Sizing an island". + A page/layout module still **loads** in the browser for its top-level side effects: registering imported components (so their tags upgrade). The client router is automatic and needs no import: it auto-enables when `@webjsdev/core` loads in the browser (the bundle every component pulls), so any page that ships a component gets client navigation for free (#620). That load is also how its imports reach the client (`import dayjs` at the top of a page fetches dayjs when the module loads, not via hydration). An inert page/layout is dead weight, which is exactly when elision drops it. A page/layout that is non-inert ONLY because it imports interactive components is **import-only** (#605): since it never hydrates, the boot emits its component modules directly and drops the page/layout module, so the browser fetches just the interactive leaves (it still ships whole when it has its own client side effect: a `client-router` import, a module-scope call, or a self-registering bare import). The verdict is path-aware (#963): client work reachable only THROUGH a shipping component (a module-scope signal bus the component imports, the invariant-5 idiom) does not pin the page, because the emitted component carries it; only a component-free path to client work ships the page whole. - importing a client-effecting NON-component util. A helper that touches a client global or self-executes drags the whole page in. Put client-only behaviour inside a component; put server-only work in `.server.{js,ts}` (it never reaches the client closure); and if a util MIXES a pure helper with client-global code (the `cn.ts` shape, #619), split the client part into its own module so the pure helper does not pin every importer. Note: if a page imports a client-effecting utility (like `cn.ts` which accesses client globals), the page module is marked as client-effecting and will be browser-shipped. When this happens, importing any bare server-only utility (like `*.server.ts` queries) inside that page will trigger a `no-server-import-in-browser-module` check violation. To fix this, convert the query to a `'use server'` action or fetch it through a route handler. diff --git a/website/app/docs/components/page.ts b/website/app/docs/components/page.ts index 9f0cd63a4..bad3285d5 100644 --- a/website/app/docs/components/page.ts +++ b/website/app/docs/components/page.ts @@ -19,6 +19,21 @@ export default function Components() {
  • Talk to an ancestor with a bubbling event, and to a stranger with a module-scope signal. A made-up event name on document is a global variable with extra steps.
  • +

    How big should a component be?

    +

    The rules above say what must not be split apart. They do not say how much to pull in, and read alone they push one way only, since there is always more surrounding markup a component could render. The stopping rule: a component owns the markup its own behaviour reads or writes. Static markup that no handler touches, no state change re-renders, and no template hole depends on belongs to the page.

    +

    This is a byte-cost rule rather than a taste one. A page never hydrates, so markup it renders is free in the browser. Markup a component renders is not: the module is fetched, @webjsdev/core comes with it, and on upgrade the component re-renders that subtree. Moving static markup across the boundary turns free HTML into shipped JavaScript that rebuilds what the server already sent.

    + <!-- app/products/[id]/page.ts: the page keeps the static markup --> +<article> + <h1>\${product.name}</h1> + <spec-table .rows=\${product.specs}></spec-table> + + <add-to-cart product-id=\${product.id}></add-to-cart> + + <review-list .reviews=\${product.reviews}></review-list> +</article> +

    Only <add-to-cart> ships. <spec-table> and <review-list> are display-only, so the framework elides them and the browser fetches neither. Wrapping the whole article in a <product-page> component would ship all three, because a component rendered by a component that ships can no longer be elided. The cost of an oversized island is therefore not linear in what you moved.

    +

    To check a component you are about to write, walk its template and ask of each element whether a handler touches it, a state change alters it, or it sits in a template hole. If all three are no, that element is a passenger. A template that is mostly passengers wants splitting. The exception is markup that is static today but is what a near-term behaviour will read, since the alternative is a component that reaches outward for it later. When ownership and bytes genuinely collide, ownership wins.

    +

    The WebComponent Base Class

    Every interactive component extends WebComponent, declares its property map by passing a shape into the base-class factory (extends WebComponent({ ... }), and optionally static styles for shadow-DOM components), implements render(), and registers itself by passing a hyphenated tag name to ClassName.register('tag-name'). The tag name is an argument to .register(), not a static field.