@webjsdev/cli 0.10.51 → 0.10.52
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/bin/webjs.js +307 -5
- package/lib/doctor.js +87 -14
- package/package.json +1 -1
- package/templates/.agents/skills/webjs/SKILL.md +3 -2
- package/templates/.agents/skills/webjs/references/built-ins.md +1 -1
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +10 -0
- package/templates/.agents/skills/webjs/references/components.md +51 -2
- package/templates/.agents/skills/webjs/references/data-and-actions.md +53 -1
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +27 -7
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +1 -1
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +1 -1
- package/templates/.agents/skills/webjs/references/testing.md +19 -0
- package/templates/gallery/modules/async-render/components/server-clock.ts +3 -2
- package/templates/gallery/modules/todo/actions/submit-todo.server.ts +5 -3
package/bin/webjs.js
CHANGED
|
@@ -49,6 +49,40 @@ if (cmd !== 'help' && cmd !== undefined && !wantsHelp && !wantsVersion) {
|
|
|
49
49
|
// .agents/rules/workflow.md / .github/copilot-instructions.md mirror it.
|
|
50
50
|
const TEMPLATES = ['full-stack', 'api'];
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The one thing `webjs elision --verify` must say about its own boundary, in
|
|
54
|
+
* the command's own output rather than only in the docs. The differential masks
|
|
55
|
+
* the JS-loaded set by construction, so it can only ever see the DANGEROUS
|
|
56
|
+
* direction (elision changed the served bytes); a wrongly dropped module shows
|
|
57
|
+
* up as a dead click, which is bytes-identical.
|
|
58
|
+
*/
|
|
59
|
+
const VERIFY_CAVEAT = `
|
|
60
|
+
This proves elision did not change the bytes your app serves. It does NOT prove
|
|
61
|
+
post-hydration behaviour: a wrongly dropped module shows up as a dead click, not
|
|
62
|
+
as different bytes. Run your browser or e2e suite twice to cover that half:
|
|
63
|
+
WEBJS_ELIDE=1 <your e2e command>
|
|
64
|
+
WEBJS_ELIDE=0 <your e2e command>`;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `--routes /a,/b` or `--routes=/a,/b` -> ['/a', '/b']. Every value is
|
|
68
|
+
* normalized to a leading slash; empties drop.
|
|
69
|
+
* @param {string[]} argv
|
|
70
|
+
* @returns {string[]}
|
|
71
|
+
*/
|
|
72
|
+
function parseRoutesFlag(argv) {
|
|
73
|
+
/** @type {string[]} */
|
|
74
|
+
const raw = [];
|
|
75
|
+
for (let i = 0; i < argv.length; i++) {
|
|
76
|
+
if (argv[i] === '--routes') { if (argv[i + 1]) raw.push(argv[i + 1]); i++; }
|
|
77
|
+
else if (argv[i].startsWith('--routes=')) raw.push(argv[i].slice('--routes='.length));
|
|
78
|
+
}
|
|
79
|
+
return raw
|
|
80
|
+
.flatMap((v) => v.split(','))
|
|
81
|
+
.map((v) => v.trim())
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.map((v) => (v.startsWith('/') ? v : '/' + v));
|
|
84
|
+
}
|
|
85
|
+
|
|
52
86
|
const USAGE = `webjs commands:
|
|
53
87
|
webjs dev [--port 8080] [--no-hot] Start dev server with live reload
|
|
54
88
|
(--no-hot: run in-process, no hot-reload supervisor)
|
|
@@ -56,8 +90,10 @@ const USAGE = `webjs commands:
|
|
|
56
90
|
webjs test [--server|--browser] Run server + browser tests
|
|
57
91
|
webjs check [--json] Run correctness checks (--json emits structured violations)
|
|
58
92
|
webjs routes [--json|--table] [--no-headers] Print the route table (path / owner file / methods). Default tree; --json matches the MCP list_routes shape; --no-headers drops the --table header
|
|
59
|
-
webjs
|
|
60
|
-
|
|
93
|
+
webjs elision [--json] [--verify] Report which component modules are elided and why each shipped one ships;
|
|
94
|
+
--verify diffs SSR output with elision on vs off (exits non-zero on a divergence)
|
|
95
|
+
webjs mcp Start the read-only MCP server (routes / actions / components / elision / check)
|
|
96
|
+
webjs doctor [--json] [--strict] Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision, component elision, un-versioned stylesheet links).
|
|
61
97
|
--json emits the structured results (with stable codes). --strict additionally fails on every remaining warning.
|
|
62
98
|
Per-check severity is CONFIG: map a code to off/warn/error under "webjs": { "doctor": { "gate": {...} } }
|
|
63
99
|
in package.json, so CI gates on a chosen subset without every warning becoming fatal
|
|
@@ -144,6 +180,24 @@ const HELP = {
|
|
|
144
180
|
],
|
|
145
181
|
examples: ['webjs routes', 'webjs routes --table', 'webjs routes --table --no-headers', 'webjs routes --json'],
|
|
146
182
|
},
|
|
183
|
+
elision: {
|
|
184
|
+
usage: 'webjs elision [--json] [--verify] [--routes <paths>]',
|
|
185
|
+
summary:
|
|
186
|
+
'Report the elision verdict: which component modules the browser never downloads, and why each one that ships does.',
|
|
187
|
+
options: [
|
|
188
|
+
{ flag: '--json', description: 'Emit the verdict as JSON (byte-identical to the MCP list_elision tool).' },
|
|
189
|
+
{ flag: '--verify', description: 'Render every static page route with elision on and off and diff the observable SSR bytes. Exits non-zero on a divergence.' },
|
|
190
|
+
{ flag: '--routes <paths>', description: 'Comma-separated URL paths to add to the --verify corpus (the only way to cover a dynamic route).' },
|
|
191
|
+
],
|
|
192
|
+
notesTitle: 'What --verify proves',
|
|
193
|
+
notes: [
|
|
194
|
+
'--verify proves elision did not change the bytes your app serves. It does NOT',
|
|
195
|
+
'prove post-hydration behaviour: a wrongly dropped module shows up as a dead',
|
|
196
|
+
'click, not as different bytes. Run your browser or e2e suite twice',
|
|
197
|
+
'(WEBJS_ELIDE=1 then WEBJS_ELIDE=0) to cover that half.',
|
|
198
|
+
],
|
|
199
|
+
examples: ['webjs elision', 'webjs elision --json', 'webjs elision --verify', 'webjs elision --verify --routes /,/blog/hello'],
|
|
200
|
+
},
|
|
147
201
|
doctor: {
|
|
148
202
|
usage: 'webjs doctor [--json] [--strict]',
|
|
149
203
|
summary: 'Verify project health. Each result carries a stable code so an agent branches on the failure kind.',
|
|
@@ -215,7 +269,7 @@ const HELP = {
|
|
|
215
269
|
},
|
|
216
270
|
mcp: {
|
|
217
271
|
usage: 'webjs mcp',
|
|
218
|
-
summary: 'Start the read-only MCP server (routes / actions / components / check + a docs/source knowledge layer).',
|
|
272
|
+
summary: 'Start the read-only MCP server (routes / actions / components / elision / check + a docs/source knowledge layer).',
|
|
219
273
|
examples: ['webjs mcp'],
|
|
220
274
|
},
|
|
221
275
|
version: {
|
|
@@ -255,9 +309,12 @@ function printCommandHelp(name) {
|
|
|
255
309
|
console.log('Options:');
|
|
256
310
|
for (const o of options) console.log(` ${o.flag.padEnd(width)} ${o.description}`);
|
|
257
311
|
// Optional per-command prose for surface a flag table cannot carry (doctor's
|
|
258
|
-
// package.json severity gate is the one that needs it).
|
|
312
|
+
// package.json severity gate is the one that needs it). The heading defaults
|
|
313
|
+
// to `Config:` because that is what doctor's block is and what its help test
|
|
314
|
+
// pins; a command whose prose is not configuration names its own heading
|
|
315
|
+
// (`webjs elision`'s is a caveat about what --verify proves).
|
|
259
316
|
if (h.notes) {
|
|
260
|
-
console.log('
|
|
317
|
+
console.log(`\n${h.notesTitle || 'Config'}:`);
|
|
261
318
|
for (const line of h.notes) console.log(` ${line}`);
|
|
262
319
|
}
|
|
263
320
|
console.log('\nExamples:');
|
|
@@ -887,6 +944,251 @@ async function main() {
|
|
|
887
944
|
}
|
|
888
945
|
break;
|
|
889
946
|
}
|
|
947
|
+
case 'elision': {
|
|
948
|
+
// Report the elision verdict for the app in cwd (#1308): which component
|
|
949
|
+
// modules the browser never downloads, why each one that ships does,
|
|
950
|
+
// which page/layout is inert / import-only / ships whole, and any orphan
|
|
951
|
+
// class that gets no verdict at all. Reuses the ONE analysis pass
|
|
952
|
+
// (`analyzeAppElision`, the same reporting layer `webjs doctor` and
|
|
953
|
+
// the MCP `list_elision` tool call), so `--json` is byte-identical to
|
|
954
|
+
// that tool. Read-only: no autofix, and nothing is written to disk.
|
|
955
|
+
const appDir = process.cwd();
|
|
956
|
+
|
|
957
|
+
// --verify: the app-level differential. Renders every static page route
|
|
958
|
+
// with elision ON and OFF in this one process and diffs the observable
|
|
959
|
+
// SSR bytes, which is literally the framework's own guard
|
|
960
|
+
// (`packages/server/test/elision/differential-elision.test.js`) pointed
|
|
961
|
+
// at an arbitrary app's route table.
|
|
962
|
+
if (rest.includes('--verify')) {
|
|
963
|
+
const { createRequestHandler, buildRouteTable, maskJsSet, staticPageRoutes } =
|
|
964
|
+
await import('@webjsdev/server');
|
|
965
|
+
|
|
966
|
+
const extra = parseRoutesFlag(rest);
|
|
967
|
+
let table;
|
|
968
|
+
try {
|
|
969
|
+
table = await buildRouteTable(appDir);
|
|
970
|
+
} catch (err) {
|
|
971
|
+
console.error(`webjs elision --verify: cannot read the route table here (${err && err.message}).`);
|
|
972
|
+
process.exit(1);
|
|
973
|
+
}
|
|
974
|
+
const staticRoutes = staticPageRoutes(table);
|
|
975
|
+
const dynamic = (table.pages || [])
|
|
976
|
+
.filter((r) => r.paramNames && r.paramNames.length)
|
|
977
|
+
.map((r) => (r.routeDir && r.routeDir !== '.' ? '/' + r.routeDir : '/'))
|
|
978
|
+
.sort();
|
|
979
|
+
// An extra route the author named explicitly is REQUIRED to render; a
|
|
980
|
+
// static one that does not is merely reported (a page may legitimately
|
|
981
|
+
// throw notFound() for every visitor).
|
|
982
|
+
const required = new Set(extra);
|
|
983
|
+
const routes = [...new Set([...staticRoutes, ...extra])];
|
|
984
|
+
|
|
985
|
+
// The app's access log would drown the verdict, and a verification
|
|
986
|
+
// run is not a server: keep errors (a real boot failure must surface)
|
|
987
|
+
// and drop the per-request info lines.
|
|
988
|
+
const quiet = { info: () => {}, warn: () => {}, debug: () => {}, error: (...a) => console.error(...a) };
|
|
989
|
+
|
|
990
|
+
const capture = async (h, r) => {
|
|
991
|
+
const resp = await h.handle(new Request('http://localhost' + r));
|
|
992
|
+
return { status: resp.status, html: await resp.text() };
|
|
993
|
+
};
|
|
994
|
+
// The module URLs a response preloads, with the content-hash query
|
|
995
|
+
// stripped. Comparing the two sides' sets is how the run reports what
|
|
996
|
+
// elision actually DROPPED, which is the only thing that distinguishes
|
|
997
|
+
// a real pass from two identical renders.
|
|
998
|
+
const preloadSet = (html) => new Set(
|
|
999
|
+
[...html.matchAll(/<link rel="modulepreload" href="([^"]+)"/g)].map((m) => m[1].split('?')[0]),
|
|
1000
|
+
);
|
|
1001
|
+
|
|
1002
|
+
const ORIG = process.env.WEBJS_ELIDE;
|
|
1003
|
+
/** @type {Record<string, {status:number, html:string}>} */
|
|
1004
|
+
const onA = {}, onB = {}, off = {};
|
|
1005
|
+
try {
|
|
1006
|
+
// Elision ON, FORCED. Deleting the override would only fall back to
|
|
1007
|
+
// `webjs.elide`, so on an app that opts out this side would run with
|
|
1008
|
+
// elision OFF too and the command would compare two identical renders
|
|
1009
|
+
// and report them "identical with elision on vs off", which is false
|
|
1010
|
+
// about a run where elision was never on. The env override wins over
|
|
1011
|
+
// the config key, which is exactly what makes it the right seam here.
|
|
1012
|
+
// Warm fully so the memoized verdict is locked before the env flips
|
|
1013
|
+
// for the second handler.
|
|
1014
|
+
process.env.WEBJS_ELIDE = '1';
|
|
1015
|
+
const hOn = await createRequestHandler({ appDir, dev: false, logger: quiet });
|
|
1016
|
+
if (hOn.warmup) await hOn.warmup();
|
|
1017
|
+
for (const r of routes) onA[r] = await capture(hOn, r);
|
|
1018
|
+
// A SECOND capture through the same warm handler. A route whose two
|
|
1019
|
+
// ON captures already differ is nondeterministic (live data, a random
|
|
1020
|
+
// id, a clock the masker does not normalize), so a differential over
|
|
1021
|
+
// it proves nothing and reporting it as a failure would be a red the
|
|
1022
|
+
// author cannot act on. The framework's own test needs no such pass
|
|
1023
|
+
// because its corpus is fixed and known; an arbitrary app's is not.
|
|
1024
|
+
for (const r of routes) onB[r] = await capture(hOn, r);
|
|
1025
|
+
// Elision OFF via the env override; a fresh handler reads it on its
|
|
1026
|
+
// own first warm.
|
|
1027
|
+
process.env.WEBJS_ELIDE = '0';
|
|
1028
|
+
const hOff = await createRequestHandler({ appDir, dev: false, logger: quiet });
|
|
1029
|
+
if (hOff.warmup) await hOff.warmup();
|
|
1030
|
+
for (const r of routes) off[r] = await capture(hOff, r);
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
console.error(`webjs elision --verify: could not boot the app (${err && err.message}).`);
|
|
1033
|
+
process.exit(1);
|
|
1034
|
+
} finally {
|
|
1035
|
+
if (ORIG === undefined) delete process.env.WEBJS_ELIDE;
|
|
1036
|
+
else process.env.WEBJS_ELIDE = ORIG;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const unrenderable = [], nondeterministic = [], diverged = [];
|
|
1040
|
+
/** @type {Set<string>} modules the OFF side preloads and the ON side does not */
|
|
1041
|
+
const dropped = new Set();
|
|
1042
|
+
let compared = 0;
|
|
1043
|
+
for (const r of routes) {
|
|
1044
|
+
if (onA[r].status >= 400) { unrenderable.push(`${r} (${onA[r].status})`); continue; }
|
|
1045
|
+
if (maskJsSet(onA[r].html) !== maskJsSet(onB[r].html)) { nondeterministic.push(r); continue; }
|
|
1046
|
+
compared++;
|
|
1047
|
+
// What elision removed on this route. A pass over a corpus where this
|
|
1048
|
+
// stays empty is TRUE but trivially so, and the author needs to see
|
|
1049
|
+
// that rather than read it as proof elision was exercised.
|
|
1050
|
+
const onSet = preloadSet(onA[r].html);
|
|
1051
|
+
for (const u of preloadSet(off[r].html)) if (!onSet.has(u)) dropped.add(u);
|
|
1052
|
+
const a = maskJsSet(onA[r].html);
|
|
1053
|
+
const b = maskJsSet(off[r].html);
|
|
1054
|
+
if (onA[r].status !== off[r].status) {
|
|
1055
|
+
diverged.push({ route: r, kind: 'status', a: String(onA[r].status), b: String(off[r].status), at: 0 });
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
if (a !== b) {
|
|
1059
|
+
let i = 0;
|
|
1060
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
|
1061
|
+
diverged.push({
|
|
1062
|
+
route: r, kind: 'body', at: i,
|
|
1063
|
+
a: a.slice(Math.max(0, i - 40), i + 40),
|
|
1064
|
+
b: b.slice(Math.max(0, i - 40), i + 40),
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
for (const d of diverged) {
|
|
1070
|
+
console.error(
|
|
1071
|
+
d.kind === 'status'
|
|
1072
|
+
? `FAIL ${d.route}: status differs with elision on vs off (on=${d.a}, off=${d.b})`
|
|
1073
|
+
: `FAIL ${d.route}: elision changed observable output near offset ${d.at}\n` +
|
|
1074
|
+
` ON : ...${JSON.stringify(d.a)}\n OFF: ...${JSON.stringify(d.b)}`,
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
const badRequired = unrenderable.filter((u) => required.has(u.split(' ')[0]));
|
|
1078
|
+
for (const u of badRequired) console.error(`FAIL ${u}: a --routes path must render`);
|
|
1079
|
+
|
|
1080
|
+
const skips = [
|
|
1081
|
+
dynamic.length ? `${dynamic.length} skipped (dynamic: ${dynamic.join(', ')})` : '0 skipped (dynamic)',
|
|
1082
|
+
`${nondeterministic.length} skipped (nondeterministic${nondeterministic.length ? ': ' + nondeterministic.join(', ') : ''})`,
|
|
1083
|
+
];
|
|
1084
|
+
if (unrenderable.length) skips.push(`${unrenderable.length} skipped (did not render: ${unrenderable.join(', ')})`);
|
|
1085
|
+
|
|
1086
|
+
if (diverged.length || badRequired.length) {
|
|
1087
|
+
console.error(`\nwebjs elision --verify: ${diverged.length} route(s) diverged out of ${compared} compared.`);
|
|
1088
|
+
process.exit(1);
|
|
1089
|
+
}
|
|
1090
|
+
if (compared === 0) {
|
|
1091
|
+
// A vacuous pass is a failure, the same posture scripts/run-bun-tests.js
|
|
1092
|
+
// takes for a run that executed zero tests.
|
|
1093
|
+
console.error(
|
|
1094
|
+
`webjs elision --verify: nothing was compared (${skips.join(', ')}).\n` +
|
|
1095
|
+
'Pass --routes /a,/b to name renderable paths, or add a static page route.',
|
|
1096
|
+
);
|
|
1097
|
+
process.exit(1);
|
|
1098
|
+
}
|
|
1099
|
+
console.log(
|
|
1100
|
+
`webjs elision --verify: ${compared} route(s) identical with elision on vs off, ${skips.join(', ')}.\n` +
|
|
1101
|
+
(dropped.size
|
|
1102
|
+
? `Elision dropped ${dropped.size} module(s) across that corpus, so the comparison was a real one.`
|
|
1103
|
+
: 'Elision dropped NO modules across that corpus: nothing on these routes was elidable, so '
|
|
1104
|
+
+ 'there was nothing for elision to change. The comparison holds, it just had no work to '
|
|
1105
|
+
+ 'do. Run `WEBJS_ELIDE=1 webjs elision` to see why every module here ships (the same '
|
|
1106
|
+
+ 'override this run used, so the verdict matches even in an app that opts out).'),
|
|
1107
|
+
);
|
|
1108
|
+
console.log(VERIFY_CAVEAT);
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const { analyzeAppElision } = await import('@webjsdev/server');
|
|
1113
|
+
const report = await analyzeAppElision(appDir);
|
|
1114
|
+
|
|
1115
|
+
// --json: the machine contract, identical to the MCP `list_elision` shape.
|
|
1116
|
+
if (rest.includes('--json')) {
|
|
1117
|
+
console.log(JSON.stringify(report));
|
|
1118
|
+
break;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
if (!report.analysed) {
|
|
1122
|
+
const why = {
|
|
1123
|
+
'no-app': 'no app/ directory here, so there is nothing to analyse',
|
|
1124
|
+
'elide-off': 'elision is disabled (webjs.elide false, or WEBJS_ELIDE), so every module ships',
|
|
1125
|
+
unanalysable: 'the app could not be analysed (`webjs check` and `webjs dev` name the real problem)',
|
|
1126
|
+
}[report.skipped] || 'nothing was analysed';
|
|
1127
|
+
console.log(`webjs elision: ${why}.`);
|
|
1128
|
+
break;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const s = report.summary;
|
|
1132
|
+
console.log(
|
|
1133
|
+
`webjs elision: ${s.components} component module(s), ${s.elided} elided, ${s.shipped} shipped. ` +
|
|
1134
|
+
`${s.routeModules} route module(s): ${s.inert} inert, ${s.importOnly} import-only, ${s.shippedWhole} ship whole.\n`,
|
|
1135
|
+
);
|
|
1136
|
+
|
|
1137
|
+
const elided = report.components.filter((c) => c.verdict === 'elided');
|
|
1138
|
+
const shipped = report.components.filter((c) => c.verdict === 'shipped');
|
|
1139
|
+
// Column width, capped so one outlier does not push every other row off
|
|
1140
|
+
// the terminal. An over-long cell overflows its own row rather than
|
|
1141
|
+
// widening the table. The FILE column gets the generous cap because a
|
|
1142
|
+
// module path is what a reader scans by; the tag column gets the tight
|
|
1143
|
+
// one because a file registering five tags is the rare case.
|
|
1144
|
+
const pad = (rows, col, cap) => Math.min(cap, Math.max(0, ...rows.map((r) => r[col].length)));
|
|
1145
|
+
const FILE_W = 58, TAG_W = 30;
|
|
1146
|
+
|
|
1147
|
+
if (elided.length) {
|
|
1148
|
+
console.log('Elided components (the browser never downloads these)');
|
|
1149
|
+
const rows = elided.map((c) => [c.file, c.tags.join(', ')]);
|
|
1150
|
+
const w = pad(rows, 0, FILE_W);
|
|
1151
|
+
for (const [file, tags] of rows) console.log(` ${file.padEnd(w)} ${tags}`.trimEnd());
|
|
1152
|
+
console.log();
|
|
1153
|
+
}
|
|
1154
|
+
if (shipped.length) {
|
|
1155
|
+
console.log('Shipped components (and the evidence that forced each one)');
|
|
1156
|
+
const rows = shipped.map((c) => [c.file, c.tags.join(', '), `${c.evidence || 'unknown'}: ${c.reason || 'no evidence recorded'}`]);
|
|
1157
|
+
const w0 = pad(rows, 0, FILE_W), w1 = pad(rows, 1, TAG_W);
|
|
1158
|
+
for (const [file, tags, why] of rows) console.log(` ${file.padEnd(w0)} ${tags.padEnd(w1)} ${why}`.trimEnd());
|
|
1159
|
+
console.log();
|
|
1160
|
+
}
|
|
1161
|
+
if (report.routeModules.length) {
|
|
1162
|
+
console.log('Route modules');
|
|
1163
|
+
const rows = report.routeModules.map((r) => [
|
|
1164
|
+
r.verdict, r.file,
|
|
1165
|
+
r.verdict === 'import-only' ? `emits ${r.emits.join(', ') || '(nothing)'}`
|
|
1166
|
+
: r.verdict === 'shipped' ? (r.blocker ? `blocked by ${r.blocker}, which ${r.reason}` : `it ${r.reason}`)
|
|
1167
|
+
: '',
|
|
1168
|
+
]);
|
|
1169
|
+
const w0 = pad(rows, 0, 12), w1 = pad(rows, 1, FILE_W);
|
|
1170
|
+
for (const [verdict, file, note] of rows) {
|
|
1171
|
+
console.log(` ${verdict.padEnd(w0)} ${file.padEnd(w1)}${note ? ' ' + note : ''}`.trimEnd());
|
|
1172
|
+
}
|
|
1173
|
+
console.log();
|
|
1174
|
+
}
|
|
1175
|
+
if (report.orphans.length) {
|
|
1176
|
+
console.log('Orphan components (no elision verdict; `static interactive = true` cannot rescue these)');
|
|
1177
|
+
for (const o of report.orphans) {
|
|
1178
|
+
console.log(` ${o.className} in ${o.file} is never registered with a literal tag`);
|
|
1179
|
+
}
|
|
1180
|
+
console.log(' Either there is no registration call at all, or the tag is computed. The scanner matches');
|
|
1181
|
+
console.log(' only a literal tag, so either way the module gets no verdict, no registry entry, and no');
|
|
1182
|
+
console.log(' preload hint. With no registration call the element never upgrades at all; with a computed');
|
|
1183
|
+
console.log(' tag it upgrades only while its module still reaches the browser through a shipping importer.');
|
|
1184
|
+
console.log(' Fix: register it with a literal tag, Class.register(\'my-tag\'), or delete the class.');
|
|
1185
|
+
console.log();
|
|
1186
|
+
}
|
|
1187
|
+
if (!report.components.length && !report.routeModules.length) {
|
|
1188
|
+
console.log(' Nothing to report. Add a component or a page under app/.');
|
|
1189
|
+
}
|
|
1190
|
+
break;
|
|
1191
|
+
}
|
|
890
1192
|
case 'types': {
|
|
891
1193
|
// Generate `.webjs/routes.d.ts` from the app's `app/` routes (#258),
|
|
892
1194
|
// narrowing the @webjsdev/core `Route` href union + per-route `params`.
|
package/lib/doctor.js
CHANGED
|
@@ -103,6 +103,7 @@ export const DOCTOR_CODES = {
|
|
|
103
103
|
'importmap-coherence': 'IMPORTMAP_COHERENCE',
|
|
104
104
|
'git-hook': 'GIT_HOOK',
|
|
105
105
|
'Page/layout elision (carrier hygiene)': 'ELISION_CARRIERS',
|
|
106
|
+
'Component elision (what the browser drops)': 'ELISION_COMPONENTS',
|
|
106
107
|
'Static build outputs (dev.regenerate freshness)': 'STATIC_ASSET_FRESHNESS',
|
|
107
108
|
'Asset urls (unmarked stylesheet links)': 'UNMARKED_ASSET_LINKS',
|
|
108
109
|
};
|
|
@@ -1002,43 +1003,104 @@ function checkGitHook(appDir) {
|
|
|
1002
1003
|
* named line. WARN only: a page legitimately MAY ship, and the analyser is
|
|
1003
1004
|
* biased toward shipping by design (server AGENTS invariant 7), so this is a
|
|
1004
1005
|
* "you may not have intended this" hint, never a hard fail.
|
|
1005
|
-
* @param {
|
|
1006
|
+
* @param {Promise<any|null>} elisionPromise the ONE shared report (#1308)
|
|
1006
1007
|
* @returns {Promise<DoctorResult>}
|
|
1007
1008
|
*/
|
|
1008
|
-
async function checkElisionCarriers(
|
|
1009
|
+
async function checkElisionCarriers(elisionPromise) {
|
|
1009
1010
|
const name = 'Page/layout elision (carrier hygiene)';
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
const { analyzeAppElision } = await import('@webjsdev/server');
|
|
1013
|
-
report = await analyzeAppElision(appDir);
|
|
1014
|
-
} catch {
|
|
1011
|
+
const report = await elisionPromise;
|
|
1012
|
+
if (!report) {
|
|
1015
1013
|
// Analysis unavailable (no app, malformed, server import failed): no advice.
|
|
1016
1014
|
return { name, status: 'pass', message: 'not analysed (no routable app or analysis unavailable)' };
|
|
1017
1015
|
}
|
|
1018
1016
|
if (!report.analysed) {
|
|
1019
1017
|
return { name, status: 'pass', message: 'not analysed (no routable app, or elision is disabled)' };
|
|
1020
1018
|
}
|
|
1021
|
-
|
|
1019
|
+
// Paths and reasons arrive app-relative from `analyzeAppElision` (#1308).
|
|
1020
|
+
const shipped = report.routeModules.filter((r) => r.verdict === 'shipped');
|
|
1021
|
+
if (shipped.length === 0) {
|
|
1022
1022
|
return { name, status: 'pass', message: 'every page/layout is elided (a pure import-only or inert carrier)' };
|
|
1023
1023
|
}
|
|
1024
|
-
const rel = (f) => relative(appDir, f) || f;
|
|
1025
1024
|
// Name the FIRST client-effecting blocker (there may be more than one; the
|
|
1026
1025
|
// module stays shipped until every such blocker is moved out).
|
|
1027
|
-
const lines =
|
|
1026
|
+
const lines = shipped.map(({ file, blocker, reason }) =>
|
|
1028
1027
|
blocker
|
|
1029
|
-
? `${
|
|
1030
|
-
: `${
|
|
1028
|
+
? `${file} ships whole. Its first client-effecting blocker is ${blocker}, which ${reason} and is not a component`
|
|
1029
|
+
: `${file} ships whole because it ${reason}`,
|
|
1031
1030
|
);
|
|
1032
1031
|
return {
|
|
1033
1032
|
name,
|
|
1034
1033
|
status: 'warn',
|
|
1035
1034
|
message:
|
|
1036
|
-
`${
|
|
1035
|
+
`${shipped.length} page/layout module(s) ship to the browser instead of being elided:\n` +
|
|
1037
1036
|
lines.map((l) => ` ${l}`).join('\n'),
|
|
1038
1037
|
fix: 'Move the client work out of the page/layout closure (into a component, or a .server module reached through an action) so the carrier can be elided, or accept that it ships. See references/components.md in the skill.',
|
|
1039
1038
|
};
|
|
1040
1039
|
}
|
|
1041
1040
|
|
|
1041
|
+
/**
|
|
1042
|
+
* The OTHER direction of the elision verdict (#1308): which COMPONENT modules
|
|
1043
|
+
* the browser never downloads. `checkElisionCarriers` above reports the benign
|
|
1044
|
+
* over-ship direction; this one reports what was DROPPED, which is where a
|
|
1045
|
+
* wrong verdict silently costs an app its interactivity.
|
|
1046
|
+
*
|
|
1047
|
+
* Pass-only except for orphans, deliberately. An elided component is the
|
|
1048
|
+
* DESIRED outcome, so warning on one would fire on every healthy app and train
|
|
1049
|
+
* the reader to skip doctor output. The passing message carries the elided
|
|
1050
|
+
* inventory instead, which makes it the discovery surface, while `webjs
|
|
1051
|
+
* elision` is the detail surface. The one always-wrong condition is an ORPHAN:
|
|
1052
|
+
* a `class X extends WebComponent` with no literal-tag registration is
|
|
1053
|
+
* invisible to the scanner, so it gets no verdict at all and `static
|
|
1054
|
+
* interactive = true` cannot rescue it (nothing consults the component
|
|
1055
|
+
* analyser for a component the scanner never saw). Never `fail`:
|
|
1056
|
+
* an app that wants an orphan to break CI gates `ELISION_COMPONENTS` to
|
|
1057
|
+
* `error` via `webjs.doctor.gate`.
|
|
1058
|
+
*
|
|
1059
|
+
* @param {Promise<any|null>} elisionPromise the ONE shared report
|
|
1060
|
+
* @returns {Promise<DoctorResult>}
|
|
1061
|
+
*/
|
|
1062
|
+
async function checkElisionComponents(elisionPromise) {
|
|
1063
|
+
const name = 'Component elision (what the browser drops)';
|
|
1064
|
+
const report = await elisionPromise;
|
|
1065
|
+
const notAnalysed = { name, status: /** @type {const} */ ('pass'), message: 'not analysed (no routable app or analysis unavailable)' };
|
|
1066
|
+
if (!report) return notAnalysed;
|
|
1067
|
+
if (!report.analysed) {
|
|
1068
|
+
return report.skipped === 'elide-off'
|
|
1069
|
+
? { name, status: 'pass', message: 'elision is disabled (webjs.elide false or WEBJS_ELIDE), so every component module ships' }
|
|
1070
|
+
: notAnalysed;
|
|
1071
|
+
}
|
|
1072
|
+
if (report.orphans.length > 0) {
|
|
1073
|
+
const lines = report.orphans.map(({ file, className }) =>
|
|
1074
|
+
`${className} in ${file} is never registered with a literal tag`,
|
|
1075
|
+
);
|
|
1076
|
+
return {
|
|
1077
|
+
name,
|
|
1078
|
+
status: 'warn',
|
|
1079
|
+
message:
|
|
1080
|
+
`${report.orphans.length} component class(es) get NO elision verdict:\n` +
|
|
1081
|
+
lines.map((l) => ` ${l}`).join('\n') +
|
|
1082
|
+
'\n Either it has no registration call at all, or it registers a computed tag. The component '
|
|
1083
|
+
+ 'scanner matches only a literal tag, so either way it never sees the class: no elision verdict, no '
|
|
1084
|
+
+ 'registry entry, no preload hint, and `static interactive = true` cannot rescue it. With no '
|
|
1085
|
+
+ 'registration call the element never upgrades at all; with a computed tag it upgrades only while '
|
|
1086
|
+
+ 'its module still reaches the browser through an importer that ships.',
|
|
1087
|
+
fix: 'Register it with a literal tag, Class.register(\'my-tag\') (invariant 3 already requires one), or delete the class if nothing uses it.',
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
const elided = report.components.filter((c) => c.verdict === 'elided');
|
|
1091
|
+
const tags = elided.flatMap((c) => c.tags);
|
|
1092
|
+
const shown = tags.slice(0, 8).join(', ');
|
|
1093
|
+
const tail = tags.length > 8 ? `, +${tags.length - 8} more` : '';
|
|
1094
|
+
return {
|
|
1095
|
+
name,
|
|
1096
|
+
status: 'pass',
|
|
1097
|
+
message:
|
|
1098
|
+
`${report.summary.elided} of ${report.summary.components} component module(s) are elided (never downloaded)` +
|
|
1099
|
+
(tags.length ? `: ${shown}${tail}` : '') +
|
|
1100
|
+
'. Run `webjs elision` for the full verdict.',
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1042
1104
|
// Directories never worth walking for the CSS-freshness advisory (mirrors
|
|
1043
1105
|
// dev-regenerate's IGNORE_DIRS): build output, deps, VCS + framework caches.
|
|
1044
1106
|
const FRESHNESS_IGNORE = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']);
|
|
@@ -1517,6 +1579,16 @@ export function checkFrameworkResolves(appDir) {
|
|
|
1517
1579
|
|
|
1518
1580
|
export async function runDoctorChecks(appDir, opts = {}) {
|
|
1519
1581
|
const cliDir = opts.cliDir || new URL('.', import.meta.url).pathname;
|
|
1582
|
+
// ONE elision report for BOTH elision checks (#1308). Started before the
|
|
1583
|
+
// batch and awaited inside each check, so the module graph is built once per
|
|
1584
|
+
// doctor run and the two checks still run in parallel with everything else.
|
|
1585
|
+
// Fails soft to null, exactly as the carrier check's own try/catch did.
|
|
1586
|
+
const elision = (async () => {
|
|
1587
|
+
try {
|
|
1588
|
+
const { analyzeAppElision } = await import('@webjsdev/server');
|
|
1589
|
+
return await analyzeAppElision(appDir);
|
|
1590
|
+
} catch { return null; }
|
|
1591
|
+
})();
|
|
1520
1592
|
const results = await Promise.all([
|
|
1521
1593
|
checkNode(cliDir, opts),
|
|
1522
1594
|
checkTsconfig(appDir),
|
|
@@ -1527,7 +1599,8 @@ export async function runDoctorChecks(appDir, opts = {}) {
|
|
|
1527
1599
|
Promise.resolve(checkFrameworkResolves(appDir)),
|
|
1528
1600
|
checkImportmapCoherence(appDir, opts),
|
|
1529
1601
|
Promise.resolve(checkGitHook(appDir)),
|
|
1530
|
-
checkElisionCarriers(
|
|
1602
|
+
checkElisionCarriers(elision),
|
|
1603
|
+
checkElisionComponents(elision),
|
|
1531
1604
|
checkStaticAssetFreshness(appDir),
|
|
1532
1605
|
checkUnmarkedAssetLinks(appDir),
|
|
1533
1606
|
]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.52",
|
|
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": {
|
|
@@ -39,6 +39,7 @@ Classify the task first, then load the smallest useful reference set. Each refer
|
|
|
39
39
|
| --------------------------------------------------------------------------- | --------------------------------------------- |
|
|
40
40
|
| Pages, layouts, dynamic routes, route handlers, metadata, redirects, 404s | `references/routing-and-pages.md` |
|
|
41
41
|
| Writing components: reactive props, signals, lifecycle, light vs shadow DOM | `references/components.md` |
|
|
42
|
+
| Why a component's JS was or was not downloaded, `webjs elision`, `static interactive = true` | `references/components.md` |
|
|
42
43
|
| Server actions, mutations, queries, validation, the `ActionResult` envelope | `references/data-and-actions.md` |
|
|
43
44
|
| Sessions, login flows, route protection, `forbidden()` / `unauthorized()` | `references/auth-and-sessions.md` |
|
|
44
45
|
| Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout | `references/styling.md` |
|
|
@@ -105,7 +106,7 @@ App-internal imports use the `#` root alias (`import { db } from '#db/connection
|
|
|
105
106
|
9. No backtick characters inside an `html\`...\`` body, even in comments (it closes the literal and 500s).
|
|
106
107
|
10. TypeScript must be erasable (`erasableSyntaxOnly: true`): no `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators.
|
|
107
108
|
11. Reactive properties are declared ONLY through the base-class factory `extends WebComponent({ count: Number })`. Never a `static properties` block, never a class-field initializer (it clobbers the reactive accessor).
|
|
108
|
-
12. A form that writes binds its action: `<form action=${importedAction}>`, or a per-button `<button formaction=${importedAction}
|
|
109
|
+
12. A form that writes binds its action: `<form action=${importedAction}>`, or a per-button `<button formaction=${importedAction}>`. A bound submitter is SELF-SUFFICIENT (#1307): the renderer puts `formmethod="post"` and `formenctype` on the button itself, so it needs no bound form around it and works inside any form or none. Quoted bindings, non-submit controls, `<input type="submit">` (the identity needs its `value`, which is also its label, so use a `<button>`), submitter `name` / `value` / `form` / static `formaction` attributes, a `.prop` spelling of any of those, `action=${fn}` off a `<form>`, a bound form with `method="get"`, a BOUND submitter's own non-post `formmethod` or unparseable `formenctype`, and a non-action function all throw. A PLAIN button's own `formmethod` / `formenctype` is a legal native override and is left alone. A page has no `action` export, so a bare `<form method="post">` is a 405.
|
|
109
110
|
|
|
110
111
|
## Export Map
|
|
111
112
|
|
|
@@ -236,7 +237,7 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
|
|
|
236
237
|
- Writing `fetch()` to call your own server instead of importing the action.
|
|
237
238
|
- Writing a bare `<form method="post">` and expecting a page `action` export to catch it. There is no such export; bind the action with `action=${fn}` or the submission is a 405.
|
|
238
239
|
- Putting a submitter's `formaction=${fn}` on anything that is not a submit control, or on a button carrying its own `name` / `value`. The identity IS the button's name/value pair, so both halves are spoken for.
|
|
239
|
-
- Writing `formmethod="get"` or `formenctype="text/plain"` on
|
|
240
|
+
- Writing `formmethod="get"` or `formenctype="text/plain"` on a button that BINDS an action. Neither can carry that action's body, so the pair contradicts itself and throws. On a button that binds nothing it is a legal native override and is honoured.
|
|
240
241
|
- Binding an action whose file declares `export const method = 'GET'`. That is a 405 at runtime and a `webjs check` error.
|
|
241
242
|
- Throwing `redirect()` / `notFound()` inside a `route.ts` handler (uncaught 500). Return a `Response` there.
|
|
242
243
|
- A placeholder first paint that fetches in `connectedCallback`. SSR does not call `connectedCallback`; put first-paint data in the constructor (server-known inputs) or use `async render()`.
|
|
@@ -233,7 +233,7 @@ Wired at the single response funnel, covering pages, routes, actions, and assets
|
|
|
233
233
|
|
|
234
234
|
- **Access log.** One structured `info` line per handled request (`method`, `path`, `status`, `durationMs`, `requestId`). Never logs bodies or secrets; framework `/__webjs/*` traffic is suppressed.
|
|
235
235
|
- **Request id.** Each request gets a `crypto.randomUUID()` correlation id, set as `X-Request-Id` (honoring a trusted inbound one) and readable server-side with `requestId()` from `@webjsdev/server` (returns `null` outside a request scope).
|
|
236
|
-
- **`onError` hook.** Register via `createRequestHandler({ onError })` or `startServer({ onError })`. Called with `(error, { request, requestId, phase })` on any caught pipeline error, before the sanitized response is sent. Best-effort (a throwing hook is ignored), purely additive (the sanitized 500 / action digest is unchanged). Point it at Sentry or an APM.
|
|
236
|
+
- **`onError` hook.** Register via `createRequestHandler({ onError })` or `startServer({ onError })`. Called with `(error, { request, requestId, phase })` on any caught pipeline error, before the sanitized response is sent. Best-effort (a throwing hook is ignored), purely additive (the sanitized 500 / action digest is unchanged). Point it at Sentry or an APM. It also carries two framework DIAGNOSTICS that are not request failures, each with an `err.code` to group or filter on, both under `phase: 'action'`: `WEBJS_FORM_SUBMITTED_AS_GET` (a page GET carrying the reserved `__webjs_action` field in its query string, so a submission holding a bound action's identity went out as a GET and the action never ran, #1307; a bound submitter carries its own `formmethod="post"`, so what reaches this is an explicit `formmethod="get"` / `method="get"` the author wrote and the renderer honours rather than refuses) and `WEBJS_FORM_ACTION_MISSING` (a PARSEABLE form body carrying no identity, the 405; an `enctype="text/plain"` submission is answered before its body is read, so it stays a bare 405). Both are detect-only, so the 200 and the 405 are unchanged; both carry `method`, `pathname`, and for the second the submitted field NAMES, never the values; and both are deduplicated per process on the code, the method, and the matched ROUTE (not the request pathname, so crafted urls on a dynamic route cannot exhaust the 256-entry cap and silence the diagnostics), since either is reachable by an unauthenticated request and an uncapped report would be a free amplifier into a paid sink.
|
|
237
237
|
|
|
238
238
|
```ts
|
|
239
239
|
const app = await createRequestHandler({
|
|
@@ -56,6 +56,16 @@ revalidate(); // clear the entire snapshot cache
|
|
|
56
56
|
|
|
57
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.
|
|
58
58
|
|
|
59
|
+
**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
|
+
|
|
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 router, which already set `history.scrollRestoration = 'manual'` and is the sole authority on scroll during a navigation. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code.
|
|
62
|
+
- **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
|
+
- **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
|
+
- **Suppression is conditional on the offset being reachable, and follows the chase onto it.** A page that has not grown yet can be too short to scroll that far, so the browser clamps to its current maximum. There the shortfall IS the growth still to come, and anchoring adding it is what carries the reader back down, so the router leaves anchoring alone. Suppressing in that case would freeze the clamp and strand the reader a full page-growth above where they left, which is this same defect pointing the other way. That case is not left to anchoring alone, though, because anchoring adds the FULL growth however far short the clamp fell, so by itself it only lands a reader who left at the very bottom. The router also CHASES the recorded offset there, re-asserting it the moment the page is tall enough to hold it, and then stopping. That is the one place the router writes scroll after the initial restore, it is scoped to the clamped path, and it stops on the same inputs that close a suppression window. It is also time-boxed, and more tightly than the window a landed restore gets: a few hundred milliseconds from the RESTORE, not the 2s ceiling, and the suppression it installs on landing shares that same deadline rather than starting a fresh one. That bound is what keeps it from moving a reader who has landed and started reading, since such a reader generates no input to cancel it and the chase cannot tell the restore settling apart from any other growth. Anchoring is left on only WHILE the offset is out of reach, which is the part that heals the clamp. The moment the chase lands on the offset it suppresses anchoring too, because the growth that made the offset reachable is rarely all of it and every later stage would otherwise be added on top of what was just written. Both halves end together on the bound. After it, the router writes no more scroll and anchoring is back on, so a component that reaches its final height later than the bound (a chart, an embed measured from its content) has its growth added and the reader drifts BELOW the offset, the same way they would without this fix at all, rather than sitting at the clamp.
|
|
65
|
+
- **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
|
+
|
|
67
|
+
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.
|
|
68
|
+
|
|
59
69
|
**Error recovery.** A 2xx/3xx swap applies in place, and an HTML error body of any status (a 422 re-rendered form, a 5xx error page) is ALSO applied in place with no reload. For a non-HTML error or a transport failure the router dispatches a cancelable `webjs:navigation-error` on `document` (detail `{ url, status, error }`). Call `preventDefault()` to own recovery, otherwise the router renders a minimal in-place alert into the layout slot.
|
|
60
70
|
|
|
61
71
|
```ts
|
|
@@ -183,7 +183,7 @@ The boundary also covers `watch(signal)` (its notify microtask) and `until()` (i
|
|
|
183
183
|
|
|
184
184
|
**A commit that throws leaves the directive's own state consistent, so the NEXT valid render is correct.** This matters because the corruption is otherwise silent: the renders that expose it are fully valid and log nothing after the first throw. The hole whose commit threw is marked so the next render re-applies it rather than skipping it as unchanged (its recorded value is never advanced past a throw, and would otherwise match exactly what the recovering render supplies, leaving a child region blank for good). Both list reconcilers additionally repair their own bookkeeping so it describes the DOM again, and the next render is an ordinary reconcile rather than a rebuild of the region, which would discard the node identity the reconcilers exist to preserve. `repeat()` re-unites its key map and repositions every row (the failure was a permanently duplicated row). A plain `.map()` array splices the part of its slot list the failed pass never reached back on, which matters whenever a slot is REPLACED rather than updated in place (its template shape changed, its kind changed between text, template and empty, or the array grew past its old length), since that is the branch that inserts the replacement before removing what it replaced (the failure was a stranded row that outlived even a render of an empty array). `guard()` records its new deps only once the commit succeeds, so a later render with those same deps re-renders the region instead of short-circuiting past a region the throw had blanked; `until()` advances its resolved priority only after the commit succeeds, so a failed high-priority resolution does not refuse the lower-priority one behind it.
|
|
185
185
|
|
|
186
|
-
**Teardown is total as well.** Removing a row is not a commit and has no retry, so a throw while tearing one down cannot be allowed to abandon the rest. Unbinding a `ref` during teardown can never abort the removal of the remaining rows, and `repeat()` drops each leftover key from its map before touching that row, so the map never describes a row that has already been removed (which used to leave the row the app DELETED on screen, reorder the survivors, and let a later render that re-added that key reinsert the disposed instance). To make that hold, a `ref` whose object `value` setter throws is now SWALLOWED on teardown, matching the ref CALLBACK, which was already swallowed everywhere. That is a deliberate divergence from lit, which guards neither and propagates from both. It applies to teardown only: on the COMMIT path a throwing object-ref setter still reaches `renderError()`, because there the boundary can report it and the next render can repair it.
|
|
186
|
+
**Teardown is total as well.** Removing a row is not a commit and has no retry, so a throw while tearing one down cannot be allowed to abandon the rest. Unbinding a `ref` during teardown can never abort the removal of the remaining rows, and `repeat()` drops each leftover key from its map before touching that row, so the map never describes a row that has already been removed (which used to leave the row the app DELETED on screen, reorder the survivors, and let a later render that re-added that key reinsert the disposed instance). To make that hold, a `ref` whose object `value` setter throws is now SWALLOWED on teardown, matching the ref CALLBACK, which was already swallowed everywhere. That is a deliberate divergence from lit, which guards neither and propagates from both. It applies to teardown only: on the COMMIT path a throwing object-ref setter still reaches `renderError()`, because there the boundary can report it and the next render can repair it. Total also means a removal takes the row's own boundary markers with it, so a list that grows and shrinks all day is net zero on the nodes the renderer added, rather than accruing one invisible comment per removed row for the life of the region.
|
|
187
187
|
|
|
188
188
|
Decision rules. Use `async render()` for request-time server data that should be in the first paint (the default). Add `renderFallback()` when a client re-fetch's stale content would mislead. Use `Task` / signals for genuinely client-only data (a click, viewport, live updates). For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`Loading...\`}>` to stream it (the only way to show a first-paint fallback; see `client-router-and-streaming.md`). Do NOT fetch in `connectedCallback` for data knowable server-side, and do NOT prop-drill what a leaf can fetch itself.
|
|
189
189
|
|
|
@@ -264,7 +264,56 @@ A component that does no client-side work renders the same SSR'd HTML with or wi
|
|
|
264
264
|
- 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)
|
|
265
265
|
- being rendered by a component that itself ships
|
|
266
266
|
|
|
267
|
-
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
|
|
267
|
+
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`.
|
|
268
|
+
|
|
269
|
+
### What `static interactive = true` does and does not rescue
|
|
270
|
+
|
|
271
|
+
The analyser reads source lexically, so a few real shapes escape it. The override covers them:
|
|
272
|
+
|
|
273
|
+
- **An OBSERVER that computes the tag it waits for.** `customElements.whenDefined(TAG)` where `TAG` is a variable does not name a tag the analyser can resolve, so the observed component is elided, its `register` never runs, and the `await` never settles. Put `static interactive = true` on the OBSERVED component.
|
|
274
|
+
- **A `:defined` rule in an external stylesheet.** `public/app.css` is not in the module graph, so a `my-badge:defined { … }` rule is invisible. Same fix, on the component the rule names.
|
|
275
|
+
- **A consumer that reaches the element through a string selector.** The analyser matches `whenDefined` / `:defined` / `instanceof`, so a `document.querySelector('my-wrapper')` consumer escapes all three. Same fix, on the component being reached.
|
|
276
|
+
|
|
277
|
+
**It does NOT rescue a component whose OWN registration tag is computed.** `Badge.register(TAG)` is not a registration the scanner recognises (invariant 3 requires a literal tag), so that component is never in the component set at all: it gets no verdict, nothing consults the analyser for it, and the override has nothing to attach to. The registration still runs if the module reaches the browser, so what you ALWAYS lose is the verdict, the tag-to-module registry entry, and the preload hint. Whether the element upgrades depends on one thing: the importing module has to ship WHOLE. An inert, import-only, or elided importer is dropped from the boot and takes the import with it, and then the element never registers at all. A page rendering a real component alongside the orphan is import-only unless it ALSO does its own client work, so shipping whole is the narrower case: assume the element does not upgrade. Always pass a literal: `Badge.register('my-badge')`.
|
|
278
|
+
|
|
279
|
+
`webjs dev` warns, and `webjs elision` / `webjs doctor` report it, as an **orphan**. That name covers TWO shapes and they fail differently, so read the warning carefully: a computed tag is the case above, while a class with NO registration call anywhere in the app is the plainer one (someone forgot to register it), and that element never upgrades. The check is app-wide, so registering the class from a sibling module is fine and is not reported. Both lose the verdict, the registry entry, and the preload hint.
|
|
280
|
+
|
|
281
|
+
### Inspecting and proving the verdict
|
|
282
|
+
|
|
283
|
+
Elision is the one thing WebJs decides about your code that you did not write down, so it is inspectable rather than something to reason about from the rules above.
|
|
284
|
+
|
|
285
|
+
```sh
|
|
286
|
+
webjs elision # per-module verdict, and the evidence behind every ship
|
|
287
|
+
webjs elision --json # the same object, for a tool or an agent
|
|
288
|
+
webjs elision --verify # prove elision changed nothing your app serves
|
|
289
|
+
webjs elision --verify --routes /,/blog/hello # add paths (the only way to cover a dynamic route)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Reading the report.** Every component is `elided` or `shipped`. A shipped one carries the `evidence` that forced it, first match wins:
|
|
293
|
+
|
|
294
|
+
| `evidence` | Means | `by` |
|
|
295
|
+
|---|---|---|
|
|
296
|
+
| `own` | its own source carries a signal; `reason` is the exact one | null |
|
|
297
|
+
| `observed` | another module observes its registration (`whenDefined` / `:defined` / `instanceof`) | the observer |
|
|
298
|
+
| `closure` | something it imports does client work | the import |
|
|
299
|
+
| `render` | a shipping component can render its tag | that component |
|
|
300
|
+
| `import` | a shipping component imports it | that component |
|
|
301
|
+
| `unreadable` | its source could not be read, so it ships conservatively | null |
|
|
302
|
+
|
|
303
|
+
An elided row carries no reason on purpose: elision is the ABSENCE of every signal, so there is no positive fact to report.
|
|
304
|
+
|
|
305
|
+
**What to do with each verdict.** `elided` on a component you believe is interactive is the one result worth acting on: find the signal it is missing (the list above), and if the interactivity is genuinely invisible to static analysis, add `static interactive = true`. `shipped` with an `evidence` you did not expect is usually a `closure` row, and the fix is to move the client-effecting import out of that component's path. An `orphans` row is always a bug, and the fix depends on which shape it is: give the class a literal registration tag if its tag is computed, or add the missing `Class.register('my-tag')` call if there is none at all (delete the class instead if nothing uses it).
|
|
306
|
+
|
|
307
|
+
**What `--verify` proves.** It renders every static page route with elision on and off and diffs the bytes with the JS-loaded set masked out, which is the framework's own guard pointed at your app. So it proves elision did not change what your app SERVES. It does not prove post-hydration behaviour, because a wrongly dropped module shows up as a dead click, not as different bytes. Cover that half by running your own browser or e2e suite twice:
|
|
308
|
+
|
|
309
|
+
```sh
|
|
310
|
+
WEBJS_ELIDE=1 npm run test:e2e
|
|
311
|
+
WEBJS_ELIDE=0 npm run test:e2e
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
It exits non-zero on a divergence AND on a corpus where nothing could be compared, so it is safe to put in CI. The ON side is forced on rather than read from your config, so the comparison is a real one even in an app that has elision switched off, and the run reports how many modules elision actually dropped so a trivially-true pass is visible. Dynamic routes are skipped by name (rendering one would mean inventing param values); pass real ones with `--routes`. A route whose two same-side renders already differ is reported as nondeterministic and excluded, since a differential over live data proves nothing.
|
|
315
|
+
|
|
316
|
+
`webjs doctor` carries the same verdict as a one-line inventory, and warns only on an orphan.
|
|
268
317
|
|
|
269
318
|
## Members app code must not shadow
|
|
270
319
|
|
|
@@ -135,7 +135,7 @@ import { createPost } from '#modules/posts/actions/create-post.server.ts';
|
|
|
135
135
|
html`<form action=${createPost}><input name="title"></form>`;
|
|
136
136
|
```
|
|
137
137
|
|
|
138
|
-
The renderer omits the `action` attribute so the form posts to the page's own url, supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` field carrying the action's `<hash>/<fn>` identity, the same identity the RPC endpoint resolves. Nothing about the action's source reaches the browser. With JS off this is an ordinary HTML submission; with JS the client router posts the same body to the same url, so the two paths are identical by construction.
|
|
138
|
+
The renderer omits the `action` attribute so the form posts to the page's own url, supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` field carrying the action's `<hash>/<fn>` identity, the same identity the RPC endpoint resolves. Nothing about the action's source reaches the browser. With JS off this is an ordinary HTML submission; with JS the client router posts the same body to the same url, encoded per the declared `enctype` (#1307: multipart stays `FormData`, urlencoded, which is the HTML default, is sent as `URLSearchParams`), so the two paths are identical by construction.
|
|
139
139
|
|
|
140
140
|
**A form-bound action always receives the `FormData`**, which is where it differs from the same function called over RPC (rich arguments) or server-to-server. `validate` is the typing seam: it takes the `FormData` and its transform-return becomes the action's typed input.
|
|
141
141
|
|
|
@@ -236,3 +236,55 @@ import { posts } from '#db/schema.server.ts';
|
|
|
236
236
|
```
|
|
237
237
|
|
|
238
238
|
Keep the wire shape in a browser-safe `modules/<feature>/types.ts` with NO runtime import from a `.server.ts` file or from `db/`. Define a hand-written DTO, or a type-only derivation (`import type { Post } ...; export type PostFormatted = Omit<Post, 'createdAt'> & { createdAt: string }`). Never `export *` or a value re-export from a `.server.ts` in `types.ts`; that carries the runtime table bindings and breaks any component importing the types. Full reference at https://webjs.dev/docs.
|
|
239
|
+
|
|
240
|
+
## SSR action seeding, and how to tell it is working
|
|
241
|
+
|
|
242
|
+
When a shipping component's `async render()` awaits an action during SSR, WebJs serializes that result into the page and the generated RPC stub reads it on its FIRST client call. So `const u = await getUser(this.id)` runs once, on the server, and hydration reuses the result with no network round-trip.
|
|
243
|
+
|
|
244
|
+
**You write nothing for this.** It is automatic, on by default, and there is no API to call. The only thing you can do is break it, so the section below is about noticing when you have.
|
|
245
|
+
|
|
246
|
+
### The correctness boundary
|
|
247
|
+
|
|
248
|
+
A seed hit returns the value the SSR render that produced this page computed for exactly this action, function, and argument list, so a hit cannot show the user something different from the HTML they are already looking at. A page navigation evicts whatever the outgoing page left unconsumed, both the block still in the DOM and anything already ingested from it, so a departed render's value is never served. On an HTML-cached page (`export const revalidate`) the seed rides inside the cached bytes, so it is exactly as fresh as the HTML it came with. A miss simply re-fetches.
|
|
249
|
+
|
|
250
|
+
There is one shape where a hit can differ from the paint, and WebJs warns about it in dev: **an action that returns a DIFFERENT result for the SAME arguments twice in one render.** The seed carries the last result while the first component painted the first one. So keep an action deterministic for a given argument list. A counter, a `Math.random()`, a `new Date()` in the return value, or a read of mutable module state all break that rule, and dev prints:
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
[webjs] SSR action seeding: "getUser" returned two DIFFERENT results for the SAME arguments during one render. ...
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The fix is to make the action deterministic, or to move the varying part into an argument so the two calls get different keys.
|
|
257
|
+
|
|
258
|
+
### Reading the dev diagnostics
|
|
259
|
+
|
|
260
|
+
A miss is invisible from the outside: the page still renders correctly, it just pays a round-trip per async component on every first load. Two channels make it visible in dev, and neither exists in production.
|
|
261
|
+
|
|
262
|
+
**Server side, per request.** The `X-Webjs-Seed` response header, also folded into the dev access-log line as a `seed` field:
|
|
263
|
+
|
|
264
|
+
| Value | What it means |
|
|
265
|
+
|---|---|
|
|
266
|
+
| `off` | Seeding is switched off (`"webjs": { "seed": false }` or `WEBJS_SEED=0`). Not a defect. |
|
|
267
|
+
| `html-cache` | The #241 HTML response cache answered. The seeds rode inside the cached bytes. |
|
|
268
|
+
| `collected=3, emitted=3` | Healthy. Three action results were captured and all three reached the page. |
|
|
269
|
+
| `collected=3, emitted=0` | The serializer threw and dropped the whole block. Something in a returned value is not serializer-safe. |
|
|
270
|
+
| `collected=3, emitted=0, streamed` | The page streams, so nothing could be emitted (see below). |
|
|
271
|
+
|
|
272
|
+
Check it with `curl -sSI localhost:3000/` or in the network tab.
|
|
273
|
+
|
|
274
|
+
**Browser side, per page view.** One `console.warn` at the first idle after hydration, and only when a call missed AND the client can be certain why. It stays silent otherwise, including on a page that emitted no seeds at all: every action call routes through the seed lookup, including ones that were never SSR-invoked and never could have been seeded (a mutation, a `Task` autorun, a `connectedCallback` read), so a miss there is not evidence of a defect. That case is the server header's job, where `collected=0` is unambiguous. The line names one of these:
|
|
275
|
+
|
|
276
|
+
- *"This page streams"*, so no seeds could be emitted. Expected, not a bug (see below).
|
|
277
|
+
- *"The page's seeds could not be serialized."* Something an action returned is not serializer-safe, so the whole block was dropped. The response header shows `collected` above `emitted` for the same reason.
|
|
278
|
+
- *"The page seeded these actions under DIFFERENT arguments."* The key is `hash(action file) / function name / serialized arguments`, so the client asked with an argument the SSR render never used. Common cause: the component computes its argument from browser-only state (a `localStorage` read, a `connectedCallback` assignment), which the server render could not have known. A miss on an action the page never seeded at all is NOT reported, because a mutation or a client-only read routes through the same lookup and could never have been seeded.
|
|
279
|
+
|
|
280
|
+
A miss AFTER hydration is correct and is not reported: the seed is consume-once, so a deliberate refetch or an argument change is supposed to go to the network.
|
|
281
|
+
|
|
282
|
+
`seedStats()` from `@webjsdev/core` returns `{ ingested, replaced, hits, misses, keyMisses, pending }` (`keyMisses` being the provable subset of `misses`, a call for an action the page seeded under other arguments) if you want to assert this in a browser test or read it from the console. A non-zero `pending` at rest usually means the seeding component ELIDED, so its module never shipped and nothing on the client was ever going to consume the seed. `pending` covers the page you are on: a page navigation evicts whatever the outgoing page left unconsumed, both the block still sitting in the DOM and anything already ingested from it, since those values belong to a render no longer on screen.
|
|
283
|
+
|
|
284
|
+
### The streamed-page exception
|
|
285
|
+
|
|
286
|
+
A page carrying a `Suspense` or `<webjs-suspense>` boundary emits NO seed block at all, not just none for the streamed region: a streamed render's deferred boundaries resolve after the first flush, so their results cannot ride the block. Every action call on that page goes to the network on hydration. That is a real trade, so make it deliberately: reach for a streaming boundary when a slow region would otherwise block the first byte, and leave a fast page buffered so it seeds.
|
|
287
|
+
|
|
288
|
+
### Switching it off
|
|
289
|
+
|
|
290
|
+
`"webjs": { "seed": false }` in `package.json`, or `WEBJS_SEED=0`. The client then re-fetches on hydration exactly as it did before the feature, and stale-while-revalidate hides the flicker. Turn it off only to isolate a problem; there is no reason to ship with it off.
|
|
@@ -64,7 +64,21 @@ html`<form action=${saveDraft}>
|
|
|
64
64
|
</form>`;
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
The identity rides the pressed button's own `name`/`value` pair, which a browser submits for that button alone, so this works with JS off exactly as it does with JS on. Both entries reach the server and the LAST wins, which is always the submitter's when one was pressed.
|
|
67
|
+
The identity rides the pressed button's own `name`/`value` pair, which a browser submits for that button alone, so this works with JS off exactly as it does with JS on. Both entries reach the server and the LAST wins, which is always the submitter's when one was pressed.
|
|
68
|
+
|
|
69
|
+
**A bound submitter is self-sufficient, so the enclosing form does not have to be bound** (#1307). The renderer puts `formmethod="post"` and `formenctype="multipart/form-data"` on the button itself, alongside the identity, exactly as React emits `formMethod` and `formEncType` on a button carrying a function `formAction`. So a per-button action works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all:
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
// Every button below submits a POST its action can read, with JS on or off.
|
|
73
|
+
html`<form>
|
|
74
|
+
<button formaction=${saveDraft}>Save</button>
|
|
75
|
+
<button formaction=${publishPost}>Publish</button>
|
|
76
|
+
</form>`;
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
One consequence worth knowing rather than discovering: no `formaction` url is emitted (an empty one is an HTML conformance error), so the submission targets whatever the FORM targets. A form declaring its own `action="/x"` sends its buttons to `/x`, which is native precedence. The action still runs if `/x` is a PAGE route, because the identity travels in the body, but against a `route.ts` or another origin the identity is ignored and nothing runs. In dev the client logs a warning at submit time naming the url. Leaving the form's `action` off, the ordinary shape, keeps the submission on the current page.
|
|
80
|
+
|
|
81
|
+
The submitter must be a `<button>` and cannot carry its own `name`, `value`, or `form` attribute, because the identity already occupies that pair. `<input type="submit">` is refused for the binding: the identity has to occupy its `value`, which on that control is also the visible caption, so the button would render captioned with the action id and could never be labelled. A `<button>` has no such conflict, since its label is its children.
|
|
68
82
|
|
|
69
83
|
**Only the bare, unquoted `action=${fn}` on a `<form>` binds.** Every near-miss is a hard render error rather than a silently-inert form, and the reason is a source leak. During SSR a `.server.ts` import is the ACTUAL function (the RPC stub exists only in the browser), and `action=` is an ordinary attribute hole, so stringifying it would write the function's body into the HTML every visitor downloads, including any literal inside it. The renderer throws instead, on the server and on the client, for `action=` and `formaction=` alike.
|
|
70
84
|
|
|
@@ -95,21 +109,21 @@ The bound, refused, and allowed shapes in full. Every "no" row is a binding that
|
|
|
95
109
|
| `action=${fn}` unquoted, on a `<form>` | **no, it BINDS** | the one supported shape: the identity is resolved and emitted as a hidden field, nothing is stringified |
|
|
96
110
|
| `action=${fn}` on any other tag | yes | `action` submits nothing off a `<form>`, so it is an ordinary attribute and the function would be stringified |
|
|
97
111
|
| `action="${fn}"`, or a mixed `action="/x/${fn}"` | yes | quoting turns a binding hole back into a plain attribute |
|
|
98
|
-
| `formaction=${fn}` unquoted, on a submitter
|
|
99
|
-
| `formaction=${fn}` inside an UNBOUND `<form>` | yes | `method="post"` and the enctype are forced on the FORM's start tag, which SSR has already emitted by the time it reaches the button, so a per-button action cannot retrofit them |
|
|
112
|
+
| `formaction=${fn}` unquoted, on a submitter, ANYWHERE | **no, it BINDS** | the second supported shape (#1207, #1307). A bound submitter carries its WHOLE submission: the identity rides the button's own `name`/`value` pair, the one channel a browser submits for the pressed button alone, and the renderer adds `formmethod="post"` and `formenctype="multipart/form-data"` to the button itself. So it works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all, and it asks NOTHING of the element around it. No `formaction` url is emitted, and the server takes the LAST `__webjs_action` entry |
|
|
100
113
|
| `formaction=${fn}` on a submitter carrying its own `name` or `value` | yes | the identity IS that name/value pair, so both halves are already spoken for. Bind one action on the form and dispatch on `name="intent"` if you need the button's own value |
|
|
101
114
|
| `formaction=${fn}` on a non-submit control, or `<input type="image">` | yes | `formaction` is inert on anything that does not submit, and an image submitter sends `name.x` / `name.y` coordinates instead of `name=value`, so the identity would never arrive |
|
|
102
|
-
| `formaction=${fn}` on a submitter with `form="other"` | yes | it re-points the submitter at a form
|
|
103
|
-
| `formmethod
|
|
115
|
+
| `formaction=${fn}` on a submitter with `form="other"` | yes | it re-points the submitter at a different form owner, which may not be where the identity field it needs lives |
|
|
116
|
+
| `formmethod` / `formenctype` the BOUND submitter cannot submit with (`get`, `PATCH`, `text/plain`, `dialog`, a padded `" post "`) | yes | a same-element contradiction: you attached an action to THIS button and told THIS button to submit in a way that action could never read. The renderer supplies `formmethod="post"` and the enctype only where you supplied neither, so your own parseable value always wins |
|
|
117
|
+
| `formmethod="get"` / `formenctype="text/plain"` on a PLAIN submitter inside a bound form | **no** | #1307 reversed this. Native HTML says the submitter's override wins, you typed it deliberately, and the form's action simply does not run, exactly as the same markup behaves anywhere else. In dev the client logs a console error at submit time if the submission is carrying an identity it cannot deliver |
|
|
104
118
|
| `formmethod="dialog"` on a submitter that binds nothing | **no** | a native `<dialog>` dismissal, never a submission, so there is no body for the action to miss. It IS refused on a button that also binds an action, which is a straight contradiction |
|
|
105
|
-
| a plain `formaction="/url"` on a submitter inside a bound form | **no** | it retargets the submission away from the page's bound action entirely, so
|
|
119
|
+
| a plain `formaction="/url"` on a submitter inside a bound form | **no** | it retargets the submission away from the page's bound action entirely, so where it goes and how is your business |
|
|
106
120
|
| `.action=` on a native form | yes | the supported binding is the plain attribute, and a `.prop` on a native element drops at SSR, so accepting it would mean a form that submits under JS and does nothing without it |
|
|
107
121
|
| `.method=` / `.enctype=` / `.encoding=` on a BOUND form | yes | the same reason one level over. All three are reflected IDL attributes, so SSR drops the binding and emits `method="post"` while a browser ends at what you assigned. Write them as plain attributes |
|
|
108
122
|
| a second `action=${fn}` on one form | yes | SSR emits the second as a plain url next to the identity field, the client takes the last. Bind exactly one, in either position |
|
|
109
123
|
| a plain `action="/url"` beside the bound hole | yes | the hole drops only its OWN attribute, so SSR keeps the static one while the client removes it: without JS the browser posts to `/url`, with JS to the page |
|
|
110
124
|
| `method=" post "` / `enctype=" multipart/form-data "` | yes | `method` and `enctype` are enumerated attributes matched against exact keywords with no whitespace stripping, so a padded value falls to the invalid-value default and the form submits as a GET with no body. Trimming it for you would emit the padded value anyway |
|
|
111
125
|
| `encoding="..."` as an ATTRIBUTE on a bound form | **no** | inert in HTML (`form.encoding` reads back `enctype`), so both renderers ignore it and still supply `enctype`. Only the `.encoding` PROPERTY aliases enctype, and that spelling IS refused, one row up |
|
|
112
|
-
| `.formAction=` on a
|
|
126
|
+
| `.formAction=` / `.formMethod=` / `.formEnctype=` on a BOUND submitter | yes | same reason, that is where they reflect: SSR drops the property and the browser applies it, so the button would submit one way with JS and another way without. On a PLAIN button they are ordinary native properties and are left alone |
|
|
113
127
|
| `.action=` on any other native tag | **no** | a plain expando (`<div .action=${fn}>`, `<button .action=${fn}>`), reflecting nothing, so nothing reaches the markup |
|
|
114
128
|
| `.action=` on a custom element | **no** | an author-defined property, not a reflected IDL attribute, so a function is a legitimate value. One declared `reflect: true` reflects on a path outside these commit sites, which used to write `String(value)` and emit the source. It now removes the attribute and warns instead, for a bare function and for an array carrying one, unless the prop supplies its own `converter.toAttribute`, which runs first and stays the author's call |
|
|
115
129
|
| `?action=` | yes | a function never leaked through a boolean hole, but the binding is meaningless, so it is refused rather than emitting the bare `action=""` that ANY truthy value produces there. Two separate facts worth carrying: `action=""` is a conformance error (the spec wants a valid non-empty URL whenever the attribute is present), and deleting the attribute is still not the WebJs fix, since a page has no `action` export and an unbound `method="post"` form is a 405 (a bare GET form just re-renders). Bind it: `<form action=${fn}>` |
|
|
@@ -170,6 +184,12 @@ The file stays `middleware.ts`, NOT Next 16's renamed `proxy.ts`. WebJs middlewa
|
|
|
170
184
|
|
|
171
185
|
Navigation is automatic. The client router auto-enables when `@webjsdev/core` loads (any page with a component), so a plain `<a href>` gets soft navigation for free. There is no `<Link>` to import and no `useRouter`. For programmatic navigation import `navigate()` / `revalidate()` from `@webjsdev/core`. There is no `next/image`, `next/font`, `next/script`, or `next/dynamic`. WebJs is no-build: use a plain `<img>`, a `<link>` / `@font-face`, a component's `static lazy = true` for viewport lazy-loading, and a dynamic `import()` where code should load lazily.
|
|
172
186
|
|
|
187
|
+
### No `<ScrollRestoration>`, and no scroll restore of your own
|
|
188
|
+
|
|
189
|
+
Remix ships a `<ScrollRestoration />` component, Next has a `scrollRestoration` flag and a pile of community `useEffect` + `scrollTo` recipes, and every one of them is a thing to NOT port. WebJs restores scroll on Back/Forward automatically: the router sets `history.scrollRestoration = 'manual'` on boot and is the sole authority on scroll for the whole navigation. There is no component to render and no option to enable. An app-level `popstate` listener that calls `scrollTo`, a remembered offset in `sessionStorage`, or a `scrollIntoView` on a saved element all race the router and win sometimes, which is worse than losing consistently.
|
|
190
|
+
|
|
191
|
+
This includes the case that most tempts a hand-rolled fix: Back landing BELOW where the reader left, on a page whose components size themselves after they render. The router already handles it, by suppressing the browser's scroll anchoring across the restore so late growth above the viewport is not added to the offset it just replayed (see `client-router-and-streaming.md`). If a restore still lands wrong, report it rather than patching around it in app code.
|
|
192
|
+
|
|
173
193
|
### Server-only code: the `.server.ts` boundary, not a `server-only` package
|
|
174
194
|
|
|
175
195
|
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.
|
|
@@ -104,7 +104,7 @@ render() {
|
|
|
104
104
|
|
|
105
105
|
`method` and the enctype are supplied by the renderer, and the hidden identity field is re-inserted as the form's first child on every client render, so there is nothing to manage by hand.
|
|
106
106
|
|
|
107
|
-
When a page owns SEVERAL mutations (create, toggle, delete), give each form its OWN binding (`action=${createTodo}` / `action=${toggleTodo}` / `action=${deleteTodo}`), or use per-button submitter server action bindings via `formaction=${action}` on submitter buttons
|
|
107
|
+
When a page owns SEVERAL mutations (create, toggle, delete), give each form its OWN binding (`action=${createTodo}` / `action=${toggleTodo}` / `action=${deleteTodo}`), or use per-button submitter server action bindings via `formaction=${action}` on submitter buttons (#1207, #1307: a bound submitter carries its own submission, so the enclosing form need not be bound). Alternatively, a form that dispatches dynamically can bind ONE action and inspect a submit button's `name="intent"`.
|
|
108
108
|
|
|
109
109
|
## Seed the list from the server for SSR plus optimistic
|
|
110
110
|
|
|
@@ -179,7 +179,7 @@ Three responses that are not the happy path:
|
|
|
179
179
|
|
|
180
180
|
The submission is Origin-verified (the same `Sec-Fetch-Site` / `Origin` check the RPC endpoint applies), so a no-JS form needs no CSRF token field.
|
|
181
181
|
|
|
182
|
-
Refusals worth knowing: `formaction=${fn}` is supported
|
|
182
|
+
Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere, bound form or not (#1307: the renderer gives the button its own `formmethod` and `formenctype`), and that button may not carry `name`, `value`, `form`, or a static `formaction` attribute (`<input type="submit">` is refused, because the identity needs its `value`, which is also its label). A bound form may not declare `method="get"`, and a function bound to `action=` that is not a `'use server'` export throws at render rather than producing a form that posts nowhere. See `muscle-memory-gotchas.md` for the full table.
|
|
183
183
|
|
|
184
184
|
## Error, loading, and 404 boundaries
|
|
185
185
|
|
|
@@ -170,6 +170,25 @@ A cross-runtime proof is often a plain assert script rather than a test file, so
|
|
|
170
170
|
- **Assert the exit code, never the logs.** These scripts conventionally pass a `quiet` logger, so anything that only logs is invisible. If your script catches its own failure, report it with an explicit `process.exit(1)` rather than a `console.error` alone, guarded as `if (import.meta.main) process.exit(1); else throw failure;`. The guard matters when a `.test.mjs` wrapper imports the script under `node --test`: an unguarded exit kills the whole single-process run and hides every other file's results, while the throw lets the harness report one failed test.
|
|
171
171
|
- **Prove the script can FAIL before you trust it passing.** Break one assertion on purpose and confirm the run exits non-zero. A proof that cannot go red is worse than no proof: it reports success forever.
|
|
172
172
|
|
|
173
|
+
## Proving display-only elision did not break anything
|
|
174
|
+
|
|
175
|
+
WebJs strips the JavaScript of every component that does no client work, so a wrong verdict costs an app real interactivity and does it silently. Two commands cover the two halves, and you need both.
|
|
176
|
+
|
|
177
|
+
```sh
|
|
178
|
+
webjs elision --verify
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
renders every static page route with elision on and off and diffs the served bytes. It is the framework's own differential guard pointed at your route table, and it exits non-zero on a divergence AND on a corpus where nothing could be compared, so it belongs in CI. It forces the ON side on rather than reading your config, and reports how many modules elision actually dropped, so a pass that compared two identical renders is visible rather than silent. Dynamic routes are skipped by name; add real paths with `--routes /,/blog/hello`.
|
|
182
|
+
|
|
183
|
+
That proves the bytes you SERVE did not change. It cannot prove post-hydration behaviour, because a wrongly dropped module shows up as a dead click, not as different bytes. Run your own browser or e2e suite twice for that half:
|
|
184
|
+
|
|
185
|
+
```sh
|
|
186
|
+
WEBJS_ELIDE=1 npm run test:e2e
|
|
187
|
+
WEBJS_ELIDE=0 npm run test:e2e
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
A test that passes under one and fails under the other is a wrong verdict, and `webjs elision` tells you which module and on what evidence. If the component's interactivity is genuinely invisible to static analysis, the fix is `static interactive = true` on it; see `components.md` for what that override does and does not rescue.
|
|
191
|
+
|
|
173
192
|
## Convention validation (`webjs check`)
|
|
174
193
|
|
|
175
194
|
`npm run check` is the correctness validator. Every rule catches code that is wrong to ship, a crash, a security leak, a reactive prop that silently stops re-rendering, or a type-strip failure. Run it and fix every violation before considering the change done (`npm run check -- --json` for an agent loop, `npm run check -- --rules` to list the rules). It is separate from `CONVENTIONS.md`, which carries the customizable project conventions you follow by judgment.
|
|
@@ -16,8 +16,9 @@ export class ServerClock extends WebComponent {
|
|
|
16
16
|
// serves it with ZERO JavaScript and skips the redundant on-hydration
|
|
17
17
|
// re-fetch. This is the common fetch-and-display leaf shape. If you ever need
|
|
18
18
|
// to force a component to ship when the analyser would elide it (for
|
|
19
|
-
// interactivity static analysis cannot see, like
|
|
20
|
-
//
|
|
19
|
+
// interactivity static analysis cannot see, like an observer that computes
|
|
20
|
+
// the tag it waits for), declare `static interactive = true`. Run
|
|
21
|
+
// `webjs elision` to see the verdict for every component in this app.
|
|
21
22
|
async render() {
|
|
22
23
|
const info = await serverGreeting();
|
|
23
24
|
return html`<p class="font-mono text-sm">server rendered this at
|
|
@@ -12,9 +12,11 @@ import { deleteTodo } from './delete-todo.server.ts';
|
|
|
12
12
|
// action: this form carries the todo's `id` on a hidden input and needs the
|
|
13
13
|
// SAME id for whichever mutation runs, so one action reading both fields is the
|
|
14
14
|
// simpler shape. When the buttons need no shared payload, bind each one
|
|
15
|
-
// directly instead, with `formaction=${action}` on a <button
|
|
16
|
-
// form
|
|
17
|
-
//
|
|
15
|
+
// directly instead, with `formaction=${action}` on a <button>. The enclosing
|
|
16
|
+
// <form> does NOT have to be bound: a bound submitter carries its own
|
|
17
|
+
// `formmethod` and enctype, so it works in any form or none. The identity rides
|
|
18
|
+
// that button's own name/value pair, so it works with JS off too. Two things to
|
|
19
|
+
// know: it must be a <button> (on an
|
|
18
20
|
// <input type="submit"> the identity would occupy `value`, which is also that
|
|
19
21
|
// control's visible label), and a bound submitter cannot carry its own
|
|
20
22
|
// `name`/`value`, which is exactly the channel `name="intent"` uses below.
|