@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.
@@ -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
- - Write the string explicitly for a tri-state ARIA attribute. A plain-attribute hole holding `false` serves `aria-expanded="false"` from the server and hydrates to NO attribute, because the client removes an attribute for `null` / `undefined` / `false` while the server stringifies it. `?attr=${bool}` is not a substitute, since a boolean binding omits the attribute in BOTH renderers.
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, a camelCase attribute name, and an attribute matching no declared property are all ignored by both, and both are handed a value whose HTML character references are already decoded.
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 write kebab-case (`user-name`); a camelCase attribute (`userName="…"`) reaches no property on either side. 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="&#123;&quot;a&quot;:1&#125;"` parses as the object it spells and `label="Tom &amp; Jerry"` is `Tom & Jerry`; the legacy semicolon-less forms decode exactly where a browser decodes them (`&nbsp` at the end of a value is a non-breaking space, `&nbspx` and `&nbsp=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.
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="&#123;&quot;a&quot;:1&#125;"` parses as the object it spells and `label="Tom &amp; Jerry"` is `Tom & Jerry`; the legacy semicolon-less forms decode exactly where a browser decodes them (`&nbsp` at the end of a value is a non-breaking space, `&nbspx` and `&nbsp=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 reactivity, 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`, interpolation into `<style>`, reading `assignedNodes()` in `firstUpdated` of a light-DOM component.
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` is NOT the same as omitting the attribute. `method=${null}` renders `method=""`, which cannot submit and is refused; `?method=${false}` emits nothing at all, so WebJs supplies `method="post"` and the form works. Both leave no attribute in the DOM, which is exactly why the check reads your template rather than the rendered element.
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` for viewport lazy-loading, and a dynamic `import()` where code should load lazily.
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 sets `history.scrollRestoration = 'manual'` on boot and is the sole authority on scroll for the whole navigation. There is no component to render and no option to enable. An app-level `popstate` listener that calls `scrollTo`, a remembered offset in `sessionStorage`, or a `scrollIntoView` on a saved element all race the router and win sometimes, which is worse than losing consistently.
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
- const liked = signal(false);
137
- // in an @click handler:
138
- const result = await optimistic(liked, true, () => likePost(postId));
139
- // `liked` flips to true instantly. If likePost THROWS or returns
140
- // { success: false }, `liked` rolls back to its prior value: the throw
141
- // re-throws, and the { success: false } result is returned so you can
142
- // read its error / fieldErrors. On success the optimistic value stays;
143
- // reconcile to the authoritative value from `result` if you need it.
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