@webjsdev/cli 0.10.56 → 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 +8 -1
- package/lib/doctor/codes.js +1 -0
- package/lib/doctor/probes/framework-resolves.js +182 -6
- package/lib/doctor/runner.js +2 -1
- package/lib/doctor.js +1 -1
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +4 -3
- package/templates/.agents/skills/webjs/SKILL.md +5 -2
- package/templates/.agents/skills/webjs/references/built-ins.md +3 -1
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +32 -3
- package/templates/.agents/skills/webjs/references/components.md +9 -1
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +26 -2
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +23 -11
- package/templates/.agents/skills/webjs/references/runtime.md +1 -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/client-router/page.ts +5 -1
- package/templates/gallery/app/features/metadata/page.ts +7 -1
- package/templates/gallery/modules/client-router/components/router-controls.ts +7 -0
- 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/lib/create.js
CHANGED
|
@@ -448,7 +448,14 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
448
448
|
// The TypeScript compiler, for `npm run typecheck` (webjs typecheck runs
|
|
449
449
|
// tsc --noEmit). Not needed at runtime (Node strips types in place), only
|
|
450
450
|
// to type-check the app.
|
|
451
|
-
|
|
451
|
+
// Must not resolve below the floor the tsconfig this same generator
|
|
452
|
+
// writes requires: `erasableSyntaxOnly` landed in TypeScript 5.8, and a
|
|
453
|
+
// 5.6 or 5.7 resolution refuses the whole config with
|
|
454
|
+
// `TS5023: Unknown compiler option`. Kept on the major the repo's own
|
|
455
|
+
// apps use, so an app and the framework that generated it type-check
|
|
456
|
+
// under the same compiler. Guarded by
|
|
457
|
+
// test/scaffolds/scaffold-typescript-floor.test.js.
|
|
458
|
+
typescript: '^6.0.3',
|
|
452
459
|
'@types/node': '^24.0.0',
|
|
453
460
|
'@web/test-runner': '^0.20.0',
|
|
454
461
|
'@web/test-runner-playwright': '^0.11.0',
|
package/lib/doctor/codes.js
CHANGED
|
@@ -42,6 +42,7 @@ export const DOCTOR_CODES = {
|
|
|
42
42
|
'vendor-gitignore': 'VENDOR_GITIGNORE',
|
|
43
43
|
'webjs-versions': 'WEBJS_VERSIONS',
|
|
44
44
|
'framework-resolve': 'FRAMEWORK_RESOLVE',
|
|
45
|
+
'framework-links': 'FRAMEWORK_LINKS',
|
|
45
46
|
'importmap-coherence': 'IMPORTMAP_COHERENCE',
|
|
46
47
|
'git-hook': 'GIT_HOOK',
|
|
47
48
|
'Page/layout elision (carrier hygiene)': 'ELISION_CARRIERS',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, statSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -60,9 +60,7 @@ export function checkFrameworkResolves(appDir) {
|
|
|
60
60
|
'@webjsdev/core cannot be resolved from this directory, and this is a git worktree with no ' +
|
|
61
61
|
'node_modules. Git worktrees do not copy node_modules, so the framework is unresolvable here ' +
|
|
62
62
|
'and `webjs dev` / `webjs start` would fail at SSR with a raw ERR_MODULE_NOT_FOUND.',
|
|
63
|
-
fix:
|
|
64
|
-
'Install dependencies in this worktree (`npm install`), or symlink node_modules from the ' +
|
|
65
|
-
'primary checkout (`ln -s ../<primary-checkout>/node_modules node_modules`).',
|
|
63
|
+
fix: freshWorktreeFix(appDir),
|
|
66
64
|
};
|
|
67
65
|
}
|
|
68
66
|
if (!hasNodeModules) {
|
|
@@ -79,6 +77,184 @@ export function checkFrameworkResolves(appDir) {
|
|
|
79
77
|
message:
|
|
80
78
|
'@webjsdev/core cannot be resolved from this directory even though node_modules exists ' +
|
|
81
79
|
'(a partial or corrupted install).',
|
|
82
|
-
fix:
|
|
80
|
+
fix: linkAwareReinstallFix(appDir),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether `dir`'s own `node_modules` is a SYMLINK, which in a linked worktree
|
|
86
|
+
* means it points at the primary checkout's tree. The remedies below branch on
|
|
87
|
+
* this, because `npm install` is the right advice when it is false and is the
|
|
88
|
+
* exact command that corrupts the primary when it is true (#1442).
|
|
89
|
+
* @param {string} dir
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
function modulesAreLinked(dir) {
|
|
93
|
+
try { return lstatSync(join(dir, 'node_modules')).isSymbolicLink(); } catch { return false; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The `npm run worktree:link` sentence, but ONLY where that script exists.
|
|
98
|
+
*
|
|
99
|
+
* This module ships in the PUBLISHED CLI, and `bin/webjs.js` prints these
|
|
100
|
+
* remedies verbatim as the `webjs dev` / `webjs start` preflight failure. A
|
|
101
|
+
* scaffolded app has no `worktree:link` script, so naming it unconditionally
|
|
102
|
+
* sends the exact audience this check exists for (#954, a fresh app worktree)
|
|
103
|
+
* to run something that does not exist. Walk up for a package.json that really
|
|
104
|
+
* declares it, and stay silent otherwise.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} appDir
|
|
107
|
+
* @returns {string} a leading-space sentence, or the empty string
|
|
108
|
+
*/
|
|
109
|
+
function linkScriptHint(appDir) {
|
|
110
|
+
let dir = resolve(appDir);
|
|
111
|
+
for (let i = 0; i < 8; i += 1) {
|
|
112
|
+
try {
|
|
113
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
114
|
+
if (pkg?.scripts?.['worktree:link']) {
|
|
115
|
+
return ' In this repo, `npm run worktree:link` does the whole setup and also repairs the shared tree.';
|
|
116
|
+
}
|
|
117
|
+
} catch { /* no package.json here, keep walking */ }
|
|
118
|
+
const up = dirname(dir);
|
|
119
|
+
if (up === dir) break;
|
|
120
|
+
dir = up;
|
|
121
|
+
}
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Remedy for the #954 fresh-worktree case, which is a worktree with NO
|
|
127
|
+
* `node_modules` at all. There is no symlink in the way yet, so a real install
|
|
128
|
+
* is safe here, and this stays the app-generic advice it has always been.
|
|
129
|
+
* @param {string} appDir
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
function freshWorktreeFix(appDir) {
|
|
133
|
+
return (
|
|
134
|
+
'Install dependencies in this worktree (`npm install`), or symlink node_modules from the ' +
|
|
135
|
+
'primary checkout (`ln -s ../<primary-checkout>/node_modules node_modules`). If you symlink, ' +
|
|
136
|
+
'never run an install through that link afterwards: it acts on the checkout that owns the ' +
|
|
137
|
+
'tree, not this one (#1442).' + linkScriptHint(appDir)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Remedy for a `node_modules` that exists but does not resolve the framework.
|
|
143
|
+
* When it is a SYMLINK, a bare `npm install` is the action that corrupts the
|
|
144
|
+
* checkout that owns the tree, so the advice has to differ.
|
|
145
|
+
* @param {string} appDir
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
function linkAwareReinstallFix(appDir) {
|
|
149
|
+
if (modulesAreLinked(appDir)) {
|
|
150
|
+
return (
|
|
151
|
+
'node_modules here is a SYMLINK at another checkout, so do NOT run `npm install`: it would act ' +
|
|
152
|
+
'on that checkout, not this one (#1442). Either reinstall in the checkout that owns the tree, ' +
|
|
153
|
+
'or remove every node_modules symlink first, nested ones included ' +
|
|
154
|
+
'(`find . -maxdepth 5 -type l -name node_modules -delete`), and install here.' + linkScriptHint(appDir)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return 'Reinstall dependencies (`npm install`, or remove node_modules and reinstall).';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Classify the `@webjsdev/core` entry that `appDir` would resolve through.
|
|
162
|
+
*
|
|
163
|
+
* Walks up for the first `node_modules` carrying the package, then judges the
|
|
164
|
+
* link against the tree that PHYSICALLY owns that `node_modules`. That owner
|
|
165
|
+
* rule is the only one correct in a linked worktree: there `node_modules` is
|
|
166
|
+
* itself a symlink at the primary's, so the owning tree is the PRIMARY and a
|
|
167
|
+
* target inside it is right rather than foreign. Judging against `appDir` would
|
|
168
|
+
* report every correctly linked worktree as corrupted.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} appDir
|
|
171
|
+
* @param {string} [pkg]
|
|
172
|
+
* @returns {{ state: 'absent'|'real'|'ok'|'dangling'|'foreign', entry?: string, target?: string, owner?: string }}
|
|
173
|
+
*/
|
|
174
|
+
export function inspectFrameworkLink(appDir, pkg = '@webjsdev/core') {
|
|
175
|
+
const parts = pkg.split('/');
|
|
176
|
+
let dir = resolve(appDir);
|
|
177
|
+
for (;;) {
|
|
178
|
+
const modules = join(dir, 'node_modules');
|
|
179
|
+
const entry = join(modules, ...parts);
|
|
180
|
+
let st = null;
|
|
181
|
+
try { st = lstatSync(entry); } catch { st = null; }
|
|
182
|
+
if (st) {
|
|
183
|
+
if (!st.isSymbolicLink()) return { state: 'real', entry };
|
|
184
|
+
let target = '';
|
|
185
|
+
try { target = readlinkSync(entry); } catch { return { state: 'real', entry }; }
|
|
186
|
+
// Resolve the target against the directory the link PHYSICALLY sits in,
|
|
187
|
+
// which is what the OS does. In a linked worktree `node_modules` is itself
|
|
188
|
+
// a symlink, so the lexical `dirname(entry)` is under the WORKTREE while
|
|
189
|
+
// the link really lives in the primary. Resolving lexically turns every
|
|
190
|
+
// correct `../../packages/core` into a worktree path and reports a healthy
|
|
191
|
+
// linked worktree as `foreign`.
|
|
192
|
+
let base = dirname(entry);
|
|
193
|
+
try { base = realpathSync(base); } catch { /* fall back to the lexical path */ }
|
|
194
|
+
const abs = resolve(base, target);
|
|
195
|
+
let owner = dir;
|
|
196
|
+
try { owner = dirname(realpathSync(modules)); } catch { /* use dir as given */ }
|
|
197
|
+
if (!existsSync(abs)) return { state: 'dangling', entry, target, owner };
|
|
198
|
+
let real = abs;
|
|
199
|
+
try { real = realpathSync(abs); } catch { /* compare the unresolved path */ }
|
|
200
|
+
let ownerReal = owner;
|
|
201
|
+
try { ownerReal = realpathSync(owner); } catch { /* compare as given */ }
|
|
202
|
+
if (real !== ownerReal && !real.startsWith(ownerReal + sep)) {
|
|
203
|
+
return { state: 'foreign', entry, target, owner: ownerReal };
|
|
204
|
+
}
|
|
205
|
+
return { state: 'ok', entry, target, owner: ownerReal };
|
|
206
|
+
}
|
|
207
|
+
const up = dirname(dir);
|
|
208
|
+
if (up === dir) return { state: 'absent' };
|
|
209
|
+
dir = up;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* CHECK: framework link integrity (#1442). WARN when the `@webjsdev/core` entry
|
|
215
|
+
* in node_modules is a symlink that DANGLES or resolves OUTSIDE the tree that
|
|
216
|
+
* owns it. That is what an install run inside a linked worktree leaves behind,
|
|
217
|
+
* and it is invisible to the framework-resolve check above, which only asks
|
|
218
|
+
* whether the package resolves at all: a link into a live FOREIGN checkout
|
|
219
|
+
* resolves perfectly and silently runs another branch's framework source.
|
|
220
|
+
*
|
|
221
|
+
* Silent PASS for a real directory, a correct link, and no entry at all, so a
|
|
222
|
+
* normally installed app pays one `lstat`. WARN rather than fail, the same
|
|
223
|
+
* environment tier as the framework-resolve and version-coherence checks.
|
|
224
|
+
* @param {string} appDir
|
|
225
|
+
* @returns {DoctorResult}
|
|
226
|
+
*/
|
|
227
|
+
export function checkFrameworkLinks(appDir) {
|
|
228
|
+
const name = 'framework-links';
|
|
229
|
+
const r = inspectFrameworkLink(appDir);
|
|
230
|
+
if (r.state === 'absent' || r.state === 'real' || r.state === 'ok') {
|
|
231
|
+
return {
|
|
232
|
+
name,
|
|
233
|
+
status: 'pass',
|
|
234
|
+
message: 'The @webjsdev framework links resolve inside the checkout that owns them.',
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const fix =
|
|
238
|
+
'Repoint the entry at the package inside the checkout that owns this node_modules. Do NOT run ' +
|
|
239
|
+
'`npm install` here while node_modules is a symlink: it acts on that owning checkout, not this ' +
|
|
240
|
+
'one (#1442).' + linkScriptHint(appDir);
|
|
241
|
+
if (r.state === 'dangling') {
|
|
242
|
+
return {
|
|
243
|
+
name,
|
|
244
|
+
status: 'warn',
|
|
245
|
+
message:
|
|
246
|
+
`${r.entry} is a symlink to ${r.target}, which does not exist. An install run inside a linked ` +
|
|
247
|
+
'worktree leaves these behind, and the worktree it named has since been removed.',
|
|
248
|
+
fix,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
name,
|
|
253
|
+
status: 'warn',
|
|
254
|
+
message:
|
|
255
|
+
`${r.entry} is a symlink to ${r.target}, which resolves OUTSIDE ${r.owner}, the checkout that ` +
|
|
256
|
+
'owns this node_modules. It resolves fine, so nothing fails, and the framework source being run ' +
|
|
257
|
+
"is another checkout's. A deliberate `npm link` produces the same shape.",
|
|
258
|
+
fix,
|
|
83
259
|
};
|
|
84
260
|
}
|
package/lib/doctor/runner.js
CHANGED
|
@@ -10,7 +10,7 @@ import { checkGitHook } from './probes/git-hook.js';
|
|
|
10
10
|
import { checkElisionCarriers, checkElisionComponents } from './probes/elision.js';
|
|
11
11
|
import { checkStaticAssetFreshness } from './probes/static-asset-freshness.js';
|
|
12
12
|
import { checkUnmarkedAssetLinks } from './probes/unmarked-asset-links.js';
|
|
13
|
-
import { checkFrameworkResolves } from './probes/framework-resolves.js';
|
|
13
|
+
import { checkFrameworkResolves, checkFrameworkLinks } from './probes/framework-resolves.js';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* @typedef {import('./codes.js').DoctorResult} DoctorResult
|
|
@@ -57,6 +57,7 @@ export async function runDoctorChecks(appDir, opts = {}) {
|
|
|
57
57
|
checkVendorGitignore(appDir),
|
|
58
58
|
checkWebjsVersions(appDir),
|
|
59
59
|
Promise.resolve(checkFrameworkResolves(appDir)),
|
|
60
|
+
Promise.resolve(checkFrameworkLinks(appDir)),
|
|
60
61
|
checkImportmapCoherence(appDir, opts),
|
|
61
62
|
Promise.resolve(checkGitHook(appDir)),
|
|
62
63
|
checkElisionCarriers(elision),
|
package/lib/doctor.js
CHANGED
|
@@ -53,5 +53,5 @@
|
|
|
53
53
|
export { DOCTOR_SEVERITIES, DOCTOR_CODES, codeForName } from './doctor/codes.js';
|
|
54
54
|
export { readDoctorPolicy, applyDoctorPolicy } from './doctor/policy.js';
|
|
55
55
|
export { readAppBasePath } from './doctor/route-modules.js';
|
|
56
|
-
export { frameworkResolves, checkFrameworkResolves } from './doctor/probes/framework-resolves.js';
|
|
56
|
+
export { frameworkResolves, checkFrameworkResolves, inspectFrameworkLink, checkFrameworkLinks } from './doctor/probes/framework-resolves.js';
|
|
57
57
|
export { runDoctorChecks } from './doctor/runner.js';
|
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
|
|
|
@@ -80,6 +80,7 @@ The table above routes by the job; this one routes by the topic, for when you al
|
|
|
80
80
|
| Server actions, mutations, queries, validation, the `ActionResult` envelope | `references/data-and-actions.md` |
|
|
81
81
|
| Sessions, login flows, route protection, `forbidden()` / `unauthorized()` | `references/auth-and-sessions.md` |
|
|
82
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` |
|
|
83
84
|
| Client router, prefetch, frames, view transitions, Suspense streaming | `references/client-router-and-streaming.md` |
|
|
84
85
|
| Optimistic UI for a user-facing mutation | `references/optimistic-ui.md` |
|
|
85
86
|
| The `@webjsdev/ui` component kit (a `components.json` is present): class helpers, tokens, `add` / `view`, the MCP `ui` tool | `references/ui-kit.md` |
|
|
@@ -122,8 +123,10 @@ app/ ROUTING ONLY (thin adapters importing from modules/)
|
|
|
122
123
|
error.ts loading.ts not-found.ts forbidden.ts unauthorized.ts boundaries (nearest wins)
|
|
123
124
|
middleware.ts root middleware
|
|
124
125
|
modules/<feature>/ actions/ (mutations, *.server.ts), queries/ (reads, *.server.ts),
|
|
125
|
-
components
|
|
126
|
-
|
|
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)
|
|
127
130
|
components/*.ts shared presentational custom elements (one per file)
|
|
128
131
|
db/*.server.ts Drizzle: schema, connection
|
|
129
132
|
public/* static assets, served at /public/<name>
|
|
@@ -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": {
|
|
@@ -44,17 +44,35 @@ 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.
|
|
58
76
|
|
|
59
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.
|
|
60
78
|
|
|
@@ -70,12 +88,15 @@ It sends no `X-Webjs-Have`, deliberately: the server short-circuits at the first
|
|
|
70
88
|
|
|
71
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).
|
|
72
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.
|
|
92
|
+
|
|
73
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:
|
|
74
94
|
|
|
75
|
-
- **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.
|
|
76
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.
|
|
77
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.
|
|
78
|
-
- **
|
|
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.
|
|
79
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.
|
|
80
101
|
|
|
81
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.
|
|
@@ -138,6 +159,14 @@ html`<webjs-frame id="activity">…contents…</webjs-frame>`
|
|
|
138
159
|
|
|
139
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.
|
|
140
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.
|
|
169
|
+
|
|
141
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.
|
|
142
171
|
|
|
143
172
|
**Self-loading.** Give a frame a `src` and it self-fetches (through the same swap path).
|
|
@@ -382,6 +382,14 @@ Import from `@webjsdev/core/directives`. Everything a `class`/`style`/conditiona
|
|
|
382
382
|
| `asyncAppend(iter)` / `asyncReplace(iter)` | Stream from an async iterable, appending each value or replacing with the latest. |
|
|
383
383
|
| `templateContent(el)` | Render the content of a `<template>` element. |
|
|
384
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
|
+
|
|
385
393
|
## Display-only elision
|
|
386
394
|
|
|
387
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:
|
|
@@ -390,7 +398,7 @@ A component that does no client-side work renders the same SSR'd HTML with or wi
|
|
|
390
398
|
- a factory-declared reactive property that is not `{ state: true }`
|
|
391
399
|
- an overridden lifecycle hook (including `renderFallback` / `renderError`)
|
|
392
400
|
- an imported `signal` / `computed` / `watch` / `Task` / `ref` / streaming directive, or `addController` / `requestUpdate`
|
|
393
|
-
- 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)
|
|
394
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)
|
|
395
403
|
- being rendered by a component that itself ships
|
|
396
404
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
## What This Covers
|
|
4
4
|
|
|
5
5
|
- The Next.js patterns that LOOK right in WebJs but break, because WebJs borrows Next's file-based routing shape but not its execution model (no RSC, no `'use client'` split): `redirect()` in a route handler, `fetch()` in a page, `<Link>`, `NEXT_PUBLIC_`, `await params`.
|
|
6
|
-
- The Lit patterns that break WebJs SSR or
|
|
6
|
+
- The Lit patterns that break WebJs SSR, reactivity, or event handling, because WebJs is HTML-first (real HTML first paint, JS opt-in per behaviour) not JS-first: `static properties` / the `@property()` decorator, class-field initializers, browser globals in `render()`, fetching in `connectedCallback`, passing a method straight to an `@event` binding, interpolation into `<style>`, reading `assignedNodes()` in `firstUpdated` of a light-DOM component.
|
|
7
7
|
- The WebJs-shaped fix for each, with short code.
|
|
8
8
|
|
|
9
9
|
Read this when a pattern feels familiar from Next.js or Lit but you are not sure it transfers. For the component runtime see `components.md`; for the routing surface see `routing-and-pages.md`. The one difference underneath everything: pages and layouts render server-only and never hydrate, and the one client boundary is a `WebComponent` custom element.
|
|
@@ -210,10 +210,14 @@ Navigation is automatic. The client router auto-enables when `@webjsdev/core` lo
|
|
|
210
210
|
|
|
211
211
|
### No `<ScrollRestoration>`, and no scroll restore of your own
|
|
212
212
|
|
|
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
|
|
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, 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.
|
|
214
|
+
|
|
215
|
+
**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
216
|
|
|
215
217
|
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
218
|
|
|
219
|
+
**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.
|
|
220
|
+
|
|
217
221
|
### Server-only code: the `.server.ts` boundary, not a `server-only` package
|
|
218
222
|
|
|
219
223
|
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 +290,26 @@ class StudentCard extends WebComponent({ student: prop<Student>(Object) }) {
|
|
|
286
290
|
|
|
287
291
|
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
292
|
|
|
293
|
+
### Passing a method straight to an `@event` binding
|
|
294
|
+
|
|
295
|
+
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.
|
|
296
|
+
|
|
297
|
+
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`.
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
// BROKEN: `this` is undefined when the event fires.
|
|
301
|
+
html`<form @submit=${this.handleSubmit}>`
|
|
302
|
+
|
|
303
|
+
// Wrap it at the binding site:
|
|
304
|
+
html`<form @submit=${(e: SubmitEvent) => this.handleSubmit(e)}>`
|
|
305
|
+
|
|
306
|
+
// Or pre-bind by declaring the handler as an arrow field:
|
|
307
|
+
_onSubmit = (e: SubmitEvent) => { /* ... */ };
|
|
308
|
+
html`<form @submit=${this._onSubmit}>`
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
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.
|
|
312
|
+
|
|
289
313
|
### Expecting shadow DOM and reaching for scoped CSS
|
|
290
314
|
|
|
291
315
|
Lit defaults to shadow DOM, so `static styles = css` scopes automatically. WebJs defaults to light DOM. A `static styles` block without `static shadow = true` does nothing useful and any inline `<style>` with bare class names leaks globally. The webjs-shaped fix is Tailwind utilities, which apply directly in light DOM. Reach for `static shadow = true` plus `static styles` only when scoped CSS genuinely belongs in a shadow root, or prefix every selector with the tag name if authoring vanilla light-DOM CSS.
|
|
@@ -85,7 +85,7 @@ TodoList.register('todo-list');
|
|
|
85
85
|
|
|
86
86
|
### Author the optimistic mutation as a degrade-first form
|
|
87
87
|
|
|
88
|
-
Wrap the mutation in a REAL `<form>` bound to the action, then intercept it for the optimistic path. One form serves both: with JS off the browser submits and the server dispatches to that action (the no-JS write path, see `routing-and-pages.md`), and with JS on `@submit` calls `e.preventDefault()` and runs the optimistic path. That is the progressive-enhancement contract, not a fetch-only handler.
|
|
88
|
+
Wrap the mutation in a REAL `<form>` bound to the action, then intercept it for the optimistic path. One form serves both: with JS off the browser submits and the server dispatches to that action (the no-JS write path, see `routing-and-pages.md`), and with JS on `@submit` calls `e.preventDefault()` and runs the optimistic path. That is the progressive-enhancement contract, not a fetch-only handler. Note the arrow wrapper on the listener: WebJs does not bind an `@event` handler to your component, so passing the method directly would leave `this` pointing at a framework-internal object (see `muscle-memory-gotchas.md`).
|
|
89
89
|
|
|
90
90
|
The SAME imported function is the form binding and the optimistic path's callee, which is what makes a degrade-first form cheap to write: there is no second wiring to keep in step.
|
|
91
91
|
|
|
@@ -94,7 +94,7 @@ import { createTodo } from '#modules/todo/actions/create-todo.server.ts';
|
|
|
94
94
|
|
|
95
95
|
render() {
|
|
96
96
|
return html`
|
|
97
|
-
<form action=${createTodo} @submit=${this.handleSubmit}>
|
|
97
|
+
<form action=${createTodo} @submit=${(e: SubmitEvent) => this.handleSubmit(e)}>
|
|
98
98
|
<input name="title" required>
|
|
99
99
|
<button>Add</button>
|
|
100
100
|
</form>
|
|
@@ -130,19 +130,31 @@ The component reads that seeded prop as its `optimistic()` `source`, so `source:
|
|
|
130
130
|
For a boolean toggle where the value itself is the mutation (like, follow, pin), `optimistic(signal, value, action)` is a thin wrapper over the signal primitive.
|
|
131
131
|
|
|
132
132
|
```ts
|
|
133
|
-
import { signal, optimistic } from '@webjsdev/core';
|
|
133
|
+
import { WebComponent, prop, signal, optimistic, html } from '@webjsdev/core';
|
|
134
134
|
import { likePost } from '#modules/posts/actions/like-post.server.ts';
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
136
|
+
class LikeButton extends WebComponent({ postId: prop(String) }) {
|
|
137
|
+
// INSTANCE scope: one signal per element. A module-scope `signal()` is SHARED
|
|
138
|
+
// across every instance (invariant 5), so a feed of these would all flip
|
|
139
|
+
// together on one click. Scope per-item state to the instance.
|
|
140
|
+
private liked = signal(false);
|
|
141
|
+
|
|
142
|
+
private async toggle() {
|
|
143
|
+
const next = !this.liked.get();
|
|
144
|
+
// Returns the action's ActionResult, so a { success: false } is readable
|
|
145
|
+
// by the caller. The rollback has already happened by then.
|
|
146
|
+
return optimistic(this.liked, next, () => likePost(this.postId));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
render() {
|
|
150
|
+
return html`<button @click=${() => this.toggle()}>${this.liked.get() ? 'Liked' : 'Like'}</button>`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
LikeButton.register('like-button');
|
|
144
154
|
```
|
|
145
155
|
|
|
156
|
+
Reserve MODULE scope for state that genuinely is app-wide (a theme, a cart count, a sidebar-open flag), which is the case invariant 5's "module-scope signals share state across components" exists to serve. Per-item state goes on the instance, as above. `liked` is a plain instance field, not a reactive property declared in the factory, so the class-field ban does not reach it and `reactive-props-no-class-field` does not flag it. `gallery/modules/optimistic-ui/components/like-button.ts` is the same shape in shipped code.
|
|
157
|
+
|
|
146
158
|
It rolls back on a thrown error OR an `ActionResult` `{ success: false }` envelope, and never on success. It is client-only (it mutates a signal), so a component importing it is never elided as a display-only component.
|
|
147
159
|
|
|
148
160
|
## When Optimistic UI Is Appropriate
|
|
@@ -20,7 +20,7 @@ Pick a runtime from the deploy target, not the code. Default to Node unless you
|
|
|
20
20
|
Three seams pick a runtime-specific implementation, all inside the framework, none in your app:
|
|
21
21
|
|
|
22
22
|
- **The listener.** `startServer` selects the `node:http` request shell on Node and a native `Bun.serve` shell on Bun. Both parse the request, run middleware, dispatch to your routes, and stream the response through the same downstream pipeline, so an SSR page, a server action RPC, and a route handler behave identically.
|
|
23
|
-
- **The type stripper.** WebJs serves `.ts` / `.
|
|
23
|
+
- **The type stripper.** WebJs serves `.ts` / `.mts` as ES modules by erasing the types in place with no bundler. Those two are the whole set: there is no JSX anywhere in the framework, so a `.tsx` file is not served. On Node that is the built-in `module.stripTypeScriptTypes`; on Bun it is `amaro` (the same engine, byte-identical and position-preserving so stack traces still point at the right line). Either way your TypeScript must be erasable (see `typescript.md`).
|
|
24
24
|
- **A few built-ins.** SQLite, hot reload, and WebSockets each bind to the runtime's native primitive (see the table).
|
|
25
25
|
|
|
26
26
|
## Node vs Bun at a glance
|
|
@@ -36,7 +36,20 @@ When custom CSS IS unavoidable inside a light-DOM component, the tag-prefix inva
|
|
|
36
36
|
|
|
37
37
|
## DRY via a JS helper, not `@apply`
|
|
38
38
|
|
|
39
|
-
When the same Tailwind bundle repeats across 2+ places, extract it into a helper
|
|
39
|
+
When the same Tailwind bundle repeats across 2+ places, extract it into a helper that returns an `html` fragment (SSR-time, no client runtime, output identical to inline classes). Where the helper LIVES follows the narrowest-owner rule, so pick the tier by who consumes it:
|
|
40
|
+
|
|
41
|
+
| Consumers | Home |
|
|
42
|
+
|---|---|
|
|
43
|
+
| routes across the app (a heading, a lede, a back link) | `lib/utils/ui.ts` |
|
|
44
|
+
| one feature (a todo row, a comment card, a board) | `modules/<feature>/utils/ui/<name>.ts` |
|
|
45
|
+
|
|
46
|
+
One file per fragment under `utils/ui/`, because a feature accumulates several and one-per-file keeps them greppable. A fragment promotes from the feature tier to `lib/` only when a second feature genuinely consumes it.
|
|
47
|
+
|
|
48
|
+
The app-wide tier grows the same way, on the same judgment `references/module-structure.md` applies to any module. `lib/utils/ui.ts` is where it starts and where it usually stays: small, independent, one-element helpers belong together in one file, however many of them there are (the blog example keeps nine there quite happily). Split to `lib/ui/<name>.ts`, one file per fragment, when a fragment stops being a one-liner, when one composes others, or when the single file is no longer scannable. The framework's own website crossed that line and its four composed page fragments live in `lib/ui/`.
|
|
49
|
+
|
|
50
|
+
So `modules/<feature>/utils/ui/`, `lib/utils/ui.ts`, and `lib/ui/` are one convention at three sizes rather than three conventions, and the `ui` segment is the part carrying the meaning at every one of them: inside `modules/<feature>/`, `components/` holds custom elements, `utils/ui/` holds functions returning a `TemplateResult`, and the rest of `utils/` holds functions returning data. Drop the segment and a view fragment ends up beside a pure data helper with nothing in the path to tell them apart.
|
|
51
|
+
|
|
52
|
+
The example below is the app-wide tier:
|
|
40
53
|
|
|
41
54
|
```ts
|
|
42
55
|
import { html } from '@webjsdev/core';
|
|
@@ -62,12 +75,44 @@ export default function Post({ params }) {
|
|
|
62
75
|
| Repeats | Action |
|
|
63
76
|
|---|---|
|
|
64
77
|
| Once | Inline the classes. |
|
|
65
|
-
| 2 to 3 times, identical | Extract to `
|
|
78
|
+
| 2 to 3 times, identical, inside ONE feature | Extract to `modules/<feature>/utils/ui/<name>.ts`. |
|
|
79
|
+
| 2 to 3 times, identical, across features or routes | Extract to `lib/utils/ui.ts`. |
|
|
66
80
|
| Varies by 1 to 2 props | Extract with a small parameter (`mb: 'sm' \| 'md'`). |
|
|
67
81
|
| Radically different per call site | Keep inline, do not force-fit. |
|
|
68
82
|
|
|
69
83
|
Avoid `@apply`: it hides which utilities a class uses and creates a second source of truth. A JS helper keeps the bundle visible at the definition site, composes with conditional classes and active states, and runs at SSR time.
|
|
70
84
|
|
|
85
|
+
### Fragment or display-only component?
|
|
86
|
+
|
|
87
|
+
Two questions, in order.
|
|
88
|
+
|
|
89
|
+
**First, is this a UNIT or a repeated CLASS BUNDLE?** The helpers this section began with (a heading, a lede, a back link) are the second kind: one element with a class list you did not want to type twice. That is a fragment by definition and never a component; nobody wants `<page-lede>` as a tag in their DOM, and promoting a class bundle to an element is the same over-reach as absorbing a page section into an island. The question below only arises for a genuine unit of markup (a row, a card, a board) that could reasonably be either.
|
|
90
|
+
|
|
91
|
+
**Second, for a unit: can the markup carry an extra wrapper element at all?** A component is a tag in the DOM, so choosing one adds a node between the parent and the markup. Usually that is fine and the component is the better choice, since it gets a tag name to target and can grow behaviour later. Two cases make it impossible outright:
|
|
92
|
+
|
|
93
|
+
| Case | What happens |
|
|
94
|
+
|---|---|
|
|
95
|
+
| a `<table>` / `<tbody>` child | the parser FOSTER-PARENTS the element out of the table (an HTML spec rule, not a WebJs one), so it lands BEFORE the table and its cells are adopted by a `<tr>` it no longer owns. The component never renders where you put it |
|
|
96
|
+
| output that is not DOM | a `<webjs-stream>` payload, or HTML a `route.ts` returns, is a STRING, and a component has no way to produce one |
|
|
97
|
+
|
|
98
|
+
Three more render fine and are wrong in ways that surface later, so treat them as strong reasons rather than hard blocks. Measured in Chromium, the element survives in all three:
|
|
99
|
+
|
|
100
|
+
| Case | What survives, what breaks |
|
|
101
|
+
|---|---|
|
|
102
|
+
| a `<ul>` / `<ol>` / `<dl>` child | the list renders, but `ul > li`, `:nth-child`, and list markers now see the wrapper instead of the row |
|
|
103
|
+
| a `<select>` child | the control still offers a wrapped `<option>` (it is in `select.options`), but it is no longer `select > option`, so selector-based CSS and DOM code miss it, and the content model is invalid |
|
|
104
|
+
| a `grid` / `flex` child | the wrapper becomes THE ITEM, so the children you meant to lay out sit one level too deep and the track sizing applies to the wrong box |
|
|
105
|
+
|
|
106
|
+
Everywhere else the two are interchangeable, and the remaining difference is cost. Be precise about that too, because the obvious summary ("fragments are free, components ship") is wrong in both directions.
|
|
107
|
+
|
|
108
|
+
**Rendered only by pages, they cost the same: nothing.** A page is inert or import-only, so a fragment it calls runs at SSR and is never fetched. A component that does no client work is elided, so it is never fetched either. Byte arguments do not decide this case; pick whichever reads better.
|
|
109
|
+
|
|
110
|
+
**Rendered by a shipping island, BOTH ship.** A module a shipping component imports is fetched, fragment or not, so the fragment's function is downloaded too (verify it yourself: watch the network panel for the helper's path). What differs is what else comes with it. The component ships a custom element class plus its registration and upgrades once per instance, and, because elision propagates downward, it un-elides every display-only component IT renders. The fragment ships a function and stops there.
|
|
111
|
+
|
|
112
|
+
So near an island the fragment is the smaller and more predictable choice, not a free one, and the gap is a class and its blast radius rather than everything. That is a tiebreaker, not a rule: it is worth acting on where a shipping island is or might become the renderer, and not worth reorganising page-level markup over. When it matters, measure with `webjs elision` rather than reasoning from either rule of thumb.
|
|
113
|
+
|
|
114
|
+
**The summary.** A repeated class bundle is always a fragment. For a real unit, take the fragment where a wrapper element cannot exist (a table child, or string output) and where it would land wrong (a list, select, grid, or flex child); take the component whenever the markup needs behaviour, wants a tag to target, or might grow either; and treat the byte difference as the last consideration rather than the first.
|
|
115
|
+
|
|
71
116
|
### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui`
|
|
72
117
|
|
|
73
118
|
An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. Conflicts are keyed on the CSS PROPERTY wherever `cn` can tell the properties apart, rather than on the shared class prefix, so the common prefix collisions do NOT evict: `cn('border-2', 'border-primary')` keeps both (a width and a colour), `cn('flex', 'flex-1')` keeps both (a `display` and a `flex-grow`, the shape an element that is both a flex container and a flex child needs), `cn('shadow-lg', 'shadow-red-500')` keeps both (a box-shadow and its colour), `cn('bg-clip-text', 'bg-primary')` keeps both (a clip and a colour, so the gradient-text idiom survives a later background), and an arbitrary value carrying a type hint is read as the property the hint names (`cn('shadow-lg', 'shadow-[color:red]')` keeps both). It is a small hand-rolled merger, not `tailwind-merge`, so it is still coarse in two ways. A prefix outside the families it knows is not grouped at all, so both classes are emitted and the winner is left to compiled stylesheet order (`inset-shadow-sm` against `inset-shadow-red-500`, `ring-2` against `ring-red-500`). And where one prefix carries two properties it reads the value against Tailwind's DEFAULT scales, so a `@theme`-extended name it cannot know about can still be misread and evict the wrong class: a custom `--shadow-card` makes `shadow-card` a box-shadow, but `cn` sees an unfamiliar name under a prefix whose bare names are usually colours and treats it as one. When an override has to win and you are unsure, pass the one class rather than layering, or install `clsx` + `tailwind-merge` and replace the helper (its header comment shows the swap). For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size.
|
|
@@ -33,7 +33,3 @@ in [`CONVENTIONS.md`](../CONVENTIONS.md) for the full guidance.
|
|
|
33
33
|
there.
|
|
34
34
|
- [ ] **Scaffold scripts / codegen** (if the project has any). Updated
|
|
35
35
|
when the change affects what new instances generate.
|
|
36
|
-
- [ ] **Pre-merge self-review loop.** Ran N rounds; last round clean.
|
|
37
|
-
Skip only for one-line trivial changes. See the **Pre-merge
|
|
38
|
-
self-review loop** section in [`CONVENTIONS.md`](../CONVENTIONS.md)
|
|
39
|
-
for the prompt template and reporting contract.
|
|
@@ -39,7 +39,11 @@ export default function ClientRouterExample() {
|
|
|
39
39
|
Opt out app-wide with <code class="font-mono">{ "webjs": { "clientRouter": false } }</code>,
|
|
40
40
|
or per-link with <code class="font-mono">data-no-router</code> (use it for
|
|
41
41
|
auth flows like <code class="font-mono">/logout</code> that must reset
|
|
42
|
-
in-memory state).
|
|
42
|
+
in-memory state). A forward navigation scrolls to top; per link (or per
|
|
43
|
+
wrapping element) <code class="font-mono">data-preserve-scroll</code> keeps
|
|
44
|
+
the reader where they are, for a filter or tab link that changes only part
|
|
45
|
+
of what they are looking at. A hash link still scrolls to its anchor, and
|
|
46
|
+
a frame-targeted link never scrolled anyway.
|
|
43
47
|
</p>
|
|
44
48
|
`;
|
|
45
49
|
}
|
|
@@ -42,7 +42,13 @@ export default function MetadataExample({
|
|
|
42
42
|
Current title source:
|
|
43
43
|
<code class="font-mono text-sm">${topic ? '?topic=' + topic : '(default, no ?topic=)'}</code>
|
|
44
44
|
</p>
|
|
45
|
-
|
|
45
|
+
<!-- data-preserve-scroll keeps the reader's scroll offset instead of
|
|
46
|
+
jumping to the top on click. These links change only the query string
|
|
47
|
+
of the page you are already on, so the control you just used would
|
|
48
|
+
otherwise scroll out from under you. It sits on the <ul> and the router
|
|
49
|
+
resolves it with closest(), so one mark covers every link inside; a
|
|
50
|
+
single link can opt back out with data-preserve-scroll="false". -->
|
|
51
|
+
<ul class="list-disc pl-5 mb-4" data-preserve-scroll>
|
|
46
52
|
<li><a class="text-primary underline underline-offset-2" href="/features/metadata?topic=webjs">?topic=webjs</a></li>
|
|
47
53
|
<li><a class="text-primary underline underline-offset-2" href="/features/metadata?topic=Routing">?topic=Routing</a></li>
|
|
48
54
|
<li><a class="text-primary underline underline-offset-2" href="/features/metadata">clear the param</a></li>
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// swap an <a> click does, but from an event handler (after a save, a wizard
|
|
3
3
|
// step, etc.). `revalidate(url?)` evicts the browser snapshot cache so the next
|
|
4
4
|
// visit refetches fresh HTML instead of the cached page.
|
|
5
|
+
// `navigate(url, { scroll: false })` is the programmatic twin of
|
|
6
|
+
// `data-preserve-scroll` on a link: the same soft swap, without the
|
|
7
|
+
// scroll-to-top. Reach for it after an in-page action that changes the URL but
|
|
8
|
+
// should not move the reader.
|
|
5
9
|
// `refreshPage(mode?)` re-renders the page you are ALREADY on and swaps the
|
|
6
10
|
// result in place, recording no history entry and never scrolling, so the reader
|
|
7
11
|
// keeps their place. 'page' (the default) morphs the deepest shared boundary, so
|
|
@@ -37,6 +41,9 @@ export class RouterControls extends WebComponent {
|
|
|
37
41
|
<button
|
|
38
42
|
@click=${() => navigate('/features/client-router/second')}
|
|
39
43
|
class=${buttonClass({ variant: 'secondary' })}>navigate() to page two</button>
|
|
44
|
+
<button
|
|
45
|
+
@click=${() => navigate('/features/client-router/second', { scroll: false })}
|
|
46
|
+
class=${buttonClass({ variant: 'secondary' })}>navigate(..., { scroll: false })</button>
|
|
40
47
|
<button
|
|
41
48
|
@click=${() => revalidate()}
|
|
42
49
|
class=${buttonClass({ variant: 'link', size: 'none' })}>revalidate() the snapshot cache</button>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Co-located browser test for the stream demo, in real Chromium with real SSR
|
|
2
|
+
// and hydration. The runner UI is `tdd` (suite/test) and there is no assertion
|
|
3
|
+
// library, so a tiny inline assert does the job.
|
|
4
|
+
//
|
|
5
|
+
// This pins the one thing the row fragment has to get right: the SEEDED rows
|
|
6
|
+
// (rendered from the html`` shape) and the STREAMED rows (rendered from the
|
|
7
|
+
// string shape) come off one class list, so a mutation cannot leave the list
|
|
8
|
+
// styled two ways. Both shapes are exercised through the real component.
|
|
9
|
+
import { html } from '@webjsdev/core';
|
|
10
|
+
import { ssrFixture } from '@webjsdev/core/testing';
|
|
11
|
+
import '../stream-demo.ts';
|
|
12
|
+
|
|
13
|
+
const assert = (cond, msg) => { if (!cond) throw new Error(msg || 'assertion failed'); };
|
|
14
|
+
const rows = (el) => [...el.querySelectorAll('#stream-list > li')];
|
|
15
|
+
const button = (el, label) => [...el.querySelectorAll('button')].find((b) => b.textContent.trim() === label);
|
|
16
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
17
|
+
|
|
18
|
+
suite('<stream-demo>', () => {
|
|
19
|
+
test('SSRs the two seeded rows as direct list children', async () => {
|
|
20
|
+
const el = await ssrFixture(html`<stream-demo></stream-demo>`);
|
|
21
|
+
const ids = rows(el).map((li) => li.id);
|
|
22
|
+
assert(ids.join(',') === 'row-1,row-2', `seeded ids, got ${ids}`);
|
|
23
|
+
// A direct child, no wrapper element between the list and the row. That is
|
|
24
|
+
// the structural reason the row is a fragment and not a display-only element.
|
|
25
|
+
assert(rows(el).every((li) => li.parentElement.id === 'stream-list'), 'rows are direct children of the list');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a streamed row carries the same classes as a seeded row', async () => {
|
|
29
|
+
const el = await ssrFixture(html`<stream-demo></stream-demo>`);
|
|
30
|
+
const seeded = rows(el)[0].className;
|
|
31
|
+
button(el, 'Append').click();
|
|
32
|
+
await tick();
|
|
33
|
+
const all = rows(el);
|
|
34
|
+
assert(all.length === 3, `three rows after append, got ${all.length}`);
|
|
35
|
+
const streamed = all[2];
|
|
36
|
+
assert(streamed.id === 'row-3', `appended row is row-3, got ${streamed.id}`);
|
|
37
|
+
assert(streamed.className === seeded, `streamed row classes match seeded:\n ${streamed.className}\n ${seeded}`);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('the string shape escapes its holes', async () => {
|
|
41
|
+
// The html`` shape escapes its own holes; this plain-string one has to do
|
|
42
|
+
// it by hand, and it is the shape a reader copies. A raw value here would
|
|
43
|
+
// become markup as soon as renderStream() inserted it.
|
|
44
|
+
const { streamRowHTML } = await import('../../utils/ui/row.ts');
|
|
45
|
+
const out = streamRowHTML('x" onload="boom', '<img src=x onerror=boom>');
|
|
46
|
+
assert(!out.includes('<img'), `text hole is escaped, got ${out}`);
|
|
47
|
+
assert(!out.includes('" onload'), `attribute hole is escaped, got ${out}`);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('replace keeps the id and reset restores the seed list', async () => {
|
|
51
|
+
const el = await ssrFixture(html`<stream-demo></stream-demo>`);
|
|
52
|
+
button(el, 'Replace Row 1').click();
|
|
53
|
+
await tick();
|
|
54
|
+
assert(rows(el)[0].id === 'row-1', 'replace keeps row-1');
|
|
55
|
+
assert(rows(el)[0].textContent.includes('replaced'), 'replace swaps the content');
|
|
56
|
+
button(el, 'Prepend').click();
|
|
57
|
+
await tick();
|
|
58
|
+
button(el, 'Reset').click();
|
|
59
|
+
await tick();
|
|
60
|
+
const ids = rows(el).map((li) => li.id);
|
|
61
|
+
assert(ids.join(',') === 'row-1,row-2', `reset restores the seed list, got ${ids}`);
|
|
62
|
+
assert(rows(el)[0].textContent.trim() === 'Row 1', 'reset restores the seed content');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
// region-swap would clobber.
|
|
14
14
|
import { WebComponent, html, renderStream } from '@webjsdev/core';
|
|
15
15
|
import { buttonClass } from '#components/ui/button.ts';
|
|
16
|
+
// The row markup lives in ONE place, a feature-local view fragment under
|
|
17
|
+
// `utils/ui/` (see references/styling.md for that folder). A fragment rather
|
|
18
|
+
// than a display-only <stream-row> element because BOTH of this demo's uses
|
|
19
|
+
// rule an element out, which is the test to apply: the seeded list needs a
|
|
20
|
+
// direct `<ul> > <li>` child (a wrapper tag would sit between them and break
|
|
21
|
+
// the selector and the list semantics), and the streamed payload is an HTML
|
|
22
|
+
// STRING, which a component cannot produce at all. Where a wrapper IS fine,
|
|
23
|
+
// prefer the component.
|
|
24
|
+
import { streamRow, streamRowHTML } from '../utils/ui/row.ts';
|
|
16
25
|
|
|
17
26
|
// Build a <webjs-stream> payload string. It is a plain string (NOT an html``
|
|
18
27
|
// template), so interpolating the row markup here is fine. `remove` needs no
|
|
@@ -22,9 +31,6 @@ function streamPayload(action: string, target: string, inner = '') {
|
|
|
22
31
|
return `<webjs-stream action="${action}" target="${target}">${body}</webjs-stream>`;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
|
-
const rowCls = 'flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground';
|
|
26
|
-
const row = (id: string, label: string) => `<li id="${id}" class="${rowCls}">${label}</li>`;
|
|
27
|
-
|
|
28
34
|
export class StreamDemo extends WebComponent {
|
|
29
35
|
// A plain instance field, NOT a signal: incremented to mint unique row ids.
|
|
30
36
|
// It is never read inside render(), so appending a row does not re-render the
|
|
@@ -37,16 +43,16 @@ export class StreamDemo extends WebComponent {
|
|
|
37
43
|
// never runs. Name your handlers something else (see muscle-memory-gotchas).
|
|
38
44
|
appendRow() {
|
|
39
45
|
this.#n++;
|
|
40
|
-
renderStream(streamPayload('append', 'stream-list',
|
|
46
|
+
renderStream(streamPayload('append', 'stream-list', streamRowHTML(`row-${this.#n}`, `Row ${this.#n} (appended)`)));
|
|
41
47
|
}
|
|
42
48
|
prependRow() {
|
|
43
49
|
this.#n++;
|
|
44
|
-
renderStream(streamPayload('prepend', 'stream-list',
|
|
50
|
+
renderStream(streamPayload('prepend', 'stream-list', streamRowHTML(`row-${this.#n}`, `Row ${this.#n} (prepended)`)));
|
|
45
51
|
}
|
|
46
52
|
replaceFirst() {
|
|
47
53
|
// `replace` swaps the target element itself. The replacement keeps id row-1,
|
|
48
54
|
// so the button stays repeatable.
|
|
49
|
-
renderStream(streamPayload('replace', 'row-1',
|
|
55
|
+
renderStream(streamPayload('replace', 'row-1', streamRowHTML('row-1', 'Row 1 (replaced)')));
|
|
50
56
|
}
|
|
51
57
|
removeSecond() {
|
|
52
58
|
// `remove` deletes the target and needs no <template>.
|
|
@@ -54,7 +60,7 @@ export class StreamDemo extends WebComponent {
|
|
|
54
60
|
}
|
|
55
61
|
reset() {
|
|
56
62
|
// `update` replaces the target's children, restoring the seed list.
|
|
57
|
-
renderStream(streamPayload('update', 'stream-list',
|
|
63
|
+
renderStream(streamPayload('update', 'stream-list', streamRowHTML('row-1', 'Row 1') + streamRowHTML('row-2', 'Row 2')));
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
render() {
|
|
@@ -71,8 +77,7 @@ export class StreamDemo extends WebComponent {
|
|
|
71
77
|
<!-- The target list. renderStream() mutates it by id; this markup renders
|
|
72
78
|
once and is never re-rendered by the component. -->
|
|
73
79
|
<ul id="stream-list" class="grid gap-2 m-0 p-0 list-none">
|
|
74
|
-
|
|
75
|
-
<li id="row-2" class="flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground">Row 2</li>
|
|
80
|
+
${streamRow('row-1', 'Row 1')}${streamRow('row-2', 'Row 2')}
|
|
76
81
|
</ul>
|
|
77
82
|
</div>
|
|
78
83
|
`;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// A feature-local VIEW FRAGMENT: pure, returns markup, used only by the stream
|
|
2
|
+
// demo. It lives in `modules/<feature>/utils/ui/` rather than `utils/` (which
|
|
3
|
+
// holds helpers returning DATA) or `components/` (which holds custom elements).
|
|
4
|
+
// See .agents/skills/webjs/references/styling.md.
|
|
5
|
+
//
|
|
6
|
+
// WHY a fragment and not a display-only <stream-row> element: an element is a
|
|
7
|
+
// tag in the DOM, and neither caller can carry one. The list wants a direct
|
|
8
|
+
// `<ul> > <li>` child, so a wrapper would break that selector and the list
|
|
9
|
+
// semantics; and <webjs-stream> takes its payload as an HTML STRING, which no
|
|
10
|
+
// component can return. That is the test, not bytes: where a wrapper element is
|
|
11
|
+
// harmless, reach for the component instead.
|
|
12
|
+
//
|
|
13
|
+
// Hence the two shapes below, off one class list, so the streamed row and the
|
|
14
|
+
// seeded rows cannot drift apart. That drift was live: the class list used to
|
|
15
|
+
// exist three times in this feature, once in a `rowCls` const and twice inlined
|
|
16
|
+
// in the component's own template, which did not use the const.
|
|
17
|
+
import { html, escapeAttr, escapeText } from '@webjsdev/core';
|
|
18
|
+
|
|
19
|
+
const ROW =
|
|
20
|
+
'flex items-center gap-2 px-3 py-2 rounded-xl bg-card border border-border text-[15px] text-foreground';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The row as a template, for the component's own render(). `unknown` on the
|
|
24
|
+
* label because it forwards straight into an `html` hole, which renders a
|
|
25
|
+
* string, a number, a TemplateResult, or an array of those.
|
|
26
|
+
*/
|
|
27
|
+
export function streamRow(id: string, label: unknown) {
|
|
28
|
+
return html`<li id=${id} class=${ROW}>${label}</li>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The same row as an HTML string, for a <webjs-stream> template payload.
|
|
33
|
+
*
|
|
34
|
+
* ESCAPE EVERY HOLE HERE. The `html` tag above escapes its own holes; this
|
|
35
|
+
* plain template literal does NOT, so an unescaped value becomes markup the
|
|
36
|
+
* moment renderStream() puts it in the document. The demo only ever passes its
|
|
37
|
+
* own literals, but this is the shape people copy, so it does the safe thing.
|
|
38
|
+
*/
|
|
39
|
+
export function streamRowHTML(id: string, label: string): string {
|
|
40
|
+
return `<li id="${escapeAttr(id)}" class="${ROW}">${escapeText(label)}</li>`;
|
|
41
|
+
}
|
|
@@ -5,6 +5,7 @@ import { dirname, resolve } from 'node:path';
|
|
|
5
5
|
|
|
6
6
|
import { createRequestHandler } from '@webjsdev/server';
|
|
7
7
|
import { testRequest } from '@webjsdev/server/testing';
|
|
8
|
+
import type { Handle } from '@webjsdev/server/testing';
|
|
8
9
|
|
|
9
10
|
const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
10
11
|
|
|
@@ -22,7 +23,7 @@ const MAX = 5;
|
|
|
22
23
|
// X-Forwarded-For that DISAGREES, standing in for the CDN egress address the
|
|
23
24
|
// real deploy puts there, so a test that passes only because the two agree
|
|
24
25
|
// cannot exist.
|
|
25
|
-
function ping(handle:
|
|
26
|
+
function ping(handle: Handle, visitor: string, cdnEgress = '172.68.1.9') {
|
|
26
27
|
return testRequest(handle, PING, {
|
|
27
28
|
headers: { 'cf-connecting-ip': visitor, 'x-forwarded-for': cdnEgress },
|
|
28
29
|
});
|