@webjsdev/cli 0.10.51 → 0.10.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -135,7 +135,7 @@ import { createPost } from '#modules/posts/actions/create-post.server.ts';
135
135
  html`<form action=${createPost}><input name="title"></form>`;
136
136
  ```
137
137
 
138
- The renderer omits the `action` attribute so the form posts to the page's own url, supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` field carrying the action's `<hash>/<fn>` identity, the same identity the RPC endpoint resolves. Nothing about the action's source reaches the browser. With JS off this is an ordinary HTML submission; with JS the client router posts the same body to the same url, so the two paths are identical by construction.
138
+ The renderer omits the `action` attribute so the form posts to the page's own url, supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` field carrying the action's `<hash>/<fn>` identity, the same identity the RPC endpoint resolves. Nothing about the action's source reaches the browser. With JS off this is an ordinary HTML submission; with JS the client router posts the same body to the same url, encoded per the declared `enctype` (#1307: multipart stays `FormData`, urlencoded, which is the HTML default, is sent as `URLSearchParams`), so the two paths are identical by construction.
139
139
 
140
140
  **A form-bound action always receives the `FormData`**, which is where it differs from the same function called over RPC (rich arguments) or server-to-server. `validate` is the typing seam: it takes the `FormData` and its transform-return becomes the action's typed input.
141
141
 
@@ -236,3 +236,55 @@ import { posts } from '#db/schema.server.ts';
236
236
  ```
237
237
 
238
238
  Keep the wire shape in a browser-safe `modules/<feature>/types.ts` with NO runtime import from a `.server.ts` file or from `db/`. Define a hand-written DTO, or a type-only derivation (`import type { Post } ...; export type PostFormatted = Omit<Post, 'createdAt'> & { createdAt: string }`). Never `export *` or a value re-export from a `.server.ts` in `types.ts`; that carries the runtime table bindings and breaks any component importing the types. Full reference at https://webjs.dev/docs.
239
+
240
+ ## SSR action seeding, and how to tell it is working
241
+
242
+ When a shipping component's `async render()` awaits an action during SSR, WebJs serializes that result into the page and the generated RPC stub reads it on its FIRST client call. So `const u = await getUser(this.id)` runs once, on the server, and hydration reuses the result with no network round-trip.
243
+
244
+ **You write nothing for this.** It is automatic, on by default, and there is no API to call. The only thing you can do is break it, so the section below is about noticing when you have.
245
+
246
+ ### The correctness boundary
247
+
248
+ A seed hit returns the value the SSR render that produced this page computed for exactly this action, function, and argument list, so a hit cannot show the user something different from the HTML they are already looking at. A page navigation evicts whatever the outgoing page left unconsumed, both the block still in the DOM and anything already ingested from it, so a departed render's value is never served. On an HTML-cached page (`export const revalidate`) the seed rides inside the cached bytes, so it is exactly as fresh as the HTML it came with. A miss simply re-fetches.
249
+
250
+ There is one shape where a hit can differ from the paint, and WebJs warns about it in dev: **an action that returns a DIFFERENT result for the SAME arguments twice in one render.** The seed carries the last result while the first component painted the first one. So keep an action deterministic for a given argument list. A counter, a `Math.random()`, a `new Date()` in the return value, or a read of mutable module state all break that rule, and dev prints:
251
+
252
+ ```
253
+ [webjs] SSR action seeding: "getUser" returned two DIFFERENT results for the SAME arguments during one render. ...
254
+ ```
255
+
256
+ The fix is to make the action deterministic, or to move the varying part into an argument so the two calls get different keys.
257
+
258
+ ### Reading the dev diagnostics
259
+
260
+ A miss is invisible from the outside: the page still renders correctly, it just pays a round-trip per async component on every first load. Two channels make it visible in dev, and neither exists in production.
261
+
262
+ **Server side, per request.** The `X-Webjs-Seed` response header, also folded into the dev access-log line as a `seed` field:
263
+
264
+ | Value | What it means |
265
+ |---|---|
266
+ | `off` | Seeding is switched off (`"webjs": { "seed": false }` or `WEBJS_SEED=0`). Not a defect. |
267
+ | `html-cache` | The #241 HTML response cache answered. The seeds rode inside the cached bytes. |
268
+ | `collected=3, emitted=3` | Healthy. Three action results were captured and all three reached the page. |
269
+ | `collected=3, emitted=0` | The serializer threw and dropped the whole block. Something in a returned value is not serializer-safe. |
270
+ | `collected=3, emitted=0, streamed` | The page streams, so nothing could be emitted (see below). |
271
+
272
+ Check it with `curl -sSI localhost:3000/` or in the network tab.
273
+
274
+ **Browser side, per page view.** One `console.warn` at the first idle after hydration, and only when a call missed AND the client can be certain why. It stays silent otherwise, including on a page that emitted no seeds at all: every action call routes through the seed lookup, including ones that were never SSR-invoked and never could have been seeded (a mutation, a `Task` autorun, a `connectedCallback` read), so a miss there is not evidence of a defect. That case is the server header's job, where `collected=0` is unambiguous. The line names one of these:
275
+
276
+ - *"This page streams"*, so no seeds could be emitted. Expected, not a bug (see below).
277
+ - *"The page's seeds could not be serialized."* Something an action returned is not serializer-safe, so the whole block was dropped. The response header shows `collected` above `emitted` for the same reason.
278
+ - *"The page seeded these actions under DIFFERENT arguments."* The key is `hash(action file) / function name / serialized arguments`, so the client asked with an argument the SSR render never used. Common cause: the component computes its argument from browser-only state (a `localStorage` read, a `connectedCallback` assignment), which the server render could not have known. A miss on an action the page never seeded at all is NOT reported, because a mutation or a client-only read routes through the same lookup and could never have been seeded.
279
+
280
+ A miss AFTER hydration is correct and is not reported: the seed is consume-once, so a deliberate refetch or an argument change is supposed to go to the network.
281
+
282
+ `seedStats()` from `@webjsdev/core` returns `{ ingested, replaced, hits, misses, keyMisses, pending }` (`keyMisses` being the provable subset of `misses`, a call for an action the page seeded under other arguments) if you want to assert this in a browser test or read it from the console. A non-zero `pending` at rest usually means the seeding component ELIDED, so its module never shipped and nothing on the client was ever going to consume the seed. `pending` covers the page you are on: a page navigation evicts whatever the outgoing page left unconsumed, both the block still sitting in the DOM and anything already ingested from it, since those values belong to a render no longer on screen.
283
+
284
+ ### The streamed-page exception
285
+
286
+ A page carrying a `Suspense` or `<webjs-suspense>` boundary emits NO seed block at all, not just none for the streamed region: a streamed render's deferred boundaries resolve after the first flush, so their results cannot ride the block. Every action call on that page goes to the network on hydration. That is a real trade, so make it deliberately: reach for a streaming boundary when a slow region would otherwise block the first byte, and leave a fast page buffered so it seeds.
287
+
288
+ ### Switching it off
289
+
290
+ `"webjs": { "seed": false }` in `package.json`, or `WEBJS_SEED=0`. The client then re-fetches on hydration exactly as it did before the feature, and stale-while-revalidate hides the flicker. Turn it off only to isolate a problem; there is no reason to ship with it off.
@@ -64,7 +64,21 @@ html`<form action=${saveDraft}>
64
64
  </form>`;
65
65
  ```
66
66
 
67
- The identity rides the pressed button's own `name`/`value` pair, which a browser submits for that button alone, so this works with JS off exactly as it does with JS on. Both entries reach the server and the LAST wins, which is always the submitter's when one was pressed. The submitter must be a `<button>` and cannot carry its own `name`, `value`, or `form` attribute, because the identity already occupies that pair. `<input type="submit">` is refused for the binding: the identity has to occupy its `value`, which on that control is also the visible caption, so the button would render captioned with the action id and could never be labelled. A `<button>` has no such conflict, since its label is its children.
67
+ The identity rides the pressed button's own `name`/`value` pair, which a browser submits for that button alone, so this works with JS off exactly as it does with JS on. Both entries reach the server and the LAST wins, which is always the submitter's when one was pressed.
68
+
69
+ **A bound submitter is self-sufficient, so the enclosing form does not have to be bound** (#1307). The renderer puts `formmethod="post"` and `formenctype="multipart/form-data"` on the button itself, alongside the identity, exactly as React emits `formMethod` and `formEncType` on a button carrying a function `formAction`. So a per-button action works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all:
70
+
71
+ ```js
72
+ // Every button below submits a POST its action can read, with JS on or off.
73
+ html`<form>
74
+ <button formaction=${saveDraft}>Save</button>
75
+ <button formaction=${publishPost}>Publish</button>
76
+ </form>`;
77
+ ```
78
+
79
+ One consequence worth knowing rather than discovering: no `formaction` url is emitted (an empty one is an HTML conformance error), so the submission targets whatever the FORM targets. A form declaring its own `action="/x"` sends its buttons to `/x`, which is native precedence. The action still runs if `/x` is a PAGE route, because the identity travels in the body, but against a `route.ts` or another origin the identity is ignored and nothing runs. In dev the client logs a warning at submit time naming the url. Leaving the form's `action` off, the ordinary shape, keeps the submission on the current page.
80
+
81
+ The submitter must be a `<button>` and cannot carry its own `name`, `value`, or `form` attribute, because the identity already occupies that pair. `<input type="submit">` is refused for the binding: the identity has to occupy its `value`, which on that control is also the visible caption, so the button would render captioned with the action id and could never be labelled. A `<button>` has no such conflict, since its label is its children.
68
82
 
69
83
  **Only the bare, unquoted `action=${fn}` on a `<form>` binds.** Every near-miss is a hard render error rather than a silently-inert form, and the reason is a source leak. During SSR a `.server.ts` import is the ACTUAL function (the RPC stub exists only in the browser), and `action=` is an ordinary attribute hole, so stringifying it would write the function's body into the HTML every visitor downloads, including any literal inside it. The renderer throws instead, on the server and on the client, for `action=` and `formaction=` alike.
70
84
 
@@ -95,21 +109,21 @@ The bound, refused, and allowed shapes in full. Every "no" row is a binding that
95
109
  | `action=${fn}` unquoted, on a `<form>` | **no, it BINDS** | the one supported shape: the identity is resolved and emitted as a hidden field, nothing is stringified |
96
110
  | `action=${fn}` on any other tag | yes | `action` submits nothing off a `<form>`, so it is an ordinary attribute and the function would be stringified |
97
111
  | `action="${fn}"`, or a mixed `action="/x/${fn}"` | yes | quoting turns a binding hole back into a plain attribute |
98
- | `formaction=${fn}` unquoted, on a submitter inside a bound form | **no, it BINDS** | the second supported shape (#1207). The identity rides the button's own `name`/`value` pair, the one channel a browser submits for the pressed button alone, so no `formaction` url is emitted and the server takes the LAST `__webjs_action` entry |
99
- | `formaction=${fn}` inside an UNBOUND `<form>` | yes | `method="post"` and the enctype are forced on the FORM's start tag, which SSR has already emitted by the time it reaches the button, so a per-button action cannot retrofit them |
112
+ | `formaction=${fn}` unquoted, on a submitter, ANYWHERE | **no, it BINDS** | the second supported shape (#1207, #1307). A bound submitter carries its WHOLE submission: the identity rides the button's own `name`/`value` pair, the one channel a browser submits for the pressed button alone, and the renderer adds `formmethod="post"` and `formenctype="multipart/form-data"` to the button itself. So it works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all, and it asks NOTHING of the element around it. No `formaction` url is emitted, and the server takes the LAST `__webjs_action` entry |
100
113
  | `formaction=${fn}` on a submitter carrying its own `name` or `value` | yes | the identity IS that name/value pair, so both halves are already spoken for. Bind one action on the form and dispatch on `name="intent"` if you need the button's own value |
101
114
  | `formaction=${fn}` on a non-submit control, or `<input type="image">` | yes | `formaction` is inert on anything that does not submit, and an image submitter sends `name.x` / `name.y` coordinates instead of `name=value`, so the identity would never arrive |
102
- | `formaction=${fn}` on a submitter with `form="other"` | yes | it re-points the submitter at a form other than the bound one it sits in, so the boundness just checked was about the wrong element |
103
- | `formmethod="get"` / `formenctype="text/plain"` on ANY submitter inside a bound form | yes | this is the Part B rule, and it applies whether or not that button binds an action of its own: a GET sends no body and `text/plain` is not parseable, so the submission works under JS (the router posts `FormData`) and 405s without it |
115
+ | `formaction=${fn}` on a submitter with `form="other"` | yes | it re-points the submitter at a different form owner, which may not be where the identity field it needs lives |
116
+ | `formmethod` / `formenctype` the BOUND submitter cannot submit with (`get`, `PATCH`, `text/plain`, `dialog`, a padded `" post "`) | yes | a same-element contradiction: you attached an action to THIS button and told THIS button to submit in a way that action could never read. The renderer supplies `formmethod="post"` and the enctype only where you supplied neither, so your own parseable value always wins |
117
+ | `formmethod="get"` / `formenctype="text/plain"` on a PLAIN submitter inside a bound form | **no** | #1307 reversed this. Native HTML says the submitter's override wins, you typed it deliberately, and the form's action simply does not run, exactly as the same markup behaves anywhere else. In dev the client logs a console error at submit time if the submission is carrying an identity it cannot deliver |
104
118
  | `formmethod="dialog"` on a submitter that binds nothing | **no** | a native `<dialog>` dismissal, never a submission, so there is no body for the action to miss. It IS refused on a button that also binds an action, which is a straight contradiction |
105
- | a plain `formaction="/url"` on a submitter inside a bound form | **no** | it retargets the submission away from the page's bound action entirely, so its own `formmethod` is the author's business and Part B leaves it alone |
119
+ | a plain `formaction="/url"` on a submitter inside a bound form | **no** | it retargets the submission away from the page's bound action entirely, so where it goes and how is your business |
106
120
  | `.action=` on a native form | yes | the supported binding is the plain attribute, and a `.prop` on a native element drops at SSR, so accepting it would mean a form that submits under JS and does nothing without it |
107
121
  | `.method=` / `.enctype=` / `.encoding=` on a BOUND form | yes | the same reason one level over. All three are reflected IDL attributes, so SSR drops the binding and emits `method="post"` while a browser ends at what you assigned. Write them as plain attributes |
108
122
  | a second `action=${fn}` on one form | yes | SSR emits the second as a plain url next to the identity field, the client takes the last. Bind exactly one, in either position |
109
123
  | a plain `action="/url"` beside the bound hole | yes | the hole drops only its OWN attribute, so SSR keeps the static one while the client removes it: without JS the browser posts to `/url`, with JS to the page |
110
124
  | `method=" post "` / `enctype=" multipart/form-data "` | yes | `method` and `enctype` are enumerated attributes matched against exact keywords with no whitespace stripping, so a padded value falls to the invalid-value default and the form submits as a GET with no body. Trimming it for you would emit the padded value anyway |
111
125
  | `encoding="..."` as an ATTRIBUTE on a bound form | **no** | inert in HTML (`form.encoding` reads back `enctype`), so both renderers ignore it and still supply `enctype`. Only the `.encoding` PROPERTY aliases enctype, and that spelling IS refused, one row up |
112
- | `.formAction=` on a button or input | yes | same reason, that is where `formAction` reflects |
126
+ | `.formAction=` / `.formMethod=` / `.formEnctype=` on a BOUND submitter | yes | same reason, that is where they reflect: SSR drops the property and the browser applies it, so the button would submit one way with JS and another way without. On a PLAIN button they are ordinary native properties and are left alone |
113
127
  | `.action=` on any other native tag | **no** | a plain expando (`<div .action=${fn}>`, `<button .action=${fn}>`), reflecting nothing, so nothing reaches the markup |
114
128
  | `.action=` on a custom element | **no** | an author-defined property, not a reflected IDL attribute, so a function is a legitimate value. One declared `reflect: true` reflects on a path outside these commit sites, which used to write `String(value)` and emit the source. It now removes the attribute and warns instead, for a bare function and for an array carrying one, unless the prop supplies its own `converter.toAttribute`, which runs first and stays the author's call |
115
129
  | `?action=` | yes | a function never leaked through a boolean hole, but the binding is meaningless, so it is refused rather than emitting the bare `action=""` that ANY truthy value produces there. Two separate facts worth carrying: `action=""` is a conformance error (the spec wants a valid non-empty URL whenever the attribute is present), and deleting the attribute is still not the WebJs fix, since a page has no `action` export and an unbound `method="post"` form is a 405 (a bare GET form just re-renders). Bind it: `<form action=${fn}>` |
@@ -170,6 +184,12 @@ The file stays `middleware.ts`, NOT Next 16's renamed `proxy.ts`. WebJs middlewa
170
184
 
171
185
  Navigation is automatic. The client router auto-enables when `@webjsdev/core` loads (any page with a component), so a plain `<a href>` gets soft navigation for free. There is no `<Link>` to import and no `useRouter`. For programmatic navigation import `navigate()` / `revalidate()` from `@webjsdev/core`. There is no `next/image`, `next/font`, `next/script`, or `next/dynamic`. WebJs is no-build: use a plain `<img>`, a `<link>` / `@font-face`, a component's `static lazy = true` for viewport lazy-loading, and a dynamic `import()` where code should load lazily.
172
186
 
187
+ ### No `<ScrollRestoration>`, and no scroll restore of your own
188
+
189
+ Remix ships a `<ScrollRestoration />` component, Next has a `scrollRestoration` flag and a pile of community `useEffect` + `scrollTo` recipes, and every one of them is a thing to NOT port. WebJs restores scroll on Back/Forward automatically: the router sets `history.scrollRestoration = 'manual'` on boot and is the sole authority on scroll for the whole navigation. There is no component to render and no option to enable. An app-level `popstate` listener that calls `scrollTo`, a remembered offset in `sessionStorage`, or a `scrollIntoView` on a saved element all race the router and win sometimes, which is worse than losing consistently.
190
+
191
+ This includes the case that most tempts a hand-rolled fix: Back landing BELOW where the reader left, on a page whose components size themselves after they render. The router already handles it, by suppressing the browser's scroll anchoring across the restore so late growth above the viewport is not added to the offset it just replayed (see `client-router-and-streaming.md`). If a restore still lands wrong, report it rather than patching around it in app code.
192
+
173
193
  ### Server-only code: the `.server.ts` boundary, not a `server-only` package
174
194
 
175
195
  Next poisons a client-imported module with the `server-only` package. WebJs uses the file extension: `*.server.ts` is the path-level boundary (the file router refuses to serve the source). A `'use server'` file's exports are RPC-callable; a `.server.ts` file WITHOUT `'use server'` is a server-only utility whose browser import throws at load. Reach a no-`'use server'` utility through a `'use server'` action, `route.ts`, or `middleware`, never by direct import into a shipping page or component.
@@ -268,4 +288,4 @@ Context providers publish on connect via `hostConnected`, which does not run at
268
288
 
269
289
  ### Vanilla DOM instead of Lit idioms
270
290
 
271
- WebJs components are Lit-shaped on purpose: the value is the declarative DX. Prefer a factory-declared reactive prop over `this.getAttribute`, a `signal` over a `state: true` prop for internal state, a `class=${...}` binding over `this.classList`, a `@click=${...}` binding over `this.addEventListener`, and `C.register('x')` over `customElements.define`. Vanilla DOM stays right only where the platform offers nothing declarative: `this.closest('ui-tabs')` for compound-component ancestor lookup (resolves at SSR too), slotted-content queries, global `document` / `window` listeners, and imperative `el.focus()`. This is a convention, not a lint rule.
291
+ WebJs components are Lit-shaped on purpose: the value is the declarative DX. Prefer a factory-declared reactive prop over `this.getAttribute`, a `signal` over a `state: true` prop for internal state, a `class=${...}` binding over `this.classList`, a `@click=${...}` binding over `this.addEventListener`, and `C.register('x')` over `customElements.define`. Vanilla DOM stays right only where the platform offers nothing declarative: `this.closest('ui-tabs')` for compound-component ancestor lookup (resolves at SSR too), slotted-content queries, global `document` / `window` listeners, and imperative `el.focus()`. This is a convention, not a lint rule. A global `document` / `window` LISTENER is one of those legitimate cases, because the event happens outside the element. A document QUERY is not: reaching for markup that another component rendered is the jQuery habit to drop, and for your OWN rendered node a `ref` replaces the selector entirely. The ownership rules at the top of `components.md` state the full test.
@@ -104,7 +104,7 @@ render() {
104
104
 
105
105
  `method` and the enctype are supplied by the renderer, and the hidden identity field is re-inserted as the form's first child on every client render, so there is nothing to manage by hand.
106
106
 
107
- When a page owns SEVERAL mutations (create, toggle, delete), give each form its OWN binding (`action=${createTodo}` / `action=${toggleTodo}` / `action=${deleteTodo}`), or use per-button submitter server action bindings via `formaction=${action}` on submitter buttons inside a bound form (#1207). Alternatively, a form that dispatches dynamically can bind ONE action and inspect a submit button's `name="intent"`.
107
+ When a page owns SEVERAL mutations (create, toggle, delete), give each form its OWN binding (`action=${createTodo}` / `action=${toggleTodo}` / `action=${deleteTodo}`), or use per-button submitter server action bindings via `formaction=${action}` on submitter buttons (#1207, #1307: a bound submitter carries its own submission, so the enclosing form need not be bound). Alternatively, a form that dispatches dynamically can bind ONE action and inspect a submit button's `name="intent"`.
108
108
 
109
109
  ## Seed the list from the server for SSR plus optimistic
110
110
 
@@ -179,7 +179,9 @@ Three responses that are not the happy path:
179
179
 
180
180
  The submission is Origin-verified (the same `Sec-Fetch-Site` / `Origin` check the RPC endpoint applies), so a no-JS form needs no CSRF token field.
181
181
 
182
- Refusals worth knowing: `formaction=${fn}` is supported only on a `<button>` inside a bound form, and that button may not carry `name`, `value`, `form`, or a static `formaction` attribute (`<input type="submit">` is refused, because the identity needs its `value`, which is also its label). A bound form may not declare `method="get"`, and a function bound to `action=` that is not a `'use server'` export throws at render rather than producing a form that posts nowhere. See `muscle-memory-gotchas.md` for the full table.
182
+ A submitter's own `formmethod` / `formenctype` / `formtarget` overrides the form's on PRESENCE, not on the value being non-empty, and the client router resolves them the same way (#1322). So `<button type="submit" formmethod="">` really does submit as a GET, because a present-but-empty enumerated attribute falls to its own invalid-value default rather than inheriting the form's `method="post"`.
183
+
184
+ Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere, bound form or not (#1307: the renderer gives the button its own `formmethod` and `formenctype`), and that button may not carry `name`, `value`, `form`, or a static `formaction` attribute (`<input type="submit">` is refused, because the identity needs its `value`, which is also its label). A bound form may not declare `method="get"`, and a function bound to `action=` that is not a `'use server'` export throws at render rather than producing a form that posts nowhere. See `muscle-memory-gotchas.md` for the full table.
183
185
 
184
186
  ## Error, loading, and 404 boundaries
185
187
 
@@ -70,7 +70,7 @@ Avoid `@apply`: it hides which utilities a class uses and creates a second sourc
70
70
 
71
71
  ### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui`
72
72
 
73
- An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. Conflicts are keyed on the CSS PROPERTY wherever `cn` can tell the properties apart, rather than on the shared class prefix, so the common prefix collisions do NOT evict: `cn('border-2', 'border-primary')` keeps both (a width and a colour), `cn('flex', 'flex-1')` keeps both (a `display` and a `flex-grow`, the shape an element that is both a flex container and a flex child needs), and an arbitrary value carrying a type hint is read as the property the hint names (`cn('shadow-lg', 'shadow-[color:red]')` keeps both). It is a small hand-rolled merger, not `tailwind-merge`, so some prefixes are still grouped coarsely and a less common pair can collide (`bg-clip-text` against `bg-primary`, `shadow-lg` against `shadow-red-500`). When an override has to win and you are unsure, pass the one class rather than layering, or install `clsx` + `tailwind-merge` and replace the helper (its header comment shows the swap). For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size.
73
+ An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. Conflicts are keyed on the CSS PROPERTY wherever `cn` can tell the properties apart, rather than on the shared class prefix, so the common prefix collisions do NOT evict: `cn('border-2', 'border-primary')` keeps both (a width and a colour), `cn('flex', 'flex-1')` keeps both (a `display` and a `flex-grow`, the shape an element that is both a flex container and a flex child needs), `cn('shadow-lg', 'shadow-red-500')` keeps both (a box-shadow and its colour), `cn('bg-clip-text', 'bg-primary')` keeps both (a clip and a colour, so the gradient-text idiom survives a later background), and an arbitrary value carrying a type hint is read as the property the hint names (`cn('shadow-lg', 'shadow-[color:red]')` keeps both). It is a small hand-rolled merger, not `tailwind-merge`, so it is still coarse in two ways. A prefix outside the families it knows is not grouped at all, so both classes are emitted and the winner is left to compiled stylesheet order (`inset-shadow-sm` against `inset-shadow-red-500`, `ring-2` against `ring-red-500`). And where one prefix carries two properties it reads the value against Tailwind's DEFAULT scales, so a `@theme`-extended name it cannot know about can still be misread and evict the wrong class: a custom `--shadow-card` makes `shadow-card` a box-shadow, but `cn` sees an unfamiliar name under a prefix whose bare names are usually colours and treats it as one. When an override has to win and you are unsure, pass the one class rather than layering, or install `clsx` + `tailwind-merge` and replace the helper (its header comment shows the swap). For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size.
74
74
 
75
75
  ```ts
76
76
  // components/ui/button.ts (npx webjsdev ui add button, themed to your app)
@@ -170,6 +170,33 @@ A cross-runtime proof is often a plain assert script rather than a test file, so
170
170
  - **Assert the exit code, never the logs.** These scripts conventionally pass a `quiet` logger, so anything that only logs is invisible. If your script catches its own failure, report it with an explicit `process.exit(1)` rather than a `console.error` alone, guarded as `if (import.meta.main) process.exit(1); else throw failure;`. The guard matters when a `.test.mjs` wrapper imports the script under `node --test`: an unguarded exit kills the whole single-process run and hides every other file's results, while the throw lets the harness report one failed test.
171
171
  - **Prove the script can FAIL before you trust it passing.** Break one assertion on purpose and confirm the run exits non-zero. A proof that cannot go red is worse than no proof: it reports success forever.
172
172
 
173
+ ## Proving display-only elision did not break anything
174
+
175
+ WebJs strips the JavaScript of every component that does no client work, so a wrong verdict costs an app real interactivity and does it silently. Two commands cover the two halves, and you need both.
176
+
177
+ ```sh
178
+ webjs elision --verify
179
+ ```
180
+
181
+ renders every static page route with elision on and off and diffs the served bytes. It is the framework's own differential guard pointed at your route table, and it exits non-zero on a divergence AND on a corpus where nothing could be compared, so it belongs in CI. It forces the ON side on rather than reading your config, and reports how many modules elision actually dropped, so a pass that compared two identical renders is visible rather than silent. Dynamic routes are skipped by name; add real paths with `--routes /,/blog/hello`.
182
+
183
+ That proves the bytes you SERVE did not change. It cannot prove post-hydration behaviour, because a wrongly dropped module shows up as a dead click, not as different bytes. Run your own browser or e2e suite twice for that half:
184
+
185
+ ```sh
186
+ WEBJS_ELIDE=1 npm run test:e2e
187
+ WEBJS_ELIDE=0 npm run test:e2e
188
+ ```
189
+
190
+ A test that passes under one and fails under the other is a wrong verdict, and `webjs elision` tells you which module and on what evidence. If the component's interactivity is genuinely invisible to static analysis, the fix is `static interactive = true` on it; see `components.md` for what that override does and does not rescue.
191
+
192
+ ## Type-checking your tests (`webjs typecheck`)
193
+
194
+ Your tests are inside the tsconfig `include`, so `npm run typecheck` reads them (#1299). Treat a type error in a test as a failed gate, not a review catch: the checker sees a wrong argument shape or an unannotated parameter in a test the same way it sees one in `app/`.
195
+
196
+ Write them to the same bar as app code, then. No `any`, no blanket `@ts-expect-error`. When a test needs a complete props object the framework would normally build, put a small typed helper in `test/helpers/` and import it rather than reaching for a cast; a cast in a test silences the one thing that would have told you the call was wrong.
197
+
198
+ `.js` test files follow whatever `checkJs` says. With it off they are parsed and not checked, which is the usual setup for browser tests a real browser runs.
199
+
173
200
  ## Convention validation (`webjs check`)
174
201
 
175
202
  `npm run check` is the correctness validator. Every rule catches code that is wrong to ship, a crash, a security leak, a reactive prop that silently stops re-rendering, or a type-strip failure. Run it and fix every violation before considering the change done (`npm run check -- --json` for an agent loop, `npm run check -- --rules` to list the rules). It is separate from `CONVENTIONS.md`, which carries the customizable project conventions you follow by judgment.
@@ -62,19 +62,33 @@ Prefer explicit `.ts` extensions in imports. A `.js` specifier pointing at a `.t
62
62
  "module": "NodeNext",
63
63
  "moduleResolution": "NodeNext",
64
64
  "lib": ["ES2022", "DOM", "DOM.Iterable"],
65
+ "types": ["node"],
65
66
  "strict": true,
66
67
  "noEmit": true,
67
- "checkJs": true,
68
- "allowJs": true,
69
68
  "allowImportingTsExtensions": true,
70
69
  "skipLibCheck": true,
71
70
  "erasableSyntaxOnly": true
72
- }
71
+ },
72
+ "include": [
73
+ "app/**/*",
74
+ "components/**/*",
75
+ "modules/**/*",
76
+ "lib/**/*",
77
+ "test/**/*",
78
+ "middleware.js",
79
+ "middleware.ts",
80
+ ".webjs/routes.d.ts"
81
+ ],
82
+ "exclude": ["node_modules", ".webjs/vendor", "db/migrations"]
73
83
  }
74
84
  ```
75
85
 
76
86
  `erasableSyntaxOnly: true` is the non-negotiable line. It aligns the compiler's accepted syntax with the stripper's, so violations surface as diagnostics instead of a runtime 500.
77
87
 
88
+ `test/**/*` is in the `include` on purpose (#1299), the way Next / Remix / Astro's generated configs cover the whole tree. Leave it there. A test file outside the `include` is a file `webjs typecheck` never opens, so an implicitly-`any` parameter or a wrong argument shape in a test survives until somebody reads the line, which is exactly how one reached review here. Do not add a second `tsconfig.test.json` either: a config nobody remembers to run reproduces the same gap in a new place.
89
+
90
+ Note what is absent: `checkJs`, the flag mentioned at the top of this file for a JSDoc-typed codebase (it implies `allowJs`, so it is the only one you add). Turning it on makes `tsc` read your `.js` files, which is the point, but it also pulls in browser tests written as `.js`. Those run in a real browser through web-test-runner, so their test globals are not in scope for `tsc` and each one reports a `Cannot find name 'test'`. Turn it on deliberately, and give the browser tests a `types` entry or their own exclude when you do.
91
+
78
92
  ## Full-stack type safety
79
93
 
80
94
  ### The rule: derive the type, never `unknown` or `any`
@@ -6,16 +6,18 @@
6
6
  #
7
7
  # 1. U+2014 em-dash, anywhere.
8
8
  # 2. Space-hyphen-space " - " in PROSE contexts (comment lines, markdown
9
- # lines, headings, blockquotes). Math expressions in code like
9
+ # lines, headings, blockquotes, a JSON "description" / "title" /
10
+ # "displayName" string value, and a column-0 YAML front-matter
11
+ # description: / title: / displayName: line). Math expressions in code like
10
12
  # `Math.abs(a - b)` or `arr.length - 1` are NOT flagged.
11
- # 3. Space-semicolon-space " ; " in PROSE contexts. JS / CSS statement
12
- # terminators (`;\n`) are NOT flagged.
13
+ # 3. Space-semicolon-space " ; " in the same PROSE contexts as rule 2.
14
+ # JS / CSS statement terminators (`;\n`) are NOT flagged.
13
15
  # 4. Code-shaped left-hand side immediately followed by a colon and prose:
14
16
  # - `<code>foo()</code>:` (markdown code-LHS in docs)
15
17
  # - `<my-tag>:` (custom-element tag with hyphen)
16
18
  # - Inline comment `// foo(): description`
17
19
  #
18
- # Why this exists: see AGENTS.md "Invariants", item 10. These patterns
20
+ # Why this exists: see AGENTS.md "Invariants", item 11. These patterns
19
21
  # confuse AI agents that try to parse the prose as TypeScript / shorthand-
20
22
  # method / object-literal syntax, and trip humans reading API docs.
21
23
  #
@@ -46,8 +48,13 @@ if [ -z "$new_content" ]; then
46
48
  exit 0
47
49
  fi
48
50
 
51
+ # Every match below reads from a here-string, never a pipe. `grep -q` exits on
52
+ # the first match, which closes a pipe under `printf`, and with `set -o pipefail`
53
+ # that SIGPIPE became the pipeline status, so the rule silently skipped on any
54
+ # payload past the pipe buffer (measured: 0 of 8 blocks at 128 KB).
55
+
49
56
  # --- 1. U+2014 em-dash --------------------------------------------------
50
- if printf '%s' "$new_content" | grep -q $'\xe2\x80\x94'; then
57
+ if grep -q $'\xe2\x80\x94' <<< "$new_content"; then
51
58
  cat >&2 <<'EOF'
52
59
  BLOCKED: em-dash (U+2014) detected in this tool call.
53
60
 
@@ -57,7 +64,7 @@ restructured sentence. Do NOT replace it with " - " or " ; " or a
57
64
  trailing colon on code: those are also banned. See rule 2 / 3 / 4
58
65
  below for the alternatives.
59
66
 
60
- Rule: AGENTS.md, Invariants section, item 10.
67
+ Rule: AGENTS.md, Invariants section, item 11.
61
68
  Hook: .claude/hooks/block-prose-punctuation.sh.
62
69
  EOF
63
70
  exit 2
@@ -81,25 +88,42 @@ block_pause_hyphen=0
81
88
  # `*` (markdown bold-start would have a letter after, distinguishable),
82
89
  # followed by prose with `\w+ - \w+` pattern. Specifically: catch lines
83
90
  # like `// foo - bar`, ` * foo - bar`, `* foo - bar`.
84
- if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
91
+ if grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
85
92
  block_pause_hyphen=1
86
93
  fi
87
94
 
88
95
  # Markdown heading " - " pause: line starts with `#` followed by prose
89
96
  # and ` - ` pattern.
90
- if printf '%s\n' "$new_content" | grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
97
+ if grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
91
98
  block_pause_hyphen=1
92
99
  fi
93
100
 
94
101
  # Markdown blockquote " - " pause: line starts with `>` followed by prose
95
102
  # and ` - ` pattern. (Single `>` blockquote, not table.)
96
- if printf '%s\n' "$new_content" | grep -qE '^>[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
103
+ if grep -qE '^>[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
97
104
  block_pause_hyphen=1
98
105
  fi
99
106
 
100
107
  # HTML / markdown <p>, <li>, <td> body " - " pause: line contains a
101
108
  # closing HTML tag from a prose context, then prose-style ` - `.
102
- if printf '%s\n' "$new_content" | grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
109
+ if grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
110
+ block_pause_hyphen=1
111
+ fi
112
+
113
+ # JSON prose-value " - " pause: a string assignment whose KEY is one of the
114
+ # three prose-bearing keys this project's JSON uses. Scoping to the key is what
115
+ # keeps this off semver ranges, script commands, urls, paths and globs, every
116
+ # one of which lives under a different key. Shape, not file path: the Bash
117
+ # payload carries no file_path, so a heredoc writing a manifest is covered too.
118
+ if grep -qE '^[[:space:]]*"(description|title|displayName)"[[:space:]]*:[[:space:]]*".*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
119
+ block_pause_hyphen=1
120
+ fi
121
+
122
+ # YAML front-matter " - " pause, same three keys. Anchored at column 0 with no
123
+ # leading whitespace, which is what confines it to document front matter: every
124
+ # nested YAML mapping is indented, including the workflow-input `description:`
125
+ # values in .github/workflows/release.yml.
126
+ if grep -qE '^(description|title|displayName):[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
103
127
  block_pause_hyphen=1
104
128
  fi
105
129
 
@@ -118,13 +142,17 @@ restructured phrasing.
118
142
  Bad: <li>Foo - bar.</li>
119
143
  Good: <li>Foo, with bar.</li>
120
144
 
145
+ Bad: "description": "A library - for things"
146
+ Good: "description": "A library for things"
147
+
121
148
  Plain hyphens are still fine in compound words (`AI-first`), CLI
122
149
  flags (`--http2`), filenames, ranges, and math expressions in code
123
150
  (`arr.length - 1`, `Math.abs(a - b)`). The hook only flags the
124
151
  ` < word > - < word > ` pause-pattern in prose contexts (comments,
125
- markdown headings, blockquotes, HTML prose tags).
152
+ markdown headings, blockquotes, HTML prose tags, and a JSON or
153
+ front-matter description / title / displayName value).
126
154
 
127
- Rule: AGENTS.md, Invariants section, item 10.
155
+ Rule: AGENTS.md, Invariants section, item 11.
128
156
  Hook: .claude/hooks/block-prose-punctuation.sh.
129
157
  EOF
130
158
  exit 2
@@ -134,19 +162,29 @@ fi
134
162
  # Same prose-context guard as #2.
135
163
  block_pause_semicolon=0
136
164
 
137
- if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
165
+ if grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
138
166
  block_pause_semicolon=1
139
167
  fi
140
168
 
141
- if printf '%s\n' "$new_content" | grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
169
+ if grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
142
170
  block_pause_semicolon=1
143
171
  fi
144
172
 
145
- if printf '%s\n' "$new_content" | grep -qE '^>[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
173
+ if grep -qE '^>[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
146
174
  block_pause_semicolon=1
147
175
  fi
148
176
 
149
- if printf '%s\n' "$new_content" | grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
177
+ if grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
178
+ block_pause_semicolon=1
179
+ fi
180
+
181
+ # JSON prose-value " ; " pause, same three keys as rule 2.
182
+ if grep -qE '^[[:space:]]*"(description|title|displayName)"[[:space:]]*:[[:space:]]*".*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
183
+ block_pause_semicolon=1
184
+ fi
185
+
186
+ # YAML front-matter " ; " pause, column-0 anchored like rule 2.
187
+ if grep -qE '^(description|title|displayName):[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
150
188
  block_pause_semicolon=1
151
189
  fi
152
190
 
@@ -161,10 +199,14 @@ two sentences (period) or with a conjunction (", and", ", but", ", so").
161
199
  Good: // Forms work. Links work too.
162
200
  Good: // Forms work, and links work too.
163
201
 
202
+ Bad: "description": "Forms work ; links work too."
203
+ Good: "description": "Forms work. Links work too."
204
+
164
205
  Semicolons stay fine inside code (JS statement terminators, CSS
165
- declarations) since those are not flagged.
206
+ declarations) since those are not flagged. Only the space-surrounded
207
+ form is banned, so an ordinary English semicolon is untouched.
166
208
 
167
- Rule: AGENTS.md, Invariants section, item 10.
209
+ Rule: AGENTS.md, Invariants section, item 11.
168
210
  Hook: .claude/hooks/block-prose-punctuation.sh.
169
211
  EOF
170
212
  exit 2
@@ -175,7 +217,7 @@ fi
175
217
  # lowercase prose. The `)</code>:` shape is unambiguous: this is markdown,
176
218
  # not code, AND the inner code ends in `()` so the colon visually parses
177
219
  # as a return-type annotation.
178
- if printf '%s' "$new_content" | grep -qE '\)</code>:[[:space:]][a-z]'; then
220
+ if grep -qE '\)</code>:[[:space:]][a-z]' <<< "$new_content"; then
179
221
  cat >&2 <<'EOF'
180
222
  BLOCKED: code-LHS colon-then-prose detected ("<code>foo()</code>: ...").
181
223
 
@@ -186,7 +228,7 @@ parses as a TypeScript return-type annotation. Rewrite verb-led.
186
228
  Good: <code>repeat()</code> is the keyed list directive
187
229
  Good: <code>startServer()</code> creates an HTTP(S) server
188
230
 
189
- Rule: AGENTS.md, Invariants section, item 10.
231
+ Rule: AGENTS.md, Invariants section, item 11.
190
232
  Hook: .claude/hooks/block-prose-punctuation.sh.
191
233
  EOF
192
234
  exit 2
@@ -195,7 +237,7 @@ fi
195
237
  # --- 4b. Custom-element-tag <my-tag>: prose ------------------------------
196
238
  # HTML reserves hyphenated tag names for custom elements (W3C spec), so
197
239
  # `<x-y>:` is unambiguous prose, never JSX / TS / CSS.
198
- if printf '%s' "$new_content" | grep -qE '<[a-z][a-z0-9]*(-[a-z0-9]+)+([[:space:]][^>]*)?>:[[:space:]][a-z]'; then
240
+ if grep -qE '<[a-z][a-z0-9]*(-[a-z0-9]+)+([[:space:]][^>]*)?>:[[:space:]][a-z]' <<< "$new_content"; then
199
241
  cat >&2 <<'EOF'
200
242
  BLOCKED: custom-element-tag colon-then-prose detected ("<my-tag>: ...").
201
243
 
@@ -206,7 +248,7 @@ webjs bans `<my-tag>: <prose>` in comments and docs. Rewrite verb-led.
206
248
  Bad: // <ui-dialog-content>: the centered panel.
207
249
  Good: // <ui-dialog-content> is the centered panel.
208
250
 
209
- Rule: AGENTS.md, Invariants section, item 10.
251
+ Rule: AGENTS.md, Invariants section, item 11.
210
252
  Hook: .claude/hooks/block-prose-punctuation.sh.
211
253
  EOF
212
254
  exit 2
@@ -216,7 +258,7 @@ fi
216
258
  # Match comment-line prefix (`//` or leading `*`) before `\w+(...): ` and
217
259
  # lowercase prose. Avoids TS return-type annotations because those never
218
260
  # appear inside comment lines.
219
- if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]][^(]*[A-Za-z_][A-Za-z0-9_]*\([^)]*\):[[:space:]][a-z]'; then
261
+ if grep -qE '^[[:space:]]*(//|\*)[[:space:]][^(]*[A-Za-z_][A-Za-z0-9_]*\([^)]*\):[[:space:]][a-z]' <<< "$new_content"; then
220
262
  cat >&2 <<'EOF'
221
263
  BLOCKED: comment-line code-LHS colon-then-prose detected ("// foo(): ...").
222
264
 
@@ -227,7 +269,7 @@ webjs bans `xyz(): <prose>` inside comments and JSDoc. Rewrite verb-led.
227
269
  Bad: // closest(): null if the click wasn't inside a frame
228
270
  Good: // closest() returns null when the click wasn't inside a frame
229
271
 
230
- Rule: AGENTS.md, Invariants section, item 10.
272
+ Rule: AGENTS.md, Invariants section, item 11.
231
273
  Hook: .claude/hooks/block-prose-punctuation.sh.
232
274
  EOF
233
275
  exit 2
@@ -16,8 +16,9 @@ export class ServerClock extends WebComponent {
16
16
  // serves it with ZERO JavaScript and skips the redundant on-hydration
17
17
  // re-fetch. This is the common fetch-and-display leaf shape. If you ever need
18
18
  // to force a component to ship when the analyser would elide it (for
19
- // interactivity static analysis cannot see, like a dynamically-computed tag
20
- // string), declare `static interactive = true`.
19
+ // interactivity static analysis cannot see, like an observer that computes
20
+ // the tag it waits for), declare `static interactive = true`. Run
21
+ // `webjs elision` to see the verdict for every component in this app.
21
22
  async render() {
22
23
  const info = await serverGreeting();
23
24
  return html`<p class="font-mono text-sm">server rendered this at
@@ -31,7 +31,10 @@ export class DirectiveDemo extends WebComponent {
31
31
  // A controlled value (for `live`) and a key (for `keyed`).
32
32
  private text = signal('type here');
33
33
  private variant = signal(0);
34
- // A handle to the input node, attached by `ref` in the browser.
34
+ // A handle to the input node, attached by `ref` in the browser. A ref rather
35
+ // than a `querySelector` because the template already owns the node, so the
36
+ // handle flows out of `render()` instead of being re-found by a selector that
37
+ // could match someone else's markup (or nothing at all after a rename).
35
38
  private inputRef = createRef<HTMLInputElement>();
36
39
  // Created ONCE (not per render), so `until` keeps the resolved value across
37
40
  // re-renders instead of flashing back to the fallback each time.
@@ -6,6 +6,11 @@
6
6
  // active item from location.pathname, so the highlight follows soft-nav. SSR is
7
7
  // still correct: `render()` reads the `current` prop (the pathname the layout
8
8
  // passes) for the first paint, and the client takes over from location after.
9
+ // The document LISTENER below is the legitimate case, not a reach across the
10
+ // app: the router has no element to dispatch from, and the handler reads only
11
+ // location.pathname and writes only this module's own signal, so it queries
12
+ // nothing outside itself. Querying the document for another file's markup is
13
+ // the shape to avoid.
9
14
  import { WebComponent, prop, html, signal } from '@webjsdev/core';
10
15
  import { FEATURE_GROUPS } from '#modules/gallery/nav.ts';
11
16
 
@@ -12,9 +12,11 @@ import { deleteTodo } from './delete-todo.server.ts';
12
12
  // action: this form carries the todo's `id` on a hidden input and needs the
13
13
  // SAME id for whichever mutation runs, so one action reading both fields is the
14
14
  // simpler shape. When the buttons need no shared payload, bind each one
15
- // directly instead, with `formaction=${action}` on a <button> inside the bound
16
- // form. The identity then rides that button's own name/value pair, so it works
17
- // with JS off too. Two things to know: it must be a <button> (on an
15
+ // directly instead, with `formaction=${action}` on a <button>. The enclosing
16
+ // <form> does NOT have to be bound: a bound submitter carries its own
17
+ // `formmethod` and enctype, so it works in any form or none. The identity rides
18
+ // that button's own name/value pair, so it works with JS off too. Two things to
19
+ // know: it must be a <button> (on an
18
20
  // <input type="submit"> the identity would occupy `value`, which is also that
19
21
  // control's visible label), and a bound submitter cannot carry its own
20
22
  // `name`/`value`, which is exactly the channel `name="intent"` uses below.
@@ -20,6 +20,13 @@ import { createServer } from 'node:net';
20
20
  // minimal structural types keep the file typed in the meantime; swap them for
21
21
  // the real imports once puppeteer-core is in package.json. Reaching for `any`
22
22
  // here would silently un-type every call below.
23
+ //
24
+ // They also stay in force when the package IS present, which is the case a
25
+ // generated app usually hits, since @web/test-runner pulls puppeteer-core in
26
+ // transitively. The real Page / Browser are far richer than these, so letting
27
+ // them flow in would fail against the narrow shapes here for the goto return
28
+ // type and the event-handler signature. The single import below is the one
29
+ // boundary where that is resolved, and it is the only suppressed line.
23
30
  type Page = {
24
31
  // goto resolves an HTTPResponse this file never reads, and modelling that
25
32
  // type would mean re-declaring puppeteer's. Returning void is the honest
@@ -30,6 +37,10 @@ type Page = {
30
37
  removeAllListeners(event: string): void;
31
38
  };
32
39
  type Browser = { newPage(): Promise<Page>; close(): Promise<void> };
40
+ // The module's own default export, narrowed to the one call this file makes.
41
+ type Puppeteer = {
42
+ launch(opts: { executablePath?: string; headless?: boolean; args?: string[] }): Promise<Browser>;
43
+ };
33
44
 
34
45
  let browser: Browser, page: Page, serverProcess: ChildProcess, baseUrl: string;
35
46
 
@@ -46,9 +57,23 @@ function freePort(): Promise<number> {
46
57
  }
47
58
 
48
59
  before(async () => {
49
- let puppeteer;
60
+ let puppeteer: Puppeteer | undefined;
61
+ // The next line is where the optional dependency enters, and what it reports
62
+ // depends on whether puppeteer-core is installed: an unresolved specifier
63
+ // when it is absent, a type mismatch against the structural shapes above
64
+ // when it is present. Suppressing it keeps the rest of the file checked
65
+ // against those shapes either way.
66
+ //
67
+ // It is deliberately ts-ignore rather than the expect-error directive, whose
68
+ // name is spelled out here rather than written, because a comment line
69
+ // starting with that token IS a live directive to tsc even inside prose. The
70
+ // expect-error form is wrong on its own merits too: it errors when there is
71
+ // nothing to suppress, so it would break whenever the package resolves
72
+ // cleanly.
73
+ // @ts-ignore
50
74
  try { puppeteer = (await import('puppeteer-core')).default; }
51
75
  catch { console.log('# Skipping: puppeteer-core not installed'); return; }
76
+ if (!puppeteer) return;
52
77
 
53
78
  const port = await freePort();
54
79
  baseUrl = `http://localhost:${port}`;