@webjsdev/mcp 0.1.13 → 0.1.15
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.
- package/README.md +63 -6
- package/package.json +1 -1
- package/resources/AGENTS.md +100 -36
- package/resources/SKILL.md +59 -20
- package/resources/corpus.json +3 -3
- package/resources/references/auth-and-sessions.md +15 -0
- package/resources/references/built-ins.md +96 -4
- package/resources/references/client-router-and-streaming.md +54 -5
- package/resources/references/components.md +62 -6
- package/resources/references/data-and-actions.md +32 -3
- package/resources/references/module-structure.md +229 -0
- package/resources/references/muscle-memory-gotchas.md +55 -5
- package/resources/references/optimistic-ui.md +23 -11
- package/resources/references/routing-and-pages.md +124 -5
- package/resources/references/runtime.md +24 -5
- package/resources/references/styling.md +62 -4
- package/resources/references/testing.md +13 -0
- package/resources/references/ui-kit.md +54 -3
- package/src/mcp-docs.js +222 -19
- package/src/mcp-source.js +148 -6
- package/src/mcp.js +77 -25
|
@@ -30,7 +30,7 @@ Most of what follows restates widely held component-model advice, ported. Lit's
|
|
|
30
30
|
|
|
31
31
|
**4. ARIA state is a hole in `render()`, derived from the same state that drives behaviour.** `aria-expanded=${this.open ? 'true' : 'false'}` cannot disagree with `this.open`. A second function that re-finds the button and calls `setAttribute` can, and does, the first time someone adds a close path that forgets to call it. The same holds for `class`, `?disabled`, and any `.prop`. Two caveats ride this rule:
|
|
32
32
|
|
|
33
|
-
-
|
|
33
|
+
- A plain-attribute hole follows one rule on the server and the client (#1573): `null` / `undefined` omit the attribute, `false` omits it except on an `aria-*` name, where it is written as `"false"`, and `true` on an HTML boolean attribute (`checked`, `selected`, `disabled`, ...) writes the empty value like `?attr` (#1579). So `checked=${isDefault}` and `selected=${i === 0}` behave exactly like `?checked` / `?selected`, though `?attr` stays the clearer spelling for a boolean attribute. So `aria-expanded=${this.open}` serves `"true"` / `"false"` and hydrates unchanged, and `aria-current=${active ? 'page' : null}` omits the attribute on an inactive link. `?attr=${bool}` is not a substitute for a tri-state ARIA attribute, since a boolean binding omits the attribute when false.
|
|
34
34
|
- A hole commits on the next render, one microtask later. The one place a direct write is still correct is a synchronous snapshot read such as `webjs:before-cache`, where the router reads `outerHTML` in the same task. That is a documented exception, not the normal path.
|
|
35
35
|
|
|
36
36
|
**5. Behaviour needs an importable surface, or its test is a copy of it.** An inline `<script>` in a layout has no module identity, so a browser test cannot import it. It can only transcribe the listener into the test file and assert against the transcription, which then needs a SECOND test to grep the original for drift. Two tests, neither running shipping code. A component is importable, so its browser test mounts the real element and drives real events. A page or layout may still carry an inline `<script>`, but only for pre-paint boot work no module can do: reading a stored theme before first paint so the wrong palette never flashes, or measuring the header height into a CSS custom property. It must not be interactivity, and WHERE it sits decides how often it runs. The ROOT layout's markup sits OUTSIDE every swap range, so a soft navigation does not re-run its script, which is what makes it the right home for boot work and the wrong home for anything that has to respond to a later navigation. A page or a NESTED layout sits inside the swap range instead, so its script re-executes on every navigation that swaps that range (#1102), which means it has to be idempotent or guard on a flag it sets the first time. Neither shape gives you a listener that simply works, which is what a custom element is for. Under an opt-in CSP the script also needs the nonce from `cspNonce()`. `client-router-and-streaming.md` carries the full re-execution rule.
|
|
@@ -101,6 +101,44 @@ NavDrawer.register('nav-drawer');
|
|
|
101
101
|
|
|
102
102
|
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".
|
|
103
103
|
|
|
104
|
+
## Sizing an island: own the behaviour, not the section
|
|
105
|
+
|
|
106
|
+
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.
|
|
107
|
+
|
|
108
|
+
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.
|
|
109
|
+
|
|
110
|
+
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.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
// TOO BIG. One button's worth of behaviour, a whole page's worth of markup.
|
|
114
|
+
class ProductPage extends WebComponent({ product: prop<Product>(Object) }) {
|
|
115
|
+
render() {
|
|
116
|
+
return html`
|
|
117
|
+
<h1>${this.product.name}</h1>
|
|
118
|
+
<p>${this.product.description}</p>
|
|
119
|
+
<spec-table .rows=${this.product.specs}></spec-table>
|
|
120
|
+
<button @click=${() => addToCart(this.product.id)}>Add to cart</button>
|
|
121
|
+
<review-list .reviews=${this.product.reviews}></review-list>
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
// RIGHT SIZE. The page renders the static markup. The island is the button.
|
|
129
|
+
class AddToCart extends WebComponent({ productId: String }) {
|
|
130
|
+
render() {
|
|
131
|
+
return html`<button @click=${() => addToCart(this.productId)}>Add to cart</button>`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The first version ships three modules instead of one: itself, plus `<spec-table>` and `<review-list>`, 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.
|
|
137
|
+
|
|
138
|
+
**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".
|
|
139
|
+
|
|
140
|
+
**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.
|
|
141
|
+
|
|
104
142
|
## Reactive properties: the base-class factory
|
|
105
143
|
|
|
106
144
|
Reactive properties are declared by passing their shape into `WebComponent({ ... })`. The types flow automatically to `this.<prop>`, so there is NO `static properties` block and NO `declare` line (a `static properties` block throws at runtime, caught by `no-static-properties`).
|
|
@@ -135,17 +173,21 @@ The bare form is shorthand: `count: Number` means `prop(Number)`. Use `prop()` t
|
|
|
135
173
|
|---|---|---|
|
|
136
174
|
| `type` | `String` | Constructor feeding the default attribute converter |
|
|
137
175
|
| `reflect` | `false` | Property changes write back to the HTML attribute (a value with no attribute representation removes it instead, see below) |
|
|
138
|
-
| `state` | `false` | Internal-only. No attribute, not observed |
|
|
176
|
+
| `state` | `false` | Internal-only. No attribute, not observed, and never read from one at SSR either |
|
|
139
177
|
| `attribute` | derived from name | The HTML attribute name the property rides |
|
|
140
178
|
| `default` | none | Declarative initial value (a function runs per instance for a fresh object / array) |
|
|
141
179
|
| `hasChanged` | strict `!==` | Custom change detection |
|
|
142
|
-
| `converter` | type-based | Custom attribute-to-property serialization |
|
|
180
|
+
| `converter` | type-based | Custom attribute-to-property serialization. `fromAttribute` runs on BOTH readers (the client upgrade and SSR), ahead of type coercion |
|
|
143
181
|
|
|
144
182
|
For an array-typed prop pass `Array`, not `Object` (`array-prop-uses-array-type` flags the `Object` form). For anything the built-in converters cannot parse (Date, Map, Set) supply a `converter`.
|
|
145
183
|
|
|
184
|
+
**A `converter.fromAttribute` runs SERVER-SIDE too.** Both attribute readers go through one shared implementation, so the converter fires during SSR as well as on the client upgrade, and it wins over the declared `type` on both. So keep it free of browser globals (`document`, `window`, `navigator`), since SSR has no DOM and touching one throws where the same code worked in the browser. A converter that THROWS is not caught by either reader, because an author who writes one owns the conversion: at SSR the throw lands in per-component error isolation (an error box in dev, an empty element at a 200 in production, with the cause in the server log) while sibling components still render, and on the client it escapes `attributeChangedCallback` during upgrade. Both readers hand the converter DECODED attribute text, so a converter that parses its input (`JSON.parse` for a Map or a Set, `new Date(...)`) sees the same string on both sides even when the attribute carries `"` or `&`.
|
|
185
|
+
|
|
146
186
|
**A `reflect: true` property holding a FUNCTION drops its attribute instead of writing one, and so does one holding an array that carries a function, unless the prop is `Object` or `Array` typed.** A function has no HTML attribute representation, and the serializations it would otherwise get are both useless and dangerous. `String(fn)` is the function's SOURCE, so a reflected `'use server'` action would ship its whole body, closure secrets included, to every visitor, and `JSON.stringify(fn)` is `undefined`, which lands in the attribute as the literal four-character string. So the reflection path treats a function like `null`, removes the attribute, and warns naming the property, the tag, and the attribute. This holds on both sides, since SSR and the client-side setter run the same path, and it holds for every property name (the leak was never specific to one called `action`). Two exceptions. A property with a custom `converter.toAttribute` runs that converter first and is left alone, because an author who writes one has taken responsibility for serializing whatever they are handed. And an `Object` or `Array` typed property CARRYING a function keeps its data, because `JSON.stringify` drops the function to `null` and omits the key, so `[1, 2, fn]` reflects as `[1,2,null]` with no source and nothing else lost. If you need a function on a component, use a plain property or a signal and do not mark it `reflect`.
|
|
147
187
|
|
|
148
|
-
**An `Object` or `Array` typed reflected property whose value `JSON.stringify` cannot serialize AT ALL drops its attribute the same way, and warns.** Three shapes do this: a cycle (an object or array that reaches itself, which arrives from a parent/child graph, a linked node, a memo table, or anything a library hands back with a back-reference), a `BigInt` anywhere inside the value, and an author `toJSON()` that throws. The line to keep straight is that a value which serializes WITH A GAP in it keeps its data (the carried-function case above), while one that does not serialize at all has no string to put in the attribute and so has no attribute representation, exactly like a function. The property itself is untouched and still holds the value; only the attribute goes. Before this guard the throw escaped reflection entirely, which meant a client upgrade threw before the component's first render, and an SSR render was swallowed by per-component error isolation, which shows an error box in dev and renders the component EMPTY on a page that still returned 200 in production. To reflect something about a graph-shaped value, reflect a derived scalar (an id, a count) and keep the graph on a non-reflected property. On the read side an attribute that is PRESENT but not parseable JSON reads back as `null` rather than as the raw string, on both the SSR and the client reader. An ABSENT attribute is a different case: neither reader sees it, so the property keeps its constructor value.
|
|
188
|
+
**An `Object` or `Array` typed reflected property whose value `JSON.stringify` cannot serialize AT ALL drops its attribute the same way, and warns.** Three shapes do this: a cycle (an object or array that reaches itself, which arrives from a parent/child graph, a linked node, a memo table, or anything a library hands back with a back-reference), a `BigInt` anywhere inside the value, and an author `toJSON()` that throws. The line to keep straight is that a value which serializes WITH A GAP in it keeps its data (the carried-function case above), while one that does not serialize at all has no string to put in the attribute and so has no attribute representation, exactly like a function. The property itself is untouched and still holds the value; only the attribute goes. Before this guard the throw escaped reflection entirely, which meant a client upgrade threw before the component's first render, and an SSR render was swallowed by per-component error isolation, which shows an error box in dev and renders the component EMPTY on a page that still returned 200 in production. To reflect something about a graph-shaped value, reflect a derived scalar (an id, a count) and keep the graph on a non-reflected property. On the read side an attribute that is PRESENT but not parseable JSON reads back as `null` rather than as the raw string, on both the SSR and the client reader. An ABSENT attribute is a different case: neither reader sees it, so the property keeps its constructor value. The two readers also see the same attribute SET, not merely the same fallback: a `state: true` prop and an attribute matching no declared property are ignored by both, and a camelCase attribute name reaches its prop on both through the lowercased alias, and both are handed a value whose HTML character references are already decoded.
|
|
189
|
+
|
|
190
|
+
**Writing attributes in markup.** Names are case-insensitive and the browser lowercases them while parsing, so a camelCase prop answers to BOTH its kebab-case name (`user-name`, the one reflection writes) and its lowercased name (`username`, lit's default), which is what a camelCase attribute in markup (`userName="…"`) arrives as; all three spellings reach `userName` on both sides. Prefer kebab-case. A prop that renames its attribute answers to the new name ONLY, so `open: prop(Boolean, { attribute: 'is-open' })` is written `<my-el is-open>` and `<my-el open>` reaches nothing. Character references are decoded before the value is coerced, so `cfg="{"a":1}"` parses as the object it spells and `label="Tom & Jerry"` is `Tom & Jerry`; the legacy semicolon-less forms decode exactly where a browser decodes them (` ` at the end of a value is a non-breaking space, ` x` and ` =x` stay literal), and writing the semicolon avoids the question. A `state: true` prop takes an SSR value only through a `.prop=${value}` binding from the parent template, never from an attribute.
|
|
149
191
|
|
|
150
192
|
**Never use a class-field declaration OR initializer** (`count = 0`, `student: Student = {...}`, `todos!: Todo[]`). Under `useDefineForClassFields` even a type-only `todos!: Todo[]` compiles to define an own property after `super()`, which clobbers the prototype's reactive accessor and silently breaks reactivity. Only declare props in the factory and read/write them off `this`. The `reactive-props-no-class-field` rule catches this.
|
|
151
193
|
|
|
@@ -340,6 +382,18 @@ Import from `@webjsdev/core/directives`. Everything a `class`/`style`/conditiona
|
|
|
340
382
|
| `asyncAppend(iter)` / `asyncReplace(iter)` | Stream from an async iterable, appending each value or replacing with the latest. |
|
|
341
383
|
| `templateContent(el)` | Render the content of a `<template>` element. |
|
|
342
384
|
|
|
385
|
+
Every directive here is CLIENT behaviour. At SSR the server renders one shot, so
|
|
386
|
+
`guard` always invokes its function, `watch` reads its signal once and inlines
|
|
387
|
+
the value, and `live` is fully transparent, resolving to the value it wraps in
|
|
388
|
+
every hole position (a child, a plain attribute, a `?bool`, a `.prop`). So
|
|
389
|
+
`?open=${live(false)}` omits its attribute exactly as `?open=${false}` does. It
|
|
390
|
+
was previously resolved only in a child hole, which served `open=""` and let
|
|
391
|
+
hydration close the element a moment later (#1443).
|
|
392
|
+
|
|
393
|
+
## Lazy components: load on first sight
|
|
394
|
+
|
|
395
|
+
`static lazy = true` defers a component's module until an element with its tag is first visible: scrolled within 200px of the viewport, or shown when a `hidden` tab panel or a closed `<dialog>` around it opens. Reach for it for heavy panes and dialogs a first screen does not show (an editor behind a Code tab, a data browser, a settings dialog). It stays lazy when the component that renders it imports it (`import './code-pane.ts'`): the server keeps that import for SSR, and the browser copy of the importer gets an `observeLazy` registration in its place, with no preload for the lazy subtree (#1524). Only a SIDE-EFFECT import defers; a binding import (`import { CodePane } from ...`) stays eager, and one anywhere in the app makes that component eager everywhere. Until the module arrives the element is not upgraded, so a parent calling into it uses optional calls (`this.#pane.value?.save?.()`). The loader scans light DOM only, so do not render a lazy tag inside a shadow root. Browser tests (`webjs test --browser`) get every import as written, so a test that imports a lazy component has it defined at once. A component opened by a window event rather than by becoming visible (a panel that renders nothing until an `open` event) does not fit `static lazy`: dynamically `import()` its module in the code that dispatches the event, then dispatch.
|
|
396
|
+
|
|
343
397
|
## Display-only elision
|
|
344
398
|
|
|
345
399
|
A component that does no client-side work renders the same SSR'd HTML with or without its JS, so WebJs strips its import from the served source (and any vendor reachable only through it). This is automatic and conservative. A component stays elidable while it has NONE of:
|
|
@@ -348,10 +402,12 @@ A component that does no client-side work renders the same SSR'd HTML with or wi
|
|
|
348
402
|
- a factory-declared reactive property that is not `{ state: true }`
|
|
349
403
|
- an overridden lifecycle hook (including `renderFallback` / `renderError`)
|
|
350
404
|
- an imported `signal` / `computed` / `watch` / `Task` / `ref` / streaming directive, or `addController` / `requestUpdate`
|
|
351
|
-
- code that runs at module load (a top-level call, non-data `new`, dynamic `import(...)`, top-level `await`); only declarations and `X.register(...)` are allowed
|
|
405
|
+
- code that runs at module load (a top-level call, non-data `new`, dynamic `import(...)`, top-level `await`); only declarations and `X.register(...)` are allowed. A call inside a declaration does not run at load, so it is not a blocker: an arrow's expression body (`export const usd = (m) => Math.round(m) / MICROS`) and a function parameter default (`function relativeTime(when, now = Date.now())`) are both fine. TypeScript types are erased before the analyser reads a module, so an annotation can never be a blocker however call-shaped it looks (`readonly (readonly [number, number, number])[]` is fine)
|
|
352
406
|
- the dynamic slot READ surface (`slotchange`, `assignedNodes` / `assignedElements` / `assignedSlot`); merely RENDERING a `<slot>` 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)
|
|
353
407
|
- being rendered by a component that itself ships
|
|
354
408
|
|
|
409
|
+
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.
|
|
410
|
+
|
|
355
411
|
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`.
|
|
356
412
|
|
|
357
413
|
### What `static interactive = true` does and does not rescue
|
|
@@ -410,4 +466,4 @@ A `WebComponent` inherits `HTMLElement` (browser) or an `ElementShim` (SSR) plus
|
|
|
410
466
|
- HTMLElement / Element: `title`, `id`, `slot`, `role`, `hidden`, `dir`, `lang`, `translate`, `draggable`, `tabIndex`, `className`, `dataset`, `remove`, `closest`, `matches`, `focus`, `blur`, `click`, `append` / `prepend`, `before` / `after`. Rename (`postTitle`, `removeItem`, `handleClick`).
|
|
411
467
|
- WebComponent base: `render`, `update`, `requestUpdate`, `updated` / `firstUpdated`, `willUpdate` / `shouldUpdate`, `connectedCallback`, `renderError` / `renderFallback`, `addController` / `removeController`, `updateComplete` (#1021: there is no WebJs slot API to override; slots are native). Only override one deliberately, with its exact signature; never repurpose the name for app logic.
|
|
412
468
|
|
|
413
|
-
Framework-private fields are underscore-prefixed (`_renderRoot`, `_connected`, `_changedProperties`, `_updatePromise`, `_isUpdating`); never declare a prop or field that matches one. Safe, non-inherited names: `label`, `open`, `count`, `value`, `name`, `items`, `todos`, `active`, `variant`, `size`, `checked`, `selected`, `heading`, `message`, `status`. When in doubt, grep the base surface in `node_modules/@webjsdev/core/src/component.js
|
|
469
|
+
Framework-private fields are underscore-prefixed (`_renderRoot`, `_connected`, `_changedProperties`, `_updatePromise`, `_isUpdating`); never declare a prop or field that matches one. Safe, non-inherited names: `label`, `open`, `count`, `value`, `name`, `items`, `todos`, `active`, `variant`, `size`, `checked`, `selected`, `heading`, `message`, `status`. When in doubt, grep the base surface in `node_modules/@webjsdev/core/src/component.js` and its sibling `component/` directory, where the class body actually lives.
|
|
@@ -139,6 +139,8 @@ The renderer omits the `action` attribute so the form posts to the page's own ur
|
|
|
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
|
|
|
142
|
+
A failing form action does not need to echo the submission back. On the 422 re-render `actionData.values` already carries every submitted text field (any `values` the action returns are layered on top), and with JavaScript on the client router restores what was typed into every control the re-render did not explicitly set (#1581). Return `values` only to normalize or blank a field; see `routing-and-pages.md` for the page side.
|
|
143
|
+
|
|
142
144
|
Everything the action declares applies here too, or an action would be protected over RPC and open over a form:
|
|
143
145
|
|
|
144
146
|
- `validate` runs on the submitted `FormData`.
|
|
@@ -146,15 +148,41 @@ Everything the action declares applies here too, or an action would be protected
|
|
|
146
148
|
- `invalidates` is evicted when the action actually RAN (a middleware short-circuit does not evict), and the evicted tags are reported on the response so the browser's tag coordinator bypasses a stale cached GET. One reach limit: `fetch` follows the success `303` transparently, so JS cannot read a redirect's headers; the tags are on the wire and the `422` re-render carries them, and the redirect's own render is server-side and seeds fresh data.
|
|
147
149
|
- `invalidates` and `tags` receive the SAME first argument the action does, so on a form boundary they receive the `FormData`. `invalidates: (input) => ['post:' + input.id]` returns `post:undefined` for a submission and evicts nothing. Either read the field (`(fd) => ['post:' + fd.get('id')]`), declare a `validate` that transforms the `FormData` into the typed input first (the transform result is what the config functions then see), or use an argument-independent tag.
|
|
148
150
|
- `method = 'GET'` cannot be bound to a form: a GET action rides its args in the url and is CSRF-exempt, so it cannot answer a form POST. That is a `405` at runtime and the `form-action-not-a-get-action` error in `webjs check`.
|
|
151
|
+
- A form whose buttons run DIFFERENT actions binds each on its submitter, `<button formaction=${publishDraft}>`. **The submitter is self-sufficient** (#1307): the renderer gives it `formmethod="post"` and `formenctype` ON THE BUTTON, alongside the identity riding the button's own `name`/`value` pair, and a submitter's `formmethod` overrides the form's `method` per HTML. 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, and the enclosing form does not need binding for the button's sake. Bind the form when the FORM itself should run an action on a plain submit. In dev the client logs one `console.error` at submit time for a submission holding an identity it cannot deliver, and in production both server-visible fingerprints reach `onError` with a code (`WEBJS_FORM_SUBMITTED_AS_GET` for an identity in the query string, `WEBJS_FORM_ACTION_MISSING` for a body carrying no identity). See `muscle-memory-gotchas.md` for the shape.
|
|
149
152
|
|
|
150
153
|
The response drives the page: a success is a `303` PRG (to `result.redirect` when it is a same-site local path, else the page's own url), a failure re-renders the SAME page with `status` (default `422`) and the result on `actionData`, a submission carrying no identity is a `405`, and one whose hash no longer resolves is a `422` with a resubmit message (a form held open across a deploy). The submission is Origin-verified like an RPC call, so no token field is needed.
|
|
151
154
|
|
|
152
155
|
A streamed return (#489) is refused from a form-bound action: the RPC stub decodes frames, but a submission is answered with a redirect or a page, and with JS off there is no consumer at all. Stream from a programmatic call instead.
|
|
153
156
|
|
|
154
|
-
## HTTP-verb config exports
|
|
157
|
+
## HTTP-verb config exports & decision guide
|
|
155
158
|
|
|
156
159
|
A `'use server'` action is a POST by default. Reserved sibling exports, read statically (the same way a page reads `export const revalidate`), change its HTTP semantics WITHOUT changing the call site (you still write `await getUser(7)`).
|
|
157
160
|
|
|
161
|
+
### HTTP Verbs Decision Guide
|
|
162
|
+
|
|
163
|
+
| Action Kind | Target Verb | Example Declaration | HTTP Semantics & Features |
|
|
164
|
+
|---|---|---|---|
|
|
165
|
+
| **Form-Bound Action** (`<form action=${fn}>`) | **POST** (default) | *(no export or `export const method = 'POST'`) | Standard HTML form submission. Enforces `POST` + `multipart/form-data` or `urlencoded`. **Never export `method = 'GET'`** (triggers 405 refusal & `webjs check` violation). |
|
|
166
|
+
| **RPC Read Action (Query)** | **GET** | `export const method = 'GET'` | Read-only RPC calls (`await getTodos()`). Args ride URL query params (with POST fallback over 4KB). CSRF-exempt, supports ETags, 304 revalidation, and `export const cache`. |
|
|
167
|
+
| **RPC Write Action (Mutation)** | **POST** / **PUT** / **PATCH** / **DELETE** | Default or `export const method = 'DELETE'` | Data-modifying RPC calls (`await deleteUser(4)`). Carries CSRF protection, serialized payload body, and evicts cached query tags via `export const invalidates`. |
|
|
168
|
+
|
|
169
|
+
### Choosing the right HTTP verb
|
|
170
|
+
|
|
171
|
+
1. **Form-Bound Actions (`<form action=${fn}>` / `<button formaction=${fn}>`):**
|
|
172
|
+
- **MUST be POST.** Leave unannotated (default) or export `export const method = 'POST'`.
|
|
173
|
+
- **NEVER export `export const method = 'GET'` for form actions.** The HTML renderer automatically emits `method="post"` and `formenctype` for form actions. Binding a `method = 'GET'` action to a form returns a `405 Method Not Allowed` at runtime and triggers a `webjs check` error (`form-action-not-a-get-action`).
|
|
174
|
+
- **NEVER add `method="get"` to a bound `<form action=${fn}>`.** WebJs manages form submission semantics automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read.
|
|
175
|
+
|
|
176
|
+
2. **Programmatic / RPC Read Actions (Queries):**
|
|
177
|
+
- **ALWAYS export `export const method = 'GET'` for read-only queries.**
|
|
178
|
+
- When an action only fetches data (`await getUser(id)`), exporting `method = 'GET'` instructs the client RPC stub to issue an HTTP GET request with arguments encoded in query parameters.
|
|
179
|
+
- Enables browser/CDN caching, weak ETags (returning 304 Not Modified on cache hit), and HTTP `Cache-Control` header generation when paired with `export const cache = ...`.
|
|
180
|
+
|
|
181
|
+
3. **Programmatic / RPC Write Actions (Mutations):**
|
|
182
|
+
- **Use POST, PUT, PATCH, or DELETE for writes.**
|
|
183
|
+
- Use default `POST` or explicitly export `PUT`/`PATCH`/`DELETE` for RESTful RPC calls (`await removeUser(id)`).
|
|
184
|
+
- Pair mutating actions with `export const invalidates = (args...) => ['tag']` to evict cached reads matching those tags upon completion.
|
|
185
|
+
|
|
158
186
|
```ts
|
|
159
187
|
// modules/users/queries/get-user.server.ts: a cached, tagged GET read
|
|
160
188
|
'use server';
|
|
@@ -165,8 +193,9 @@ export async function getUser(id: number) { return db.query.users.findFirst({ wh
|
|
|
165
193
|
```
|
|
166
194
|
|
|
167
195
|
```ts
|
|
168
|
-
// a mutation
|
|
196
|
+
// modules/users/actions/update-user.server.ts: a mutation evicting matching tags
|
|
169
197
|
'use server';
|
|
198
|
+
export const method = 'PATCH'; // explicit verb
|
|
170
199
|
export const invalidates = (id: number) => ['user:' + id];
|
|
171
200
|
export const middleware = [requireAuth]; // async (ctx, next) => result; read ctx via actionContext()
|
|
172
201
|
export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
|
|
@@ -179,7 +208,7 @@ export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
|
|
|
179
208
|
|
|
180
209
|
### Cancellation with `actionSignal()`
|
|
181
210
|
|
|
182
|
-
Inside an action, `actionSignal()` from `@webjsdev/server` returns the request's `AbortSignal`. It fires when the client disconnects OR when a newer client render supersedes this one (the RPC stub aborts the previous in-flight fetch). Thread it into the work you start, and re-check it after an await to map an abort to a cancelled envelope:
|
|
211
|
+
Inside an action, `actionSignal()` from `@webjsdev/server` returns the request's `AbortSignal`. It fires when the client disconnects OR when a newer client render supersedes this one (the RPC stub aborts the previous in-flight fetch). Only an action called in the synchronous part of a component's own `render()` is tied to that render. One called from `connectedCallback`, an event handler, `firstUpdated()` / `updated()`, or a `Task` is never cancelled by a re-render, including a child element's `connectedCallback` that runs while its parent's template is being committed, so a child can start its first fetch there without the parent's next render cancelling it. Thread it into the work you start, and re-check it after an await to map an abort to a cancelled envelope:
|
|
183
212
|
|
|
184
213
|
```ts
|
|
185
214
|
'use server';
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# Module structure: file size, design principles, and splitting a large module
|
|
2
|
+
|
|
3
|
+
Read this before splitting a large source file, and before arguing about how
|
|
4
|
+
big a module is allowed to be.
|
|
5
|
+
|
|
6
|
+
Two things live here. The first is what "well structured" means in this repo,
|
|
7
|
+
which is mostly judgment rather than a number. The second is the mechanical
|
|
8
|
+
procedure for barrelling a large module into a directory, which is NOT judgment:
|
|
9
|
+
it has a small number of failure modes that are silent, and every one of them
|
|
10
|
+
has bitten this codebase already.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Design principles: judgment, not a checker
|
|
15
|
+
|
|
16
|
+
SOLID, DRY, and KISS apply here the way they apply anywhere. They are prose
|
|
17
|
+
guidance, followed by judgment, and deliberately NOT enforced by `webjs check`.
|
|
18
|
+
That split is the same one the rest of the project uses: `webjs check` carries
|
|
19
|
+
correctness rules only (code that is wrong to ship), and anything a sensible
|
|
20
|
+
project could reasonably do differently stays a convention.
|
|
21
|
+
|
|
22
|
+
What they mean in practice, in a buildless framework whose source IS what runs:
|
|
23
|
+
|
|
24
|
+
- **Single responsibility** is about what a module OWNS, not how long it is. A
|
|
25
|
+
module owns one concern when you can state that concern in a sentence without
|
|
26
|
+
the word "and". `router-client/prefetch.js` owns speculative fetching. It is
|
|
27
|
+
584 lines, and it is one responsibility.
|
|
28
|
+
- **DRY applies to knowledge, not to text.** Two identical lines that would
|
|
29
|
+
change for different reasons are not duplication. A constant that appears in
|
|
30
|
+
three places IS, which is why this repo has drift guards that read one copy
|
|
31
|
+
and assert it against another (see the guard section below).
|
|
32
|
+
- **KISS beats cleverness in a framework more than in an app.** The source is
|
|
33
|
+
the documentation surface for the AI agents that use WebJs, and it ships
|
|
34
|
+
unbundled to be read. An indirection that saves five lines and costs a reader
|
|
35
|
+
a jump is a bad trade here.
|
|
36
|
+
- **Dependency direction matters more than dependency inversion.** Modules layer
|
|
37
|
+
downward: constants at the bottom, then pure helpers, then orchestration.
|
|
38
|
+
Nothing imports upward. This is not architectural taste, it is what keeps ESM
|
|
39
|
+
cycles out (see below). Two subsystems are genuinely mutually recursive and
|
|
40
|
+
cannot layer, the client router (a navigation fetches, the fetch swaps, the
|
|
41
|
+
swap upgrades, an upgraded element navigates) and light-DOM slots (projecting
|
|
42
|
+
installs the interceptors, an intercepted mutation re-projects). Those two are
|
|
43
|
+
named in `test/architecture/import-cycles.test.mjs`, which fails on any THIRD
|
|
44
|
+
cycle, so the rule holds everywhere it can.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## File size: target 800, ceiling around 1000
|
|
49
|
+
|
|
50
|
+
**A source module targets 800 lines and should stay around 1000 at the most.**
|
|
51
|
+
The ceiling is approximate on purpose. A module at 1040 is fine; one at 1900
|
|
52
|
+
needs either a split or a named exemption (below), and the question to ask at
|
|
53
|
+
that size is which responsibility it has picked up rather than how many lines
|
|
54
|
+
it has. A barrel is exempt entirely, because its length is a function of how
|
|
55
|
+
many names it re-exports.
|
|
56
|
+
|
|
57
|
+
**Where this number comes from.** It was set by measuring the frameworks this
|
|
58
|
+
project takes its cues from, not by picking a round figure:
|
|
59
|
+
|
|
60
|
+
| Module | Lines |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `lit/packages/lit-html/src/lit-html.ts` | 2303 |
|
|
63
|
+
| `lit/packages/reactive-element/src/reactive-element.ts` | 1754 |
|
|
64
|
+
| `vite/packages/vite/src/node/optimizer/index.ts` | 1487 |
|
|
65
|
+
| `vite/packages/vite/src/node/server/index.ts` | 1447 |
|
|
66
|
+
|
|
67
|
+
Every one of them draws its seams by responsibility and lets the orchestration
|
|
68
|
+
entry stay large. None of them enforces a line count.
|
|
69
|
+
|
|
70
|
+
**A much smaller cap is a bad trade, and this repo has the measurement.**
|
|
71
|
+
Splitting ten modules into about ninety produced three bindings that needed
|
|
72
|
+
accessors because ESM forbids assigning an imported binding, one dropped import
|
|
73
|
+
that threw only inside a deferred callback, one symbol identity swap that
|
|
74
|
+
silently broke slot forwarding, and three drift guards that broke or would have
|
|
75
|
+
passed vacuously. Four of those six were invisible to `npm test`. Every module
|
|
76
|
+
boundary is a place where those failures can happen, so boundaries are worth
|
|
77
|
+
adding for cohesion and worth nothing when added to hit a number.
|
|
78
|
+
|
|
79
|
+
There is also a runtime cost. In dev the browser fetches core source files
|
|
80
|
+
individually rather than the bundle, so N modules is N requests at one more
|
|
81
|
+
level of import-graph depth.
|
|
82
|
+
|
|
83
|
+
**The ceiling is measured with a RAW line count** (the number `wc -l` prints,
|
|
84
|
+
the number you see when you open the file), because that is how #1365 specified
|
|
85
|
+
it and because a metric a reader cannot reproduce by looking at the file invites
|
|
86
|
+
argument about the metric instead of the module. The tension with dense
|
|
87
|
+
documentation is real: this repo's comment style can put a well-factored module
|
|
88
|
+
at twice its code size, and a raw ceiling must never become a reason to delete
|
|
89
|
+
explanation. The answer to that tension is the exemption list, not a different
|
|
90
|
+
metric.
|
|
91
|
+
|
|
92
|
+
**There is deliberately NO CI guard for this.** A line-count gate is a proxy
|
|
93
|
+
metric that fights cohesion: it reds forever on a generated data table like
|
|
94
|
+
`html-entities.js`, and it has to carry an exemption list that rots. So the
|
|
95
|
+
ceiling is a REVIEW-TIME check, not a test. Measure it when you split something:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
find <the tree you produced> -name '*.js' -exec wc -l {} + | awk '$1 > 1000 && $2 != "total"'
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**A module that genuinely cannot or should not go under the ceiling gets a
|
|
102
|
+
NAMED exemption**, argued in the PR that produces it, with its measured size and
|
|
103
|
+
its reason. The three exemptions the #1365 split carries show what a valid
|
|
104
|
+
reason looks like:
|
|
105
|
+
|
|
106
|
+
- **lit parity** (`component/lifecycle.js`): the file tracks lit's
|
|
107
|
+
`reactive-element.ts`, which lit keeps WHOLE at 1754 lines, and the project's
|
|
108
|
+
standing decision is to keep lit-derived code close to lit rather than
|
|
109
|
+
restructure it.
|
|
110
|
+
- **mutual recursion** (`render-client/parts.js`): the apply and instance group
|
|
111
|
+
calls back into itself, so a real split creates the import cycle the D4 rule
|
|
112
|
+
forbids, and the escape (a runtime dispatch registry) is a worse trade.
|
|
113
|
+
- **a single closure over shared request state** (`dev/handler.js`): splitting
|
|
114
|
+
means threading that state through a context object, a high-risk rewrite of
|
|
115
|
+
every app's boot path for zero behaviour gain.
|
|
116
|
+
|
|
117
|
+
Note what a valid reason is NOT: "it is mostly comments." If a module is over
|
|
118
|
+
the ceiling only because it is well documented, that is a signal the ceiling is
|
|
119
|
+
being measured too literally, not grounds for an exemption. Say why the CODE
|
|
120
|
+
cannot be split, or split it.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Splitting a module into a barrel plus a directory
|
|
125
|
+
|
|
126
|
+
The naming rule, settled once for the whole framework: **the original file keeps
|
|
127
|
+
its path and becomes the barrel, and its parts land in a sibling directory named
|
|
128
|
+
after it.** So `packages/core/src/slot.js` stays, and its parts go in
|
|
129
|
+
`packages/core/src/slot/`. Do NOT rename the barrel to match a public export
|
|
130
|
+
subpath. The `package.json` `exports` map, the hand-written `.d.ts` overlays and
|
|
131
|
+
their two guard tests, the docs pages that print importmap examples, and every
|
|
132
|
+
relative test import all key off the current path.
|
|
133
|
+
|
|
134
|
+
### The rule that matters most
|
|
135
|
+
|
|
136
|
+
**A split is a MOVE, not a rewrite.** Retyping a function while relocating it is
|
|
137
|
+
how a refactor with a green export surface ships behaviour changes. Move the
|
|
138
|
+
lines verbatim. The only edits a move should produce are the `export ` keyword
|
|
139
|
+
where a declaration now crosses a module boundary, and the generated import
|
|
140
|
+
lines.
|
|
141
|
+
|
|
142
|
+
### Where mutable module state goes
|
|
143
|
+
|
|
144
|
+
A module-scope `let` goes in the module that WRITES it, not the module that
|
|
145
|
+
looks like its topical home. ESM import bindings are read-only, so a module
|
|
146
|
+
cannot assign a binding it imported.
|
|
147
|
+
|
|
148
|
+
When two modules genuinely write the same binding, the owner exposes a
|
|
149
|
+
one-statement accessor and the other module calls it:
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
// scroll.js owns the counter.
|
|
153
|
+
export let restoreGeneration = 0;
|
|
154
|
+
export function bumpRestoreGeneration() { restoreGeneration += 1; }
|
|
155
|
+
|
|
156
|
+
// navigator.js reads the live binding and calls the accessor to write.
|
|
157
|
+
import { restoreGeneration, bumpRestoreGeneration } from './scroll.js';
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Keep importing the binding itself wherever it is READ. Dropping it from the
|
|
161
|
+
import list while a read site survives leaves a free variable, which throws only
|
|
162
|
+
when that line executes. In the client router that meant a Back-button scroll
|
|
163
|
+
restore silently landing at offset 0, with every node test still green.
|
|
164
|
+
|
|
165
|
+
### Cycles and TDZ
|
|
166
|
+
|
|
167
|
+
Layer the modules and never import upward. Node tolerates an import cycle, but
|
|
168
|
+
reading a `const` or `class` binding during the cycle's evaluation phase throws
|
|
169
|
+
a TDZ `ReferenceError` at module load, and in a minified browser bundle that is
|
|
170
|
+
a blank page rather than a test failure.
|
|
171
|
+
|
|
172
|
+
Where a back edge is unavoidable, resolve it by calling a function at call time
|
|
173
|
+
rather than reading a binding at module scope.
|
|
174
|
+
|
|
175
|
+
### Symbol identity
|
|
176
|
+
|
|
177
|
+
`Symbol('x')` mints a unique value. `Symbol.for('x')` looks one up in the global
|
|
178
|
+
registry by string. They are never interchangeable, and substituting one for the
|
|
179
|
+
other produces a value that no existing object carries, so every lookup quietly
|
|
180
|
+
returns `undefined`. Import the symbol from the module that created it.
|
|
181
|
+
|
|
182
|
+
### Drift guards read source files by path
|
|
183
|
+
|
|
184
|
+
This repo has guard tests that `readFileSync` a source file and grep it for a
|
|
185
|
+
constant, to pin two copies of a value against each other. Barrelling a file
|
|
186
|
+
breaks every one of them, and breaks them in two different ways:
|
|
187
|
+
|
|
188
|
+
- an `assert.match` fails on its own precondition, which is loud and fine;
|
|
189
|
+
- an `assert.doesNotMatch` starts passing **vacuously**, which is silent and is
|
|
190
|
+
the reason this is written down.
|
|
191
|
+
|
|
192
|
+
After any split, grep the test tree for reads of the file you just barrelled and
|
|
193
|
+
point each guard at the barrel PLUS every module beneath it.
|
|
194
|
+
|
|
195
|
+
### Verification, in order
|
|
196
|
+
|
|
197
|
+
```sh
|
|
198
|
+
# 1. Export surface is identical, in BOTH directions. `added` must be empty too:
|
|
199
|
+
# a behaviour-preserving split adds no public surface.
|
|
200
|
+
node --input-type=module -e "
|
|
201
|
+
const before = await import('/tmp/before.js');
|
|
202
|
+
const after = await import('./packages/core/src/<file>.js');
|
|
203
|
+
const A = Object.keys(before).sort(), B = Object.keys(after).sort();
|
|
204
|
+
console.log(JSON.stringify({
|
|
205
|
+
missing: A.filter((k) => !B.includes(k)),
|
|
206
|
+
added: B.filter((k) => !A.includes(k)),
|
|
207
|
+
}));
|
|
208
|
+
"
|
|
209
|
+
|
|
210
|
+
# 2. Every code line survived. Normalize away comments and the `export ` prefix,
|
|
211
|
+
# then diff. Anything left is a line the split CHANGED, and each one needs a
|
|
212
|
+
# reason in the commit message.
|
|
213
|
+
|
|
214
|
+
# 3. The module loads at all (catches a TDZ throw introduced by a cycle).
|
|
215
|
+
node --input-type=module -e "await import('./packages/core/index-browser.js')"
|
|
216
|
+
node --input-type=module -e "await import('./packages/core/index.js')"
|
|
217
|
+
|
|
218
|
+
# 4. Rebuild dist BEFORE any e2e or Bun run, which resolve the built bundle and
|
|
219
|
+
# would otherwise test the pre-split code and pass vacuously.
|
|
220
|
+
node scripts/build-framework-dist.js
|
|
221
|
+
|
|
222
|
+
# 5. The browser suite is MANDATORY for a renderer, router, component, or slot
|
|
223
|
+
# split. Those defects are post-hydration: the export surface is unchanged,
|
|
224
|
+
# the SSR bytes are unchanged, and node tests stay green.
|
|
225
|
+
npm test && npm run test:browser
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Step 5 is not optional and not a formality. Of the two silent defects this
|
|
229
|
+
codebase has hit from splitting, both were caught by the browser suite alone.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
## What This Covers
|
|
4
4
|
|
|
5
5
|
- The Next.js patterns that LOOK right in WebJs but break, because WebJs borrows Next's file-based routing shape but not its execution model (no RSC, no `'use client'` split): `redirect()` in a route handler, `fetch()` in a page, `<Link>`, `NEXT_PUBLIC_`, `await params`.
|
|
6
|
-
- The Lit patterns that break WebJs SSR or
|
|
6
|
+
- The Lit patterns that break WebJs SSR, reactivity, or event handling, because WebJs is HTML-first (real HTML first paint, JS opt-in per behaviour) not JS-first: `static properties` / the `@property()` decorator, class-field initializers, browser globals in `render()`, fetching in `connectedCallback`, passing a method straight to an `@event` binding, interpolation into `<style>`, reading `assignedNodes()` in `firstUpdated` of a light-DOM component.
|
|
7
7
|
- The WebJs-shaped fix for each, with short code.
|
|
8
8
|
|
|
9
9
|
Read this when a pattern feels familiar from Next.js or Lit but you are not sure it transfers. For the component runtime see `components.md`; for the routing surface see `routing-and-pages.md`. The one difference underneath everything: pages and layouts render server-only and never hydrate, and the one client boundary is a `WebComponent` custom element.
|
|
@@ -110,6 +110,7 @@ The bound, refused, and allowed shapes in full. Every "no" row is a binding that
|
|
|
110
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 |
|
|
111
111
|
| `action="${fn}"`, or a mixed `action="/x/${fn}"` | yes | quoting turns a binding hole back into a plain attribute |
|
|
112
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 |
|
|
113
|
+
|
|
113
114
|
| `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 |
|
|
114
115
|
| `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 |
|
|
115
116
|
| `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 |
|
|
@@ -118,6 +119,8 @@ The bound, refused, and allowed shapes in full. Every "no" row is a binding that
|
|
|
118
119
|
| `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 |
|
|
119
120
|
| 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 |
|
|
120
121
|
| `.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 |
|
|
122
|
+
| `export const method = 'GET'` on a form-bound action file | yes | form-bound actions strictly enforce `POST`. Binding a GET action to a form produces a 405 runtime refusal and `webjs check` error (`form-action-not-a-get-action`) |
|
|
123
|
+
| `method="get"` on a bound `<form action=${fn}>` | yes | WebJs supplies `method="post"` and `formenctype` automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read |
|
|
121
124
|
| `.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 |
|
|
122
125
|
| 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 |
|
|
123
126
|
| 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 |
|
|
@@ -134,6 +137,27 @@ That last row is the one to remember: quoting a binding hole turns it back into
|
|
|
134
137
|
|
|
135
138
|
`.action=${fn}` on a native form is refused during SSR too, even though the property is dropped there and nothing could leak, so a page cannot render clean on the server and then throw on hydration.
|
|
136
139
|
|
|
140
|
+
**A bound submitter is self-sufficient and asks nothing of the form around it.** This is the shape people expect to have to wire up, and do not:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
// components/publish-button.ts <- the submitter lives here
|
|
144
|
+
class PublishButton extends WebComponent({}) {
|
|
145
|
+
render() { return html`<button formaction=${publishDraft}>Publish</button>`; }
|
|
146
|
+
}
|
|
147
|
+
PublishButton.register('publish-button');
|
|
148
|
+
|
|
149
|
+
// app/triage/page.ts <- the form lives here
|
|
150
|
+
// BOTH work. The button carries its own submission attributes.
|
|
151
|
+
html`<form><publish-button></publish-button></form>`;
|
|
152
|
+
html`<form action=${saveAll}><publish-button></publish-button></form>`;
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The renderer supplies the submission attributes at the level where the action is BOUND (#1307), so a bound `<button>` gains `formmethod="post"` and `formenctype` ON THE BUTTON, alongside the reserved `__webjs_action` identity riding the button's own `name`/`value` pair. A submitter's `formmethod` overrides the form's `method` per HTML, so the submission is a POST whatever the enclosing form declares, including no `method` at all or `method="get"`, and the identity travels in the body where the dispatcher reads it.
|
|
156
|
+
|
|
157
|
+
That is also why the renderer refuses only a SAME-ELEMENT contradiction (a bound submitter's own `formmethod` other than post, an `formenctype` the server cannot parse, `formmethod="dialog"`) and never a cross-element one. A component renders its template in a separate pass with no view of the host page, so the cross-element question is unanswerable at render time, and self-sufficiency leaves nothing for it to answer. One consequence: no `formaction` url is emitted, so the submission targets whatever the FORM targets, and a form declaring `action="/x"` sends its buttons there. The action still runs when `/x` is a PAGE route, since the identity travels in the body; against a `route.ts` or another origin nothing runs, which the dev-time client guard reports at submit time.
|
|
158
|
+
|
|
159
|
+
**Two runtime signals cover what is left.** In dev, submitting a form that carries an action identity it cannot deliver logs one `console.error` naming the fix, once per shape; it never throws, so the submission behaves exactly as it does in production. In production, both server-visible fingerprints reach the `onError` hook (the programmatic `createRequestHandler({ onError })` option and any sink an `instrumentation.{js,ts}` installed) with a code to group on: `WEBJS_FORM_SUBMITTED_AS_GET` for a page GET carrying the reserved field in its query string, and `WEBJS_FORM_ACTION_MISSING` for a form body carrying no identity at all. A BOUND submitter carries its own `formmethod="post"`, and a bound form is refused a `method="get"` outright, so what reaches the first one is a PLAIN submitter's `formmethod="get"`, which native precedence lets win and the renderer deliberately honours, or a hand-authored form carrying the reserved field. Both are detect-only, so no status changes, and both carry the submitted field NAMES and never the values.
|
|
160
|
+
|
|
137
161
|
**Inside a component you may never see the error.** Per-component SSR error isolation contains the throw, so development shows an error box in place of the component and production renders it empty with the page still returning 200. A form that has silently vanished in production is this bug wearing a disguise; the message is in the server log. Nothing leaks either way.
|
|
138
162
|
|
|
139
163
|
Two things that "renders it empty" understates, both worth knowing before you go looking:
|
|
@@ -150,7 +174,9 @@ html`<form action=${submitFeedback}><input name="email"></form>`;
|
|
|
150
174
|
html`<form method="post"><input name="email"></form>`;
|
|
151
175
|
```
|
|
152
176
|
|
|
153
|
-
A hole that resolves to `null`
|
|
177
|
+
A plain attribute hole that resolves to `null` or `undefined` omits the attribute on both renderers (#1573), so `method=${null}` and `?method=${false}` both emit nothing and WebJs supplies `method="post"`. An EMPTY string is different: `method=${''}` renders `method=""`, which cannot submit and is refused.
|
|
178
|
+
|
|
179
|
+
A boolean in a plain hole on an HTML boolean attribute renders like `?attr` (core 0.7.64+, #1579): `<option selected=${i === 0}>` and `<input type="radio" checked=${v.attending !== 'no'}>` mark only the true one. On an older core the server served `selected="false"` / `checked="false"`, which HTML reads as PRESENT, so the LAST option or radio won on first paint. `?selected=${...}` / `?checked=${...}` is correct on every version.
|
|
154
180
|
|
|
155
181
|
A string stays a string: `action="/search"` and `action=${'/search'}` are unchanged, which is what a search form (`<form method="get" action="/search">`) and a `route.ts` endpoint both want. Other attributes keep their existing stringify behaviour; only a FUNCTION under `action` / `formaction` is claimed.
|
|
156
182
|
|
|
@@ -178,18 +204,22 @@ Export `GET` / `POST` / etc. as named async functions `(request, { params }) =>
|
|
|
178
204
|
|
|
179
205
|
### `middleware.ts` is per-segment and chainable, not one matcher config
|
|
180
206
|
|
|
181
|
-
The file stays `middleware.ts`, NOT Next 16's renamed `proxy.ts`. WebJs middleware is in-process, chainable, and per-segment (the Remix / Koa model). There is no `export const config = { matcher }` and no single-file restriction. The default export is `async (req, next) => Response`: return a Response to short-circuit, or call `next()` and post-process. Colocate `app/admin/middleware.ts` next to the admin routes and it runs for that subtree only. An optional root `middleware.ts` runs on every request, outermost to innermost.
|
|
207
|
+
The file stays `middleware.ts`, NOT Next 16's renamed `proxy.ts`. WebJs middleware is in-process, chainable, and per-segment (the Remix / Koa model). There is no `export const config = { matcher }` and no single-file restriction. The default export is `async (req, next) => Response`: return a Response to short-circuit, or call `next()` and post-process. Colocate `app/admin/middleware.ts` next to the admin routes and it runs for that subtree only. An optional root `middleware.ts` runs on every app request, outermost to innermost. Some requests are answered before it and never reach it, and the RULE is what to remember, not the list: anything the listener shell or the framework's pre-analysis stage answers bypasses root middleware, and everything routed with the app reaches it. That covers WebSocket upgrades bound for a `route.ts` exporting `WS`, the dev SSE stream at `/__webjs/events`, and the framework's own `/__webjs/*` runtime assets and probes; in DEV only it also covers `/public/*` plus the `/sw.js` / `/offline.html` root remaps and `/favicon.ico`, so a stylesheet is never queued behind the dev startup analysis. In production those static files go through root middleware normally. **`webjs.redirects` and `webjs.trailingSlash` are the case intuition gets wrong**: you configure them, but the framework resolves them ahead of middleware, so a 308 from a redirect rule is answered without root middleware running (redirect in the middleware instead when it has to observe those requests). Server actions go the other way: they are routed with the app, so middleware DOES run for an action call, which is what lets you gate actions with auth or rate limiting.
|
|
182
208
|
|
|
183
209
|
### No `<Link>`, no `next/navigation`, no `next/*` libraries
|
|
184
210
|
|
|
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`
|
|
211
|
+
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` to load it when it is first visible (scrolled near, or its hidden tab panel or dialog opened, and it stays lazy when another component imports it), and a dynamic `import()` where code should load lazily.
|
|
186
212
|
|
|
187
213
|
### No `<ScrollRestoration>`, and no scroll restore of your own
|
|
188
214
|
|
|
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
|
|
215
|
+
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, and the BROWSER is what does it: the router reserves the page's recorded height across the swap so the browser's own per-entry replay lands correctly, and writes no scroll itself. 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.
|
|
216
|
+
|
|
217
|
+
**Do not set `history.scrollRestoration = 'manual'` either**, which is the one line most of those recipes start with. The browser only records a per-entry scroll offset under the default `auto`, and WebKit composes the iOS edge back-swipe gesture preview from that recording, so `manual` makes every scrolled page preview BLANK for the whole gesture (#1428). The router itself used to set it, inherited from Turbo Drive, and had the same bug. It no longer does.
|
|
190
218
|
|
|
191
219
|
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
220
|
|
|
221
|
+
**One scroll reflex DOES port, and only one.** Next's `<Link scroll={false}>` has a WebJs spelling: `data-preserve-scroll` on the link, or on any element wrapping a group of them, and `navigate(url, { scroll: false })` programmatically. It suppresses the forward-navigation scroll-to-top, which is a write the ROUTER makes, and that is why it exists while none of the restore recipes above do: the restore is the browser's and the router is not a writer on it. Everything else in this section stands unchanged, including the rule not to hand-roll a restore. The attribute keeps the reader's CURRENT offset on a forward nav; it does not bring back the offset they once had on the destination, which is what a hand-rolled `sessionStorage` restore is usually reaching for.
|
|
222
|
+
|
|
193
223
|
### Server-only code: the `.server.ts` boundary, not a `server-only` package
|
|
194
224
|
|
|
195
225
|
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.
|
|
@@ -262,6 +292,26 @@ class StudentCard extends WebComponent({ student: prop<Student>(Object) }) {
|
|
|
262
292
|
|
|
263
293
|
The `@property()` decorator is banned by the erasable-TS invariant (decorators are non-erasable, they would force a build step). A `static properties = { ... }` block THROWS at runtime (`no-static-properties`). The single replacement for both is the declare-free base-class factory `WebComponent({ ... })`, with the `prop()` helper carrying options.
|
|
264
294
|
|
|
295
|
+
### Passing a method straight to an `@event` binding
|
|
296
|
+
|
|
297
|
+
Lit invokes an `@event` listener with `this` set to the host element, so `@click=${this.handleClick}` is correct there. WebJs stores the handler verbatim and dispatches it through an internal part object (`part.handler?.(ev)` in `core/src/render-client/parts.js`), so `this` is THAT object, not your component. It does not fail loudly. A read such as `this.todos` returns `undefined`, and a write such as `this.count = 1` silently lands on the framework's internal object. The `TypeError` arrives later, when you dereference the `undefined` you read back, so the stack points away from the real cause. Nothing catches it statically either: `webjs check` and `tsc` both pass and the component SSRs correctly.
|
|
298
|
+
|
|
299
|
+
Wrap the call at the binding site, which is the conventional spelling, or declare the handler as an arrow class field, which carries its own `this`.
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
// BROKEN: `this` is undefined when the event fires.
|
|
303
|
+
html`<form @submit=${this.handleSubmit}>`
|
|
304
|
+
|
|
305
|
+
// Wrap it at the binding site:
|
|
306
|
+
html`<form @submit=${(e: SubmitEvent) => this.handleSubmit(e)}>`
|
|
307
|
+
|
|
308
|
+
// Or pre-bind by declaring the handler as an arrow field:
|
|
309
|
+
_onSubmit = (e: SubmitEvent) => { /* ... */ };
|
|
310
|
+
html`<form @submit=${this._onSubmit}>`
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
An arrow class field is a class-field initializer, but it is NOT a reactive property, so the reactive-property class-field ban does not apply to it and `reactive-props-no-class-field` does not flag it.
|
|
314
|
+
|
|
265
315
|
### Expecting shadow DOM and reaching for scoped CSS
|
|
266
316
|
|
|
267
317
|
Lit defaults to shadow DOM, so `static styles = css` scopes automatically. WebJs defaults to light DOM. A `static styles` block without `static shadow = true` does nothing useful and any inline `<style>` with bare class names leaks globally. The webjs-shaped fix is Tailwind utilities, which apply directly in light DOM. Reach for `static shadow = true` plus `static styles` only when scoped CSS genuinely belongs in a shadow root, or prefix every selector with the tag name if authoring vanilla light-DOM CSS.
|