@webjsdev/cli 0.10.55 → 0.10.57
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/lib/create.js +43 -1
- package/lib/doctor/codes.js +67 -0
- package/lib/doctor/manifest.js +161 -0
- package/lib/doctor/policy.js +124 -0
- package/lib/doctor/probes/elision.js +111 -0
- package/lib/doctor/probes/env.js +53 -0
- package/lib/doctor/probes/framework-resolves.js +260 -0
- package/lib/doctor/probes/git-hook.js +58 -0
- package/lib/doctor/probes/importmap-coherence.js +158 -0
- package/lib/doctor/probes/node.js +37 -0
- package/lib/doctor/probes/static-asset-freshness.js +58 -0
- package/lib/doctor/probes/tsconfig.js +55 -0
- package/lib/doctor/probes/unmarked-asset-links.js +199 -0
- package/lib/doctor/probes/vendor-gitignore.js +84 -0
- package/lib/doctor/probes/vendor-pin.js +77 -0
- package/lib/doctor/probes/webjs-versions.js +85 -0
- package/lib/doctor/route-modules.js +100 -0
- package/lib/doctor/runner.js +72 -0
- package/lib/doctor/util.js +160 -0
- package/lib/doctor.js +5 -1634
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +4 -3
- package/templates/.agents/skills/webjs/SKILL.md +46 -4
- package/templates/.agents/skills/webjs/references/built-ins.md +3 -1
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +52 -5
- package/templates/.agents/skills/webjs/references/components.md +50 -2
- package/templates/.agents/skills/webjs/references/module-structure.md +229 -0
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +27 -3
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +23 -11
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +36 -2
- package/templates/.agents/skills/webjs/references/runtime.md +6 -1
- package/templates/.agents/skills/webjs/references/styling.md +47 -2
- package/templates/.github/pull_request_template.md +0 -4
- package/templates/gallery/app/features/boundaries/page.ts +11 -0
- package/templates/gallery/app/features/client-router/page.ts +13 -2
- package/templates/gallery/app/features/metadata/page.ts +7 -1
- package/templates/gallery/modules/client-router/components/router-controls.ts +23 -2
- package/templates/gallery/modules/gallery/nav.ts +35 -26
- package/templates/gallery/modules/stream/components/browser/stream-demo.test.js +64 -0
- package/templates/gallery/modules/stream/components/stream-demo.ts +14 -9
- package/templates/gallery/modules/stream/utils/ui/row.ts +41 -0
- package/templates/gallery/test/rate-limit/rate-limit.test.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.57",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
|
|
6
6
|
"bin": {
|
|
@@ -59,9 +59,10 @@ Read `AGENTS.md` first. Full hosted docs are at https://webjs.dev/docs.
|
|
|
59
59
|
tsconfig), either of which would 500 the app at runtime. Everything else it
|
|
60
60
|
reports is a warning that cannot fail the build. Widen or narrow the gate in
|
|
61
61
|
`package.json` rather than in the workflow.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
|
|
63
|
+
How a PR gets REVIEWED is deliberately not specified here. Use whatever your
|
|
64
|
+
team already does. WebJs has opinions about the code (the conventions above,
|
|
65
|
+
`webjs check`, the test layers) and none about your review process.
|
|
65
66
|
|
|
66
67
|
## Git rules
|
|
67
68
|
|
|
@@ -24,6 +24,8 @@ WebJs is an AI-first, web-components-first framework with **no build step**: sou
|
|
|
24
24
|
|
|
25
25
|
**Progressive enhancement is the default architecture.** With JS off, content reads, `<a>` navigates, and a `<form action=${importedAction}>` submits to its server action. JS is opt-in per interactive behaviour. Never write a first paint that depends on hydration.
|
|
26
26
|
|
|
27
|
+
**Islands, and why their size is a decision.** A WebJs page is server-rendered HTML with small interactive components embedded in it, each hydrating on its own when the browser upgrades its tag. That is the islands model, and what makes it pay is that the sea is free: static markup a page renders costs the browser nothing, because a page never hydrates. So an island is not a unit of code organisation, it is a unit of shipped JavaScript, and it should wrap the interactive part and stop. Absorbing a page's static markup into a component to keep things tidy converts free HTML into shipped JavaScript, and it takes every display-only child down with it, because a component rendered by a component that ships can no longer be elided. Size the island to the behaviour, not to the section of the design it happens to sit in. `references/components.md` has the stopping rule and a worked before/after.
|
|
28
|
+
|
|
27
29
|
## When To Use This Skill
|
|
28
30
|
|
|
29
31
|
- New features or refactors touching pages, routes, actions, components, data, auth, sessions, styling, or tests
|
|
@@ -31,9 +33,44 @@ WebJs is an AI-first, web-components-first framework with **no build step**: sou
|
|
|
31
33
|
- Answering "how should this be structured in WebJs?"
|
|
32
34
|
- Finding the right export, reference doc, or default pattern for a task
|
|
33
35
|
|
|
36
|
+
## Reach For The Right Primitive
|
|
37
|
+
|
|
38
|
+
Scan this BEFORE deciding how to build something, while the shape of the code is still open. WebJs ships a primitive for most of the jobs below, and the mistake to guard against is rarely choosing badly between them. It is that the primitive never comes to mind at all, so the React-shaped version gets hand-rolled in its place. That version compiles, passes `webjs check`, and ships, so nothing catches it. The "reflex to resist" column is what the hand-rolled version usually looks like.
|
|
39
|
+
|
|
40
|
+
Rows point rather than explain. The reference is the authority on the rule, and it is the durable half: the demo lives in the scaffold gallery, which an app deletes with `npm run gallery:clear` once it has outgrown it.
|
|
41
|
+
|
|
42
|
+
| I need to... | Reach for | Reflex to resist | Reference | Demo |
|
|
43
|
+
| --- | --- | --- | --- | --- |
|
|
44
|
+
| add a URL, static or with a dynamic segment | a file at `app/<path>/page.ts`, `[id]` for a param | registering the route in a table or config | `references/routing-and-pages.md` | `app/features/routing` |
|
|
45
|
+
| abandon a render because something is missing or not allowed | throw `notFound()` / `forbidden()` / `unauthorized()` | returning an error object and branching in the template | `references/routing-and-pages.md` | `app/features/boundaries` |
|
|
46
|
+
| set a page's title, description, or social preview | `export const metadata` or `generateMetadata()` | writing `<head>` tags in the page | `references/routing-and-pages.md` | `app/features/metadata` |
|
|
47
|
+
| make part of the page respond to a click or hold state | a `WebComponent` custom element | expecting the page's own markup to hydrate | `references/components.md` | `app/features/components` |
|
|
48
|
+
| render a keyed list, or swap one node when state changes | `repeat()` / `watch()` from `/directives` | re-rendering the component or diffing by hand | `references/components.md` | `app/features/directives` |
|
|
49
|
+
| get server data into a component's first paint | `async render()` awaiting an action | fetching in `connectedCallback`, which SSR never calls | `references/components.md` | `app/features/async-render` |
|
|
50
|
+
| call server code from the browser | import the `'use server'` function and call it | hand-writing `fetch()` against an endpoint | `references/data-and-actions.md` | `app/features/server-actions` |
|
|
51
|
+
| expose JSON to a caller outside the app | `route.ts` with named `GET` / `POST` exports | a server action, which is the in-app path | `references/routing-and-pages.md` | `app/features/route-handler` |
|
|
52
|
+
| write data from a form, JS off included | `<form action=${importedAction}>` | an `@submit` handler calling `fetch()` | `references/data-and-actions.md` | `app/features/forms` |
|
|
53
|
+
| make a mutation feel instant | `optimistic()` | a manual try-catch that restores a cached copy | `references/optimistic-ui.md` | `app/features/optimistic-ui` |
|
|
54
|
+
| navigate without a full page reload | nothing, the router is already on | importing or configuring a router | `references/client-router-and-streaming.md` | `app/features/client-router` |
|
|
55
|
+
| cross-fade a navigation instead of snapping | the `view-transition` meta, via page metadata | animating the swap yourself | `references/client-router-and-streaming.md` | `app/features/view-transitions` |
|
|
56
|
+
| show tokens or progress as the server produces them | an action returning an async generator | polling, or a socket for a one-shot answer | `references/client-router-and-streaming.md` | `app/features/streaming` |
|
|
57
|
+
| change ONE element after a write | `<webjs-stream>` | redrawing the whole list around it | `references/client-router-and-streaming.md` | `app/features/stream` |
|
|
58
|
+
| paint the page before a slow region is ready | `<webjs-suspense>` with a fallback | blocking the whole page on the slow await | `references/client-router-and-streaming.md` | `app/features/suspense` |
|
|
59
|
+
| refresh one region on its own, with no navigation | `<webjs-frame>` | a stateful component that fetches and re-renders | `references/client-router-and-streaming.md` | `app/features/frames` |
|
|
60
|
+
| hold a live two-way connection | a `WS()` route export plus `connectWS()` | polling on an interval | `references/client-router-and-streaming.md` | `app/features/websockets` |
|
|
61
|
+
| push one update to every client on a socket path | `broadcast()` | every client polling for changes | `references/client-router-and-streaming.md` | `app/features/broadcast` |
|
|
62
|
+
| add login and a signed-in-only route | `createAuth` plus a redirect in the page | rolling password hashing and session cookies | `references/auth-and-sessions.md` | `app/features/auth` |
|
|
63
|
+
| remember something per visitor across requests | `getSession()` on a signed cookie | a module-level map keyed by user | `references/auth-and-sessions.md` | `app/features/sessions` |
|
|
64
|
+
| stop re-rendering a page identical for everyone | `export const revalidate` | caching by hand in a module variable | `references/built-ins.md` | `app/features/caching` |
|
|
65
|
+
| read config or a secret at runtime | `process.env` server-side, `WEBJS_PUBLIC_` for the browser | importing a config module into a component | `references/built-ins.md` | `app/features/env` |
|
|
66
|
+
| stop one caller hammering an endpoint | the `rateLimit()` middleware | counting requests inside the handler | `references/built-ins.md` | `app/features/rate-limit` |
|
|
67
|
+
| accept an upload and serve it back | `FileStore` plus a streaming route | buffering the file in memory or writing to `public/` | `references/built-ins.md` | `app/features/file-storage` |
|
|
68
|
+
| keep the app usable offline | the opt-in service worker | caching responses in `localStorage` | `references/service-worker.md` | `app/features/service-worker` |
|
|
69
|
+
| see these composed in one real feature | the todo example app | stitching the single-feature demos together | `references/optimistic-ui.md` | `app/examples/todo` |
|
|
70
|
+
|
|
34
71
|
## Load Only The References You Need
|
|
35
72
|
|
|
36
|
-
Classify the task first, then load the smallest useful reference set. Each reference starts with a "What This Covers" section; read that to confirm relevance before reading the rest. Loading more than two or three at once usually means the task is not narrowed yet.
|
|
73
|
+
The table above routes by the job; this one routes by the topic, for when you already know which surface you are working on. Classify the task first, then load the smallest useful reference set. Each reference starts with a "What This Covers" section; read that to confirm relevance before reading the rest. Loading more than two or three at once usually means the task is not narrowed yet.
|
|
37
74
|
|
|
38
75
|
| Task involves... | Start with |
|
|
39
76
|
| --------------------------------------------------------------------------- | --------------------------------------------- |
|
|
@@ -43,6 +80,7 @@ Classify the task first, then load the smallest useful reference set. Each refer
|
|
|
43
80
|
| Server actions, mutations, queries, validation, the `ActionResult` envelope | `references/data-and-actions.md` |
|
|
44
81
|
| Sessions, login flows, route protection, `forbidden()` / `unauthorized()` | `references/auth-and-sessions.md` |
|
|
45
82
|
| Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout | `references/styling.md` |
|
|
83
|
+
| Where a repeated markup helper lives (`utils/ui/` vs `lib/`), and fragment vs display-only component | `references/styling.md` |
|
|
46
84
|
| Client router, prefetch, frames, view transitions, Suspense streaming | `references/client-router-and-streaming.md` |
|
|
47
85
|
| Optimistic UI for a user-facing mutation | `references/optimistic-ui.md` |
|
|
48
86
|
| The `@webjsdev/ui` component kit (a `components.json` is present): class helpers, tokens, `add` / `view`, the MCP `ui` tool | `references/ui-kit.md` |
|
|
@@ -51,6 +89,7 @@ Classify the task first, then load the smallest useful reference set. Each refer
|
|
|
51
89
|
| Auth, caching, env vars, rate limit, file storage, the `webjs` config block | `references/built-ins.md` |
|
|
52
90
|
| Node vs Bun, running the app, deploying, runtime-specific differences | `references/runtime.md` |
|
|
53
91
|
| Offline support, an asset cache, the opt-in service worker | `references/service-worker.md` |
|
|
92
|
+
| Splitting a large file, or how big a module may be | `references/module-structure.md` |
|
|
54
93
|
| A pattern that feels like Next.js or Lit but might not transfer | `references/muscle-memory-gotchas.md` |
|
|
55
94
|
|
|
56
95
|
Common bundles:
|
|
@@ -66,7 +105,7 @@ Common bundles:
|
|
|
66
105
|
2. **Start from the server.** Add the page/route and its server action or query before wiring interactive UI. A page render or a `<form>` POST should already return correct HTML before any component hydrates.
|
|
67
106
|
3. **Put code in the narrowest owner.** Route-local first (`modules/<feature>/`), promote to `lib/` or `components/` only when reuse is real.
|
|
68
107
|
4. **Keep server-only code behind `.server.ts`.** The DB driver, secrets, and `node:*` never belong in a page, layout, or component.
|
|
69
|
-
5. **Add interactivity per behaviour.** Reach for a component (and a signal or `@event`) only where the UI is genuinely interactive. A display-only component is elided from the browser.
|
|
108
|
+
5. **Add interactivity per behaviour.** Reach for a component (and a signal or `@event`) only where the UI is genuinely interactive. A display-only component is elided from the browser. Then wrap the interactive part and STOP: the static markup around it stays in the page, where it costs nothing.
|
|
70
109
|
6. **Validate input at the boundary.** Declare `export const validate` on an action; the RPC and `route()` boundaries run it.
|
|
71
110
|
7. **Default mutations to optimistic UI** where the client can predict the result (`optimistic()` from `@webjsdev/core`).
|
|
72
111
|
8. **Type every boundary from its source, never `unknown` or `any`.** The row type comes from the schema (`typeof todos.$inferSelect`), the action's input from a named `interface` and its result from `ActionResult<T>`, the routing files from `PageProps` / `LayoutProps` / `RouteHandlerContext`. `unknown` belongs on a payload nothing has vouched for yet that the next line narrows, and on a parameter of your own helper that forwards into an `html` template hole. Everywhere else, including a layout's `children`, it is a missing type. See `references/typescript.md`.
|
|
@@ -84,8 +123,10 @@ app/ ROUTING ONLY (thin adapters importing from modules/)
|
|
|
84
123
|
error.ts loading.ts not-found.ts forbidden.ts unauthorized.ts boundaries (nearest wins)
|
|
85
124
|
middleware.ts root middleware
|
|
86
125
|
modules/<feature>/ actions/ (mutations, *.server.ts), queries/ (reads, *.server.ts),
|
|
87
|
-
components
|
|
88
|
-
|
|
126
|
+
components/ (custom elements), types.ts,
|
|
127
|
+
utils/ (pure; returns data, or an html fragment under utils/ui/)
|
|
128
|
+
lib/ lib/*.server.ts server-only infra, lib/utils/ browser-safe helpers,
|
|
129
|
+
lib/utils/ui.ts app-wide html fragments (lib/ui/ once they grow)
|
|
89
130
|
components/*.ts shared presentational custom elements (one per file)
|
|
90
131
|
db/*.server.ts Drizzle: schema, connection
|
|
91
132
|
public/* static assets, served at /public/<name>
|
|
@@ -231,6 +272,7 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
|
|
|
231
272
|
## Common Mistakes To Avoid
|
|
232
273
|
|
|
233
274
|
- Treating a page or layout like a React component and expecting its markup to hydrate. It runs server-only; put interactivity in a component.
|
|
275
|
+
- Promoting a whole page section to a component so that one control inside it can be interactive. The island should wrap the control and the state it reads. An oversized island ships its own JS AND un-elides every display-only component inside it, so the cost is not linear in what you moved.
|
|
234
276
|
- Importing a `.server.ts` utility (no `'use server'`) directly into a shipping component. Its browser stub throws at load; reach it through a `'use server'` action.
|
|
235
277
|
- Using a `static properties` block or a class-field initializer for reactive props instead of the `WebComponent({ ... })` factory.
|
|
236
278
|
- Quoting an event / property / boolean hole (`@click="${fn}"`).
|
|
@@ -93,6 +93,8 @@ html`<link rel="stylesheet" href=${asset('/public/app.css')}>`
|
|
|
93
93
|
|
|
94
94
|
That emits `/public/app.css?v=<hash>` in production and gets the immutable year; the same url un-marked gets a ~1h cap and can serve stale bytes from a CDN after a deploy until something purges it. `asset()` resolves on the server; the browser has no resolver and returns the path unchanged. Call it from a PAGE, LAYOUT, or metadata route, which render only on the server. Inside a component that ships to the browser it silently costs you the caching: hydration is a full client re-render, so the bare path overwrites the hashed one and the asset downloads twice. The url stays valid either way, so this is a convention rather than a `webjs check` rule (`webjs doctor` does flag the plain form, see below). Under `webjs.basePath`, include the prefix yourself (`asset('/app/public/x.css')`): the framework base-path-prefixes only the urls it emits, so an author-written url is already yours to prefix. Two more constraints: call it INSIDE the render function, because a module-scope call is a side effect the elision analyser reads as client work and it ships the whole module; and mark only files that change with a DEPLOY, because the hash is memoized for the process lifetime, so a `public/` file rewritten in place at runtime would keep its old url while being served `immutable` for a year. Off in dev, so dev output is byte-identical. Only `public/` paths resolve; anything else (and a path that fails to resolve) is returned untouched.
|
|
95
95
|
|
|
96
|
+
`asset()` is a PROVIDER SEAM: `@webjsdev/server` installs the resolver at boot by importing `@webjsdev/core` and calling a setter, and that only reaches your app when both sides load the SAME copy of core. Two copies on disk are two independent sets of module-scope state, so the setter lands on one and your `asset()` reads the other, which returns bare paths and never says why. `cspNonce()` and the bound-form identity resolver behind `<form action=${fn}>` sit on the same seam and go inert together. `@webjsdev/server` therefore declares core as a PEER dependency, so npm resolves it against your app's copy and reports a genuine conflict at install time rather than nesting a second one. If you ever do end up with two (a hand-rolled install, a vendored copy), the symptom is those three features quietly doing nothing rather than any error, so reach for `npm ls @webjsdev/core` and `npm dedupe` before looking anywhere else.
|
|
97
|
+
|
|
96
98
|
Forgetting it is the one real cost of opt-in, so `webjs doctor` catches it: a page, layout, or error boundary writing a plain `<link rel="stylesheet" href="/public/app.css">` gets a WARN naming the `file:line` and the fix (#1095). It reads your source and rewrites nothing, and it stays quiet about the non-marks that are deliberate: a cross-origin sheet, a `rel="icon"`, a `rel="preload"`, and any `href=${expr}` hole. Same posture as Rails (a `stylesheet_link_tag` helper over a digest manifest) and Remix (a hashed url from the build graph, surfaced through `links()`): take the fingerprint at the point the url is PRODUCED, never by rewriting a rendered document. A warning is easy to miss, so make it fatal in the app that cares: gate `UNMARKED_ASSET_LINKS` to `error` (see the doctor severity gate below) and one `npm run doctor` step in CI stops the un-versioned url reaching a deploy. The scaffold ships exactly that.
|
|
97
99
|
|
|
98
100
|
It is opt-in rather than automatic because only the author knows which urls are the REQUEST. Do NOT mark a `rel="preload"` hint whose asset is actually fetched by CSS `url()`: the preload cache is keyed on the full url, so a versioned hint can never satisfy the unversioned request the stylesheet makes, and the file is fetched twice. Mark the thing that fetches, not the hint. Every cacheable response also carries a weak `ETag`, and a repeat request with a matching `If-None-Match` gets a `304 Not Modified` with no body. Unstorable (`no-store`) and streamed responses are excluded from the ETag path. A `private` response IS validated: `private` forbids SHARED storage, not validation, and the ETag hashes that response's own body, so two users with different bodies get different ETags and neither can match the other's, while two users with identical bodies are asking about identical bytes, where a 304 discloses nothing (#1140). That is what keeps the client router's partial responses cheap on a page that opted into caching; a default `no-store` page has nothing to validate either way. Dev is byte-faithful (no hashing).
|
|
@@ -216,7 +218,7 @@ An over-limit body responds `413` without buffering the whole payload.
|
|
|
216
218
|
|
|
217
219
|
### Doctor severity gate
|
|
218
220
|
|
|
219
|
-
`webjs doctor` reports project health, and by default only a broken toolchain fails the exit. `--strict` makes EVERY warning fatal, which is unusable in CI, because four checks are environment-shaped: `GIT_HOOK` wants a local pre-commit hook a runner has no reason to have, `ENV_DRIFT` compares against a `.env` CI does not carry, `VENDOR_PIN` fetches the network, and `FRAMEWORK_RESOLVE`
|
|
221
|
+
`webjs doctor` reports project health, and by default only a broken toolchain fails the exit. `--strict` makes EVERY warning fatal, which is unusable in CI, because four checks are environment-shaped: `GIT_HOOK` wants a local pre-commit hook a runner has no reason to have, `ENV_DRIFT` compares against a `.env` CI does not carry, `VENDOR_PIN` fetches the network, and `FRAMEWORK_RESOLVE` plus `FRAMEWORK_LINKS` depend on the environment. So per-check severity is CONFIG, keyed by the stable code every result carries.
|
|
220
222
|
|
|
221
223
|
```jsonc
|
|
222
224
|
{ "webjs": {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## What This Covers
|
|
4
4
|
|
|
5
|
-
- The automatic client router (SPA-style partial swaps), how it opts out, and programmatic `navigate()` / `revalidate()`.
|
|
5
|
+
- The automatic client router (SPA-style partial swaps), how it opts out, and programmatic `navigate()` / `revalidate()` / `refreshPage()`.
|
|
6
6
|
- Link prefetch with device-adaptive defaults.
|
|
7
7
|
- `<webjs-frame>` partial-swap regions (WebJs's Turbo Frames).
|
|
8
8
|
- View Transitions opt-in.
|
|
@@ -44,24 +44,59 @@ enableClientRouter(); // turn soft navigation back on
|
|
|
44
44
|
|
|
45
45
|
Per link, opt out with `data-no-router` (auth flows like `/logout`, OAuth redirects, print views, an experimental route with a different runtime). Cross-origin hrefs, `download`, a non-`_self` target, pure same-page hash jumps, and non-HTML extensions are auto-skipped.
|
|
46
46
|
|
|
47
|
+
**Per link, keep the reader's scroll offset with `data-preserve-scroll`.** A forward navigation scrolls to top, matching what a browser does and what Next and Remix 3 do. The attribute is the escape hatch for a navigation that changes only part of what the reader is looking at: a filter, sort, or tab link whose control sits below the fold, a pager, or a form that re-renders in place with validation errors. WebJs wants it more than most, because a searchParams-only navigation already morphs the deepest shared boundary and preserves hydrated component state, so the scroll is the only thing such a navigation still throws away.
|
|
48
|
+
|
|
49
|
+
```html
|
|
50
|
+
<nav data-preserve-scroll> <!-- covers every link inside -->
|
|
51
|
+
<a href="?sort=new">Newest</a>
|
|
52
|
+
<a href="?sort=top">Top</a>
|
|
53
|
+
<a href="/" data-preserve-scroll="false">Home</a> <!-- opts back out -->
|
|
54
|
+
</nav>
|
|
55
|
+
<form method="post" action=${saveDraft} data-preserve-scroll>...</form>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
It resolves through `closest()`, so one mark on a wrapping element covers every link in it (the same walk `data-webjs-frame` uses), and `data-preserve-scroll="false"` on a nearer element opts back out. On a form the lookup starts at the submitter and falls back to the form itself, so a marked form covers its own buttons whether they sit inside it or are attached from elsewhere with `form="id"`. The fallback only fills in a missing mark, so `data-preserve-scroll="false"` on a button still opts that button out of a marked form.
|
|
59
|
+
|
|
60
|
+
Three things it does NOT do. A hash link still scrolls to its anchor, because the reader named a target and a named target beats a blanket preference. It is inert on a frame-targeted link, since a frame swap never writes a scroll to begin with. And it is inert with JS off, where the link is a plain `<a>` and the browser does whatever it does, so nothing about a page's correctness may depend on it.
|
|
61
|
+
|
|
62
|
+
It carries the reader's CURRENT offset onto the destination; it does not restore the destination's remembered offset. Those are different features, and the second one is not something WebJs ships. So this is the wrong tool for a "back to the list" link, where the offset the reader wants is the one they had in the list, not the one they have in the article.
|
|
63
|
+
|
|
47
64
|
**Programmatic navigation and cache eviction.**
|
|
48
65
|
|
|
49
66
|
```js
|
|
50
67
|
import { navigate, revalidate } from '@webjsdev/core';
|
|
51
68
|
await navigate('/about'); // push history
|
|
52
69
|
await navigate('/login', { replace: true }); // replace history
|
|
70
|
+
await navigate('/products?sort=new', { scroll: false }); // keep the reader's offset
|
|
53
71
|
revalidate('/products/123'); // evict one URL from the snapshot cache
|
|
54
72
|
revalidate(); // clear the entire snapshot cache
|
|
55
73
|
```
|
|
56
74
|
|
|
57
|
-
The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restores instantly, then refetches in the background. Call `revalidate(path)` after a server action mutates data a cached page depends on. Wire bytes are minimized by an `X-Webjs-Have` header, so the server returns only the divergent layout fragment. Concurrent navigations abort the prior in-flight fetch, and scroll is restored on Back/Forward.
|
|
75
|
+
The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restores instantly, then refetches in the background. Call `revalidate(path)` after a server action mutates data a cached page depends on. Wire bytes are minimized by an `X-Webjs-Have` header, so the server returns only the divergent layout fragment. Concurrent navigations abort the prior in-flight fetch, and scroll is restored on Back/Forward. The popstate an in-page fragment CLICK produces is absorbed rather than re-navigated (#1437), so an anchor click restores nothing and re-fetches nothing, the repeat click of one anchor included (that one REPLACES its entry rather than pushing, so it arrives with the url unchanged). The gate is PROVENANCE, the router marking the click it bowed out of and the next popstate consuming that mark, rather than any comparison of urls: a Back between two entries differing only by fragment can still need a re-render, because `getSubmitAction` prefers the raw `action` ATTRIBUTE and that carries no fragment, so a bound-submitter form declaring `action="/p"` pushes its 422 re-render at `/p` while the reader sits at `/p#sec`. A popstate with no click behind it therefore stays on the normal path, which means an ordinary Back or Forward between two fragment states still re-renders. Telling those apart would need to know whether the DOM was replaced between the two ENTRIES, which is per-entry state the router does not keep.
|
|
76
|
+
|
|
77
|
+
**In-place refresh of the page you are on.** `refreshPage(mode)` re-renders the CURRENT url on the server and applies it without a page load.
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
import { refreshPage } from '@webjsdev/core';
|
|
81
|
+
await refreshPage(); // 'page': morph the deepest shared boundary
|
|
82
|
+
await refreshPage('shell'); // replace the whole body (the layout's own markup changed)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
It records no history entry and never scrolls, so the reader keeps their place and Back still goes to the previous page. `'page'` morphs the deepest shared boundary, so the outer layout's DOM and the hydrated state of its components survive; `'shell'` replaces the whole body, which is what a LAYOUT change needs, since a layout's own header, nav, and footer sit outside every children range and a boundary morph would leave them untouched. Component instances do not survive a `'shell'` refresh.
|
|
86
|
+
|
|
87
|
+
It sends no `X-Webjs-Have`, deliberately: the server short-circuits at the first layout the client already holds, and a same-url request matches every one of them, so the response would omit the very layout that changed. It resolves `false` when it did not apply (the router is disabled, or the fetch failed), so a caller falls back to a full load.
|
|
88
|
+
|
|
89
|
+
It does NOT reload changed component modules and cannot: `customElements.define` is once-per-tag and a module url is fetched once per document. A caller whose change touched browser code has to reload. This is exactly why the dev live-reload client calls `refreshPage` for a page or layout edit and `location.reload()` for a component edit (#1398, and see `references/runtime.md` for which dev modes get the refresh).
|
|
90
|
+
|
|
91
|
+
Keep the two scroll concerns apart. The Back/Forward restore below is the BROWSER's and has no per-link knob, because the offset it replays is one the browser recorded. The forward-navigation scroll-to-top is the router's own write, and `data-preserve-scroll` is its knob.
|
|
58
92
|
|
|
59
93
|
**Back/Forward scroll restore vs late layout growth.** The router SUPPRESSES the browser's scroll anchoring (`overflow-anchor`) for the duration of a Back/Forward restore, then puts it back. The saved offset was recorded against the page at its SETTLED height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser treats that late growth as content appearing above a reader and adds it to the offset the router just replayed, so the reader lands BELOW where they left (the reported case was 763px, exactly the height a page gained after its swap). What follows for an app:
|
|
60
94
|
|
|
61
|
-
- **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the
|
|
95
|
+
- **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the restore, which is the BROWSER's (see the next bullet) and which the router protects with a suppression window while the page settles. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code.
|
|
96
|
+
- **The BROWSER restores Back/Forward scroll, not the router, and an app must not set `history.scrollRestoration = 'manual'`** (#1428). The router FORCES `history.scrollRestoration` to `auto` on start (and puts the app's own value back on `disableClientRouter()`), so setting `'manual'` yourself does not take effect while the router runs. Under `auto` the browser records a scroll position per history entry, replays it on a traverse, and composes the iOS edge back-swipe GESTURE PREVIEW from that same recorded state. The router writes no scroll on a restore at all: it reserves the recorded height (below) so the browser's replay lands on a document that can hold the offset, and that is the whole mechanism. One writer, the same model Next and Remix 3 use. Taking `manual` suppresses the recording, so every scrolled page previews BLANK for the whole gesture. That is what the router itself used to do, inherited from Turbo Drive's `assumeControlOfScrollRestoration`, and it is why Turbo still previews blank the same way: Turbo is single-writer too, but the writer is the APP. An app that sets `manual` re-breaks the preview app-wide.
|
|
62
97
|
- **An app that sets `overflow-anchor` on `<html>` itself sees it overridden during a restore and restored afterwards**, including a value set inline by your own script. Setting it in a stylesheet is unaffected between restores. Nothing else on the page is touched, and the router never sets `overflow-anchor` anywhere but the root element.
|
|
63
98
|
- **A new PAGE navigation ends an open window.** The window outlives its own restore on purpose (a floor, then a ceiling), so a page navigation or a page-level form submission starting inside that span closes it first, and reopens only if it earns one. Otherwise a second Back, or a click, would inherit suppressed anchoring on a page it was never meant for. A FRAME-TARGETED navigation or submission is the exception, on exactly the rule that decides frame targeting everywhere else (the enclosing frame, an explicit `data-webjs-frame="<id>"` from anywhere, or the frame's own `src`; `_top` and an unresolvable id are page navigations and do close the window). It swaps one region and leaves the page, and so the restored offset, intact, so it leaves the restore running. Closing there would hand anchoring back mid-restore and bring the double count straight back, and it needs no user input to happen, since a component upgrading in the just-restored page can drive a frame on its own.
|
|
64
|
-
- **
|
|
99
|
+
- **The recorded HEIGHT is reserved across the restore, so the offset is always reachable.** A snapshot records the page's settled `scrollHeight` alongside the offset, and the restore holds that height on the root element until the page has filled in. Without it the swapped-in markup is briefly shorter than the page it came from, the browser clamps the restore to whatever the short document allowed, and the reader lands short. The reservation removes that window rather than correcting for it afterwards, which is what retired the older catch-up that used to chase the offset as the page grew. It is released on the same settle that closes the anchoring window, on the same ceiling, and when another navigation supersedes the restore, but never on user input: releasing the height under a reader mid-scroll is the one harm an early release could do. An app's own inline `min-height` on the root is saved and put back, the same contract the anchoring window keeps.
|
|
65
100
|
- **The window closes on the first real input** (`wheel`, `touchmove`, `keydown`, `pointerdown`), so a reader who starts scrolling mid-restore immediately gets normal browser anchoring back. Absent that it closes once the restore is over, which is the LATER of the restore's own background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor is load-bearing: waiting on the revalidation alone ties the window's length to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it early and the reader would land low again. Suppression only ever WITHHOLDS a browser correction, it never moves the viewport, so it cannot yank someone who has taken over.
|
|
66
101
|
|
|
67
102
|
Components that reach their final size only after they render (a chart, a media embed with no intrinsic dimensions, anything sized from measured content) are exactly the shape that triggers this, and they need no special handling: give them a placeholder height where you can, and let the router own the restore.
|
|
@@ -110,6 +145,10 @@ A prefetch issues a real GET, so any mutating endpoint MUST be a POST or a `<for
|
|
|
110
145
|
|
|
111
146
|
**The cache is ANCHOR-VALIDATED, not just URL-keyed (#1114).** A prefetched fragment is a reduced response: the request carries `X-Webjs-Have` (the boundaries the client already holds) and the server returns only the divergent part from the deepest boundary it short-circuited on. That boundary is the fragment's ANCHOR, and the fragment applies to any live DOM that still offers it with the same route-key. So on consume the router checks the anchor, not the whole `have` string: a root-anchored fragment survives an unrelated navigation and stays a cache hit, while one anchored at `/docs` is discarded once you leave /docs, because applying it would hand the swap a tree sharing no boundary with the live DOM, which correctly degrades to a full page load. A discard costs one round-trip. The router also never prefetches the page it is already on (#1106), since that request cannot serve any later navigation and only occupies a capped cache slot; a hover's intent timer routinely fires after the click it belongs to has already swapped, which is when that happens. Both behaviours are internal; nothing to configure.
|
|
112
147
|
|
|
148
|
+
**The cache also carries a FRAME dimension (#1407).** A link that drives a `<webjs-frame>` is prefetched with the same `X-Webjs-Frame` header its click will send, so the cached body is the frame subtree the swap actually needs and the click resolves with no round trip. The server marks that sliced response `X-Webjs-Frame: <id>` on the way out, and varies on the request header, so the cache keys the entry by URL plus frame id: a page fragment can never be applied into a frame region, nor a frame subtree into a page swap, and each dimension is a separate entry for one URL. A frame entry is validated differently from a page one, because a subtree carries no boundary comment to anchor on: what has to hold is that a live `<webjs-frame>` with that id is still in the document, checked at consume time. Two responses are refused outright. A body answering a framed request WITHOUT the server's marker is a whole document (a streamed render skips the slice, and so does an id absent from the output), so it is discarded rather than stored under either key. The REFUSAL is remembered though, in a small memo kept outside the fragment cache, so a link on a streaming route re-asks about once per TTL instead of on every hover, without occupying a cache slot a real fragment could use (that memo set is itself capped, so a page with many distinct refused frame links can re-ask sooner). Only a detected deploy or an in-place `refreshPage` drops those memos early, since those are the two moments the SOURCE can have changed. `revalidate()` leaves them, because it is the post-mutation api and clearing there would drop every memo on every write; a mutation CAN change a render's streamed shape (a page may render `Suspense` conditionally on fetched data), but a memo that outlives that costs one skipped warm-up for that key until the TTL runs out, which is the cheaper side of the trade. And a framed link pointing at the URL the page is already on is a frame refresh, which must show fresh bytes, so #1106 excludes it in its own dimension.
|
|
149
|
+
|
|
150
|
+
One consequence to know when reading a network tab: dedupe is per dimension, so a page holding TWO links to one URL, one driving a frame and one not, warms both and issues two speculative requests where it previously issued one. That is not redundancy, since the two responses genuinely differ and a click on either link needs its own; suppressing one would leave whichever link lost unwarmed ahead of the click, which bites hardest on touch, where `viewport` is the default and the only thing left is the `touchstart` warm at tap time. Both stay inside the same cache cap, concurrency gate, TTL, and `Save-Data` gate as every other prefetch, and the change adds no new trigger and no per-link fan-out.
|
|
151
|
+
|
|
113
152
|
## `<webjs-frame>` Partial-Swap Regions
|
|
114
153
|
|
|
115
154
|
`<webjs-frame>` is WebJs's take on Turbo Frames, so most `<turbo-frame>` muscle memory transfers. It is a lazy, URL-addressable region that swaps on its own, driven by a link or form targeting its id, and it ships zero component JS. Use it for a region that loads or refreshes INDEPENDENTLY of a full-page navigation (a marketing widget, tabbed UI, a filtered results panel), which a page or layout cannot express.
|
|
@@ -118,7 +157,15 @@ A prefetch issues a real GET, so any mutating endpoint MUST be a POST or a `<for
|
|
|
118
157
|
html`<webjs-frame id="activity">…contents…</webjs-frame>`
|
|
119
158
|
```
|
|
120
159
|
|
|
121
|
-
On click the router walks `closest('webjs-frame')` from the target. If a frame is found and the response carries a matching `<webjs-frame id>`, the swap is scoped to that frame's children, and the server returns ONLY that subtree.
|
|
160
|
+
On click the router walks `closest('webjs-frame')` from the target. If a frame is found and the response carries a matching `<webjs-frame id>`, the swap is scoped to that frame's children, and the server returns ONLY that subtree. A link that drives a frame participates in link prefetch like any other, in that frame's own dimension (#1407), so a hovered or viewport-warmed frame link swaps on click with no round trip. A `<webjs-frame src>` SELF-load is the exception: it neither reads nor keeps that cache, since asking a frame to load its own src is a freshness request rather than a hover being followed. See the prefetch section above for the frame dimension's rules.
|
|
161
|
+
|
|
162
|
+
**A frame swap never moves the window scroll.** A page navigation scrolls to top, the way a browser does; a frame swap changes one region and leaves the rest of the document standing, the reader's scroll offset included. That holds for a nested link, an external `data-webjs-frame` trigger, a frame-targeted form submission, and a `src` self-load alike, and it holds for a `#hash` on a frame link too, which rides the URL without moving the viewport. It does NOT cover a pure fragment link whose path and query match the page it sits on, because the router never sees one: the click handler bows out before `preventDefault`, so the browser does its own native fragment jump and the window moves.
|
|
163
|
+
|
|
164
|
+
**Every spelling of a fragment link is the browser's, the bare `#` included** (#1437). `href="#"` is the back-to-top idiom and it serializes with an EMPTY fragment, which reads identically to no fragment at all through `URL.hash`, so the bow-out tests the `href` for a `#` instead. A `<a href="#">Back to top</a>` therefore scrolls to top natively, inside a frame as well as outside one. `href=""` is NOT a fragment link: it resolves to the current url with the fragment REMOVED, which the spec reloads rather than jumps, so the router navigates it like any other link.
|
|
165
|
+
|
|
166
|
+
The escapes are page navigations and DO scroll: `data-webjs-frame="_top"`, and an id `resolveTargetFrameId` cannot match to a live frame, which warns and degrades to a normal nav. Do not read that second one as covering a RESPONSE that lacks the requested frame (the `webjs:frame-missing` warning). There the frame resolved and the nav stayed frame-scoped, so the offset holds and only the panel is left unchanged. Turbo's `autoscroll` opt-in, which scrolls the frame itself into view on swap, has no WebJs equivalent; the router simply never writes scroll for a frame.
|
|
167
|
+
|
|
168
|
+
**Read "never moves" as "WebJs never writes one", not as a guarantee the viewport cannot move.** A swap that makes the panel SHORTER shortens the document with it, and a reader parked near the bottom is then holding an offset the document can no longer reach, so the browser clamps it. Measured on the gallery's frames demo: filtering from All to Done at the bottom of the page moves the window from 474 to 405, exactly the 69px the document lost. The router wrote no scroll there (verified with every scrolling API instrumented), and any DOM change that shortens a page does the same thing. Keeping the frame a stable height across its states avoids it entirely.
|
|
122
169
|
|
|
123
170
|
**External targeting.** A trigger does not have to be nested inside the frame. An `<a>` or `<form>` carrying `data-webjs-frame="<id>"` drives that frame from anywhere (an explicit `data-webjs-frame` wins over the enclosing-frame default). `data-webjs-frame="_top"` is a reserved token forcing a full-page navigation that breaks out of the frame.
|
|
124
171
|
|
|
@@ -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`).
|
|
@@ -344,6 +382,14 @@ 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
|
+
|
|
347
393
|
## Display-only elision
|
|
348
394
|
|
|
349
395
|
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 +398,12 @@ A component that does no client-side work renders the same SSR'd HTML with or wi
|
|
|
352
398
|
- a factory-declared reactive property that is not `{ state: true }`
|
|
353
399
|
- an overridden lifecycle hook (including `renderFallback` / `renderError`)
|
|
354
400
|
- 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
|
|
401
|
+
- 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. 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
402
|
- 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
403
|
- being rendered by a component that itself ships
|
|
358
404
|
|
|
405
|
+
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.
|
|
406
|
+
|
|
359
407
|
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
408
|
|
|
361
409
|
### What `static interactive = true` does and does not rescue
|
|
@@ -414,4 +462,4 @@ A `WebComponent` inherits `HTMLElement` (browser) or an `ElementShim` (SSR) plus
|
|
|
414
462
|
- 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
463
|
- 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
464
|
|
|
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
|
|
465
|
+
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.
|
|
@@ -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.
|