@webjsdev/mcp 0.1.14 → 0.1.16
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/package.json +1 -1
- package/resources/AGENTS.md +96 -34
- package/resources/SKILL.md +50 -7
- package/resources/corpus.json +3 -3
- package/resources/references/auth-and-sessions.md +15 -0
- package/resources/references/built-ins.md +93 -3
- package/resources/references/client-router-and-streaming.md +54 -5
- package/resources/references/components.md +57 -5
- package/resources/references/data-and-actions.md +3 -1
- package/resources/references/module-structure.md +229 -0
- package/resources/references/muscle-memory-gotchas.md +31 -5
- package/resources/references/optimistic-ui.md +23 -11
- package/resources/references/routing-and-pages.md +107 -9
- package/resources/references/runtime.md +24 -5
- package/resources/references/styling.md +48 -3
- package/resources/references/testing.md +13 -0
- package/resources/references/ui-kit.md +39 -3
- package/src/mcp-source.js +148 -6
- package/src/mcp.js +9 -2
|
@@ -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`).
|
|
@@ -147,9 +185,9 @@ For an array-typed prop pass `Array`, not `Object` (`array-prop-uses-array-type`
|
|
|
147
185
|
|
|
148
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`.
|
|
149
187
|
|
|
150
|
-
**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
|
|
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.
|
|
151
189
|
|
|
152
|
-
**Writing attributes in markup.** Names are case-insensitive and the browser lowercases them while parsing, so
|
|
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.
|
|
153
191
|
|
|
154
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.
|
|
155
193
|
|
|
@@ -344,6 +382,18 @@ Import from `@webjsdev/core/directives`. Everything a `class`/`style`/conditiona
|
|
|
344
382
|
| `asyncAppend(iter)` / `asyncReplace(iter)` | Stream from an async iterable, appending each value or replacing with the latest. |
|
|
345
383
|
| `templateContent(el)` | Render the content of a `<template>` element. |
|
|
346
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
|
+
|
|
347
397
|
## Display-only elision
|
|
348
398
|
|
|
349
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:
|
|
@@ -352,10 +402,12 @@ A component that does no client-side work renders the same SSR'd HTML with or wi
|
|
|
352
402
|
- a factory-declared reactive property that is not `{ state: true }`
|
|
353
403
|
- an overridden lifecycle hook (including `renderFallback` / `renderError`)
|
|
354
404
|
- an imported `signal` / `computed` / `watch` / `Task` / `ref` / streaming directive, or `addController` / `requestUpdate`
|
|
355
|
-
- 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)
|
|
356
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)
|
|
357
407
|
- being rendered by a component that itself ships
|
|
358
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
|
+
|
|
359
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`.
|
|
360
412
|
|
|
361
413
|
### What `static interactive = true` does and does not rescue
|
|
@@ -414,4 +466,4 @@ A `WebComponent` inherits `HTMLElement` (browser) or an `ElementShim` (SSR) plus
|
|
|
414
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`).
|
|
415
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.
|
|
416
468
|
|
|
417
|
-
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`.
|
|
@@ -206,7 +208,7 @@ export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
|
|
|
206
208
|
|
|
207
209
|
### Cancellation with `actionSignal()`
|
|
208
210
|
|
|
209
|
-
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:
|
|
210
212
|
|
|
211
213
|
```ts
|
|
212
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.
|
|
@@ -174,7 +174,9 @@ html`<form action=${submitFeedback}><input name="email"></form>`;
|
|
|
174
174
|
html`<form method="post"><input name="email"></form>`;
|
|
175
175
|
```
|
|
176
176
|
|
|
177
|
-
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.
|
|
178
180
|
|
|
179
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.
|
|
180
182
|
|
|
@@ -202,18 +204,22 @@ Export `GET` / `POST` / etc. as named async functions `(request, { params }) =>
|
|
|
202
204
|
|
|
203
205
|
### `middleware.ts` is per-segment and chainable, not one matcher config
|
|
204
206
|
|
|
205
|
-
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.
|
|
206
208
|
|
|
207
209
|
### No `<Link>`, no `next/navigation`, no `next/*` libraries
|
|
208
210
|
|
|
209
|
-
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.
|
|
210
212
|
|
|
211
213
|
### No `<ScrollRestoration>`, and no scroll restore of your own
|
|
212
214
|
|
|
213
|
-
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.
|
|
214
218
|
|
|
215
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.
|
|
216
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
|
+
|
|
217
223
|
### Server-only code: the `.server.ts` boundary, not a `server-only` package
|
|
218
224
|
|
|
219
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.
|
|
@@ -286,6 +292,26 @@ class StudentCard extends WebComponent({ student: prop<Student>(Object) }) {
|
|
|
286
292
|
|
|
287
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.
|
|
288
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
|
+
|
|
289
315
|
### Expecting shadow DOM and reaching for scoped CSS
|
|
290
316
|
|
|
291
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.
|
|
@@ -85,7 +85,7 @@ TodoList.register('todo-list');
|
|
|
85
85
|
|
|
86
86
|
### Author the optimistic mutation as a degrade-first form
|
|
87
87
|
|
|
88
|
-
Wrap the mutation in a REAL `<form>` bound to the action, then intercept it for the optimistic path. One form serves both: with JS off the browser submits and the server dispatches to that action (the no-JS write path, see `routing-and-pages.md`), and with JS on `@submit` calls `e.preventDefault()` and runs the optimistic path. That is the progressive-enhancement contract, not a fetch-only handler.
|
|
88
|
+
Wrap the mutation in a REAL `<form>` bound to the action, then intercept it for the optimistic path. One form serves both: with JS off the browser submits and the server dispatches to that action (the no-JS write path, see `routing-and-pages.md`), and with JS on `@submit` calls `e.preventDefault()` and runs the optimistic path. That is the progressive-enhancement contract, not a fetch-only handler. Note the arrow wrapper on the listener: WebJs does not bind an `@event` handler to your component, so passing the method directly would leave `this` pointing at a framework-internal object (see `muscle-memory-gotchas.md`).
|
|
89
89
|
|
|
90
90
|
The SAME imported function is the form binding and the optimistic path's callee, which is what makes a degrade-first form cheap to write: there is no second wiring to keep in step.
|
|
91
91
|
|
|
@@ -94,7 +94,7 @@ import { createTodo } from '#modules/todo/actions/create-todo.server.ts';
|
|
|
94
94
|
|
|
95
95
|
render() {
|
|
96
96
|
return html`
|
|
97
|
-
<form action=${createTodo} @submit=${this.handleSubmit}>
|
|
97
|
+
<form action=${createTodo} @submit=${(e: SubmitEvent) => this.handleSubmit(e)}>
|
|
98
98
|
<input name="title" required>
|
|
99
99
|
<button>Add</button>
|
|
100
100
|
</form>
|
|
@@ -130,19 +130,31 @@ The component reads that seeded prop as its `optimistic()` `source`, so `source:
|
|
|
130
130
|
For a boolean toggle where the value itself is the mutation (like, follow, pin), `optimistic(signal, value, action)` is a thin wrapper over the signal primitive.
|
|
131
131
|
|
|
132
132
|
```ts
|
|
133
|
-
import { signal, optimistic } from '@webjsdev/core';
|
|
133
|
+
import { WebComponent, prop, signal, optimistic, html } from '@webjsdev/core';
|
|
134
134
|
import { likePost } from '#modules/posts/actions/like-post.server.ts';
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
136
|
+
class LikeButton extends WebComponent({ postId: prop(String) }) {
|
|
137
|
+
// INSTANCE scope: one signal per element. A module-scope `signal()` is SHARED
|
|
138
|
+
// across every instance (invariant 5), so a feed of these would all flip
|
|
139
|
+
// together on one click. Scope per-item state to the instance.
|
|
140
|
+
private liked = signal(false);
|
|
141
|
+
|
|
142
|
+
private async toggle() {
|
|
143
|
+
const next = !this.liked.get();
|
|
144
|
+
// Returns the action's ActionResult, so a { success: false } is readable
|
|
145
|
+
// by the caller. The rollback has already happened by then.
|
|
146
|
+
return optimistic(this.liked, next, () => likePost(this.postId));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
render() {
|
|
150
|
+
return html`<button @click=${() => this.toggle()}>${this.liked.get() ? 'Liked' : 'Like'}</button>`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
LikeButton.register('like-button');
|
|
144
154
|
```
|
|
145
155
|
|
|
156
|
+
Reserve MODULE scope for state that genuinely is app-wide (a theme, a cart count, a sidebar-open flag), which is the case invariant 5's "module-scope signals share state across components" exists to serve. Per-item state goes on the instance, as above. `liked` is a plain instance field, not a reactive property declared in the factory, so the class-field ban does not reach it and `reactive-props-no-class-field` does not flag it. `gallery/modules/optimistic-ui/components/like-button.ts` is the same shape in shipped code.
|
|
157
|
+
|
|
146
158
|
It rolls back on a thrown error OR an `ActionResult` `{ success: false }` envelope, and never on success. It is client-only (it mutates a signal), so a component importing it is never elided as a display-only component.
|
|
147
159
|
|
|
148
160
|
## When Optimistic UI Is Appropriate
|