@webjsdev/server 0.8.90 → 0.9.0
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/index.d.ts +41 -0
- package/index.js +1 -0
- package/package.json +3 -3
- package/src/action-input.js +208 -0
- package/src/actions.js +17 -2
- package/src/check/helpers.js +85 -0
- package/src/check/rules-components.js +33 -2
- package/src/check/rules.js +5 -0
- package/src/check/runner.js +2 -0
- package/src/component-elision.js +64 -88
- package/src/dev/dev-status.js +122 -0
- package/src/dev/handler.js +131 -1
- package/src/dev/serve.js +18 -8
- package/src/dev/server.js +17 -4
- package/src/dev/vendor-retry.js +12 -14
- package/src/dev-regenerate.js +320 -76
- package/src/dev-styles.js +64 -6
- package/src/form-dispatch.js +10 -4
- package/src/ssr/document.js +12 -4
- package/src/ssr/head.js +150 -2
- package/src/ssr/preloads.js +5 -0
- package/src/ssr/render.js +18 -29
- package/src/ssr/shipped-modules.js +50 -4
- package/src/testing.d.ts +1 -1
- package/src/testing.js +7 -2
- package/webjs-config.schema.json +2 -2
package/src/dev-styles.js
CHANGED
|
@@ -61,17 +61,28 @@ export function preloadStyles(root, now, timeoutMs) {
|
|
|
61
61
|
kept[key] = { el, url };
|
|
62
62
|
}
|
|
63
63
|
let committed = false;
|
|
64
|
-
/** @type {Array<{ key: string, old: Element, next: Element, state: string }>} */
|
|
64
|
+
/** @type {Array<{ key: string, file: string, old: Element, next: Element, state: string }>} */
|
|
65
65
|
const pairs = [];
|
|
66
66
|
// Drop every other copy of this sheet (the old link, a copy the head merge
|
|
67
|
-
// re-appended) so exactly the
|
|
67
|
+
// re-appended, the client router's temporary preload copy) so exactly the
|
|
68
|
+
// replacement is left. The router marks its copy `data-webjs-preload-style`
|
|
69
|
+
// and gives it only the fetch attributes, so it never shares the identity
|
|
70
|
+
// key. Left in place it waits for a twin link this function has just
|
|
71
|
+
// removed, and lingers until the router's timeout. So a copy goes too when
|
|
72
|
+
// it is the same file (the same URL once the dev cache-buster is ignored,
|
|
73
|
+
// so `?theme=dark` stays when `?theme=light` is rebuilt) AND has already
|
|
74
|
+
// loaded: an unloaded copy is one a navigation is still waiting on, and
|
|
75
|
+
// removing it would stall that navigation for the whole timeout.
|
|
68
76
|
const settleLoaded = function (pair) {
|
|
77
|
+
if (pair.old.parentNode) pair.old.parentNode.removeChild(pair.old);
|
|
69
78
|
const links = [].slice.call(scope.querySelectorAll('link[rel~="stylesheet"][href]'));
|
|
70
79
|
for (const el of links) {
|
|
71
80
|
if (el === pair.next) continue;
|
|
72
81
|
let url;
|
|
73
82
|
try { url = new URL(el.getAttribute('href'), location.href); } catch (_) { continue; }
|
|
74
|
-
if (url.origin
|
|
83
|
+
if (url.origin !== location.origin || !el.parentNode) continue;
|
|
84
|
+
const routerCopy = el.hasAttribute(ROUTER_PRELOAD_ATTR) && !!el.sheet && withoutBuster(url) === pair.file;
|
|
85
|
+
if (routerCopy || identityKey(el, url) === pair.key) el.parentNode.removeChild(el);
|
|
75
86
|
}
|
|
76
87
|
};
|
|
77
88
|
const waits = [];
|
|
@@ -79,13 +90,30 @@ export function preloadStyles(root, now, timeoutMs) {
|
|
|
79
90
|
const old = kept[key].el;
|
|
80
91
|
const url = kept[key].url;
|
|
81
92
|
if (!old.parentNode) continue;
|
|
93
|
+
const file = withoutBuster(url);
|
|
82
94
|
url.searchParams.set('__webjs_dev', String(stamp()));
|
|
83
95
|
const next = /** @type {Element} */ (old.cloneNode(false));
|
|
84
96
|
next.setAttribute('href', url.pathname + url.search);
|
|
85
|
-
|
|
97
|
+
// The copy loads under a media query that matches nothing, so it never
|
|
98
|
+
// applies before its load handler has decided whether it should: the
|
|
99
|
+
// client router can switch the link off or on (#1669) while this refresh
|
|
100
|
+
// waits, so that state is read at load, not now.
|
|
101
|
+
const media = authoredMedia(old);
|
|
102
|
+
next.removeAttribute('data-wj-style-media');
|
|
103
|
+
next.setAttribute('media', 'not all');
|
|
104
|
+
const pair = { key, file, old, next, state: 'pending' };
|
|
86
105
|
pairs.push(pair);
|
|
87
106
|
waits.push(new Promise(function (resolve) {
|
|
88
107
|
next.addEventListener('load', function () {
|
|
108
|
+
// A section sheet the router switched off is refreshed too, so going
|
|
109
|
+
// back to the section switches on the edited file, but the copy stays
|
|
110
|
+
// off until then, like the link it replaces. Media first: Firefox and
|
|
111
|
+
// WebKit give the link a new sheet object when its media changes, so
|
|
112
|
+
// disabling before would be lost.
|
|
113
|
+
const off = old.parentNode ? old.hasAttribute('data-wj-style-off') : next.hasAttribute('data-wj-style-off');
|
|
114
|
+
if (off) next.setAttribute('data-wj-style-off', ''); else next.removeAttribute('data-wj-style-off');
|
|
115
|
+
if (media) next.setAttribute('media', media); else next.removeAttribute('media');
|
|
116
|
+
if (next.sheet) next.sheet.disabled = off;
|
|
89
117
|
pair.state = 'load';
|
|
90
118
|
if (committed) settleLoaded(pair);
|
|
91
119
|
resolve(undefined);
|
|
@@ -112,6 +140,24 @@ export function preloadStyles(root, now, timeoutMs) {
|
|
|
112
140
|
});
|
|
113
141
|
}
|
|
114
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The attribute the client router marks its temporary stylesheet copies with
|
|
145
|
+
* (`PRELOAD_ATTR` in core's `router-client/stylesheets.js`). Duplicated rather
|
|
146
|
+
* than imported, since this module is inlined into the reload client as source.
|
|
147
|
+
*/
|
|
148
|
+
const ROUTER_PRELOAD_ATTR = 'data-webjs-preload-style';
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A stylesheet URL with the dev cache-buster removed, which is what names the
|
|
152
|
+
* file: two links that differ only by `__webjs_dev` load the same rules.
|
|
153
|
+
* @param {URL} url
|
|
154
|
+
*/
|
|
155
|
+
function withoutBuster(url) {
|
|
156
|
+
const u = new URL(url.href);
|
|
157
|
+
u.searchParams.delete('__webjs_dev');
|
|
158
|
+
return u.pathname + u.search;
|
|
159
|
+
}
|
|
160
|
+
|
|
115
161
|
/**
|
|
116
162
|
* A link's identity for de-duping: its path plus every attribute except `href`.
|
|
117
163
|
* JSON-encoded over a sorted list so no separator can collide with an attribute
|
|
@@ -123,9 +169,21 @@ function identityKey(el, url) {
|
|
|
123
169
|
const parts = [];
|
|
124
170
|
const attrs = el.attributes;
|
|
125
171
|
for (let a = 0; a < attrs.length; a++) {
|
|
126
|
-
|
|
127
|
-
|
|
172
|
+
const name = attrs[a].name;
|
|
173
|
+
// The client router's switch-off state (#1669) is not part of which sheet
|
|
174
|
+
// a link is, and neither is a held `not all` media: the author's is.
|
|
175
|
+
if (name === 'href' || name === 'data-wj-style-off' || name === 'data-wj-style-media') continue;
|
|
176
|
+
parts.push([name, name === 'media' ? authoredMedia(el) : attrs[a].value]);
|
|
128
177
|
}
|
|
129
178
|
parts.sort((x, y) => (x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0));
|
|
130
179
|
return JSON.stringify([url.pathname, parts]);
|
|
131
180
|
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The media a link applies under: the author's, also while the client router
|
|
184
|
+
* holds it under `not all` (`data-wj-style-media`, owned-styles.js).
|
|
185
|
+
* @param {Element} el
|
|
186
|
+
*/
|
|
187
|
+
function authoredMedia(el) {
|
|
188
|
+
return el.hasAttribute('data-wj-style-media') ? el.getAttribute('data-wj-style-media') : el.getAttribute('media');
|
|
189
|
+
}
|
package/src/form-dispatch.js
CHANGED
|
@@ -8,6 +8,7 @@ import { lookupActionIdentity } from './form-action-identity.js';
|
|
|
8
8
|
import { verifyOrigin } from './csrf.js';
|
|
9
9
|
import { actionMethod, actionConfigFn, actionMiddleware, resolveTags, RESERVED_CONFIG } from './action-config.js';
|
|
10
10
|
import { runValidate } from './actions.js';
|
|
11
|
+
import { formInput } from './action-input.js';
|
|
11
12
|
import { runActionChain } from './action-middleware.js';
|
|
12
13
|
import { runWithActionSignal } from './action-signal.js';
|
|
13
14
|
import { isStreamable } from './action-stream.js';
|
|
@@ -527,10 +528,15 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
|
|
|
527
528
|
);
|
|
528
529
|
}
|
|
529
530
|
|
|
530
|
-
// A form-bound action
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
|
|
531
|
+
// A form-bound action receives the submitted fields as ONE object that reads
|
|
532
|
+
// both ways (`action-input.js`): `input.title`, the shape the same action gets
|
|
533
|
+
// over RPC and through `route()`, and `input.get('title')`, the FormData
|
|
534
|
+
// readers. It used to receive the raw FormData, so a validator written for
|
|
535
|
+
// the RPC shape read `undefined` and answered every submission with a 422
|
|
536
|
+
// (#1664). `validate`, middleware, the action and `invalidates` all see it.
|
|
537
|
+
// The raw `formData` local keeps feeding the #1581 submitted-values path.
|
|
538
|
+
const warn = ssrOpts.dev ? (logger && logger.warn ? (m) => logger.warn(m) : (m) => console.warn(m)) : undefined;
|
|
539
|
+
let args = [formInput(formData, { warn, label: fnName })];
|
|
534
540
|
const validate = actionConfigFn(mod, 'validate');
|
|
535
541
|
if (typeof validate === 'function') {
|
|
536
542
|
const v = runValidate(validate, args[0]);
|
package/src/ssr/document.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { collectHoistedHeadTags, hoistHeadTags, wrapHead } from './head.js';
|
|
1
|
+
import { collectHoistedHeadTags, hoistHeadTags, hoistOwnedStylesheets, stylesheetKeys, wrapHead } from './head.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Derive a layout's segment path from its file path. The path identifies
|
|
@@ -331,19 +331,27 @@ export function buildDocumentParts(body, wrapOpts) {
|
|
|
331
331
|
if (shell) {
|
|
332
332
|
const headInner = buildHeadInner(wrapOpts);
|
|
333
333
|
const hoist = collectHoistedHeadTags(shell.userBody);
|
|
334
|
-
const
|
|
334
|
+
const rootHead = [headInner, shell.userHead.trim(), hoist.tags.join('\n')]
|
|
335
335
|
.filter(Boolean)
|
|
336
336
|
.join('\n');
|
|
337
|
+
// A nested layout's or page's leading stylesheets join the head after the
|
|
338
|
+
// root's, so the cascade reads root to leaf (#1669).
|
|
339
|
+
const owned = hoistOwnedStylesheets(hoist.body, stylesheetKeys(rootHead, wrapOpts.pageUrl), wrapOpts.pageUrl);
|
|
340
|
+
const composedHead = [rootHead, owned.tags.join('\n')].filter(Boolean).join('\n');
|
|
337
341
|
const prefix =
|
|
338
342
|
`<!doctype html>\n<html${shell.htmlAttrs}>\n<head${shell.headAttrs}>\n` +
|
|
339
343
|
composedHead +
|
|
340
344
|
`\n</head>\n<body${shell.bodyAttrs}>\n`;
|
|
341
|
-
return { prefix, streamBody:
|
|
345
|
+
return { prefix, streamBody: owned.body, closer: `\n</body>\n</html>` };
|
|
342
346
|
}
|
|
343
347
|
// No user shell: framework owns the wrapper.
|
|
344
348
|
const headHtml = wrapHead(wrapOpts);
|
|
345
349
|
const { head, body: bodyOut } = hoistHeadTags(headHtml, body);
|
|
346
|
-
|
|
350
|
+
const owned = hoistOwnedStylesheets(bodyOut, stylesheetKeys(head, wrapOpts.pageUrl), wrapOpts.pageUrl);
|
|
351
|
+
const prefix = owned.tags.length
|
|
352
|
+
? head.replace('</head>', () => owned.tags.join('\n') + '\n</head>')
|
|
353
|
+
: head;
|
|
354
|
+
return { prefix, streamBody: owned.body, closer: `\n</body>\n</html>` };
|
|
347
355
|
}
|
|
348
356
|
|
|
349
357
|
/** Re-export for unit testing. */
|
package/src/ssr/head.js
CHANGED
|
@@ -305,7 +305,7 @@ export function collectHoistedHeadTags(bodyHtml) {
|
|
|
305
305
|
* (breaks the ES-module waterfall without a bundler) and any user-declared
|
|
306
306
|
* `metadata.preload` entries.
|
|
307
307
|
*
|
|
308
|
-
* @param {{ metadata: Record<string,any>, moduleUrls: string[], dev: boolean, streaming: boolean, preloads?: string[], lazyComponents?: Record<string, string>, nonce?: string }} opts
|
|
308
|
+
* @param {{ metadata: Record<string,any>, moduleUrls: string[], dev: boolean, streaming: boolean, preloads?: string[], lazyComponents?: Record<string, string>, nonce?: string, pageUrl?: string }} opts
|
|
309
309
|
*/
|
|
310
310
|
/**
|
|
311
311
|
* Extract leading `<script>`, `<style>`, and `<link>` tags from the body
|
|
@@ -323,13 +323,161 @@ export function hoistHeadTags(headHtml, bodyHtml) {
|
|
|
323
323
|
// streaming path so both hoist identically. See collectHoistedHeadTags.
|
|
324
324
|
const { tags: hoisted, body: remaining } = collectHoistedHeadTags(bodyHtml);
|
|
325
325
|
if (!hoisted.length) return { head: headHtml, body: bodyHtml };
|
|
326
|
-
const newHead = headHtml.replace('</head>', hoisted.join('\n') + '\n</head>');
|
|
326
|
+
const newHead = headHtml.replace('</head>', () => hoisted.join('\n') + '\n</head>');
|
|
327
327
|
return { head: newHead, body: remaining };
|
|
328
328
|
}
|
|
329
329
|
|
|
330
330
|
/** Internal helper re-exported for unit testing. */
|
|
331
331
|
export const _hoistHeadTags = hoistHeadTags;
|
|
332
332
|
|
|
333
|
+
/**
|
|
334
|
+
* The attribute naming the boundary a hoisted stylesheet came from (#1669).
|
|
335
|
+
* Its value is the SEGMENT of the `wj:children` range the link was rendered
|
|
336
|
+
* in: a nested layout's own markup sits inside its parent's range, and a
|
|
337
|
+
* page's inside its own boundary or its innermost layout's. The client router
|
|
338
|
+
* switches the sheet off when it swaps that range and the incoming page no
|
|
339
|
+
* longer links the same file, so a section's stylesheet leaves with the
|
|
340
|
+
* section, and switches it back on when a later page links it again.
|
|
341
|
+
*/
|
|
342
|
+
export const STYLE_OWNER_ATTR = 'data-wj-style-owner';
|
|
343
|
+
|
|
344
|
+
const BOUNDARY_OPEN = '<!--wj:children:';
|
|
345
|
+
// One token of a leading run after a boundary marker: a comment that is not a
|
|
346
|
+
// client-router marker, or a `<link>` tag (quote-aware, like the root hoist).
|
|
347
|
+
// Sticky, so it matches at `lastIndex` without copying the rest of the body.
|
|
348
|
+
const OWNED_RUN_TOKEN_RE =
|
|
349
|
+
/\s*(<!--(?!\/?wj:)[\s\S]*?-->|<link\b(?:[^>"']|"[^"]*"|'[^']*')*>)/iy;
|
|
350
|
+
const LINK_TAG_RE = /<link\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
|
|
351
|
+
|
|
352
|
+
// One attribute after the tag name, read in order so a quoted value is
|
|
353
|
+
// consumed whole and never mistaken for another attribute.
|
|
354
|
+
const ATTR_RE = /\s*([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/y;
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* A tag's attributes, lowercased names to raw values (the first of a repeated
|
|
358
|
+
* name wins, as in HTML).
|
|
359
|
+
* @param {string} tag
|
|
360
|
+
* @returns {Map<string, string>}
|
|
361
|
+
*/
|
|
362
|
+
function tagAttrs(tag) {
|
|
363
|
+
/** @type {Map<string, string>} */
|
|
364
|
+
const out = new Map();
|
|
365
|
+
const start = /^<[a-zA-Z][^\s/>]*/.exec(tag);
|
|
366
|
+
let pos = start ? start[0].length : 0;
|
|
367
|
+
for (;;) {
|
|
368
|
+
ATTR_RE.lastIndex = pos;
|
|
369
|
+
const m = ATTR_RE.exec(tag);
|
|
370
|
+
if (!m || m[0].length === 0) break;
|
|
371
|
+
pos += m[0].length;
|
|
372
|
+
const name = m[1].toLowerCase();
|
|
373
|
+
if (!out.has(name)) out.set(name, m[2] ?? m[3] ?? m[4] ?? '');
|
|
374
|
+
}
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The identity of a stylesheet `<link>`, or null when the tag is not an
|
|
380
|
+
* active one (`rel` naming `stylesheet` and not `alternate`, with an href).
|
|
381
|
+
* The URL the href resolves to against the page's own URL (`base`), plus the
|
|
382
|
+
* `media` it applies under, so `./a.css` and `/a.css` on one page are one
|
|
383
|
+
* sheet and a print sheet and a screen sheet of one file are two. The router
|
|
384
|
+
* keys its sheets the same way (owned-styles.js). With no base the href is
|
|
385
|
+
* used as written.
|
|
386
|
+
* @param {string} tag
|
|
387
|
+
* @param {string} [base]
|
|
388
|
+
* @returns {string | null}
|
|
389
|
+
*/
|
|
390
|
+
function stylesheetTagKey(tag, base) {
|
|
391
|
+
const attrs = tagAttrs(tag);
|
|
392
|
+
const rel = (attrs.get('rel') || '').toLowerCase().split(/\s+/);
|
|
393
|
+
let href = attrs.get('href');
|
|
394
|
+
if (!rel.includes('stylesheet') || rel.includes('alternate') || !href) return null;
|
|
395
|
+
if (base) { try { href = new URL(href, base).href; } catch { /* keep as written */ } }
|
|
396
|
+
const media = (attrs.get('media') || '').trim();
|
|
397
|
+
return media && media !== 'all' ? `${href} ${media}` : href;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The keys (`stylesheetTagKey`) of every stylesheet `<link>` in an HTML string.
|
|
402
|
+
* @param {string} html
|
|
403
|
+
* @param {string} [base] The page's URL, which hrefs resolve against.
|
|
404
|
+
* @returns {Set<string>}
|
|
405
|
+
*/
|
|
406
|
+
export function stylesheetKeys(html, base) {
|
|
407
|
+
const out = new Set();
|
|
408
|
+
for (const m of html.matchAll(LINK_TAG_RE)) {
|
|
409
|
+
const key = stylesheetTagKey(m[0], base);
|
|
410
|
+
if (key) out.add(key);
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Hoist the stylesheet links at the start of each nested layout's and page's
|
|
417
|
+
* output into `<head>` (#1669). The root hoist (`collectHoistedHeadTags`) only
|
|
418
|
+
* reads the leading run of the WHOLE body and stops at the first boundary
|
|
419
|
+
* marker, so a section layout's `<link rel="stylesheet">` stayed in `<body>`,
|
|
420
|
+
* where it is not reliably render-blocking (#406). This reads the leading run
|
|
421
|
+
* after EVERY `<!--wj:children:...-->` open marker instead.
|
|
422
|
+
*
|
|
423
|
+
* Only `rel="stylesheet"` links move, each tagged with `STYLE_OWNER_ATTR` set
|
|
424
|
+
* to the marker's segment. Comments and other `<link>` tags in the run stay
|
|
425
|
+
* where they are and do not end it; anything else does. A stylesheet whose
|
|
426
|
+
* href and media are already in `seen` (the head so far, or an earlier
|
|
427
|
+
* hoist) is dropped rather than hoisted twice, since one URL linked twice is
|
|
428
|
+
* two active sheets the browser matches every rule against. Every framework
|
|
429
|
+
* the WebJs router takes cues from puts a route's sheet in the head and
|
|
430
|
+
* dedupes by href. Attributes are read in order (`tagAttrs`), so a name
|
|
431
|
+
* inside another attribute's quoted value is never mistaken for one.
|
|
432
|
+
*
|
|
433
|
+
* @param {string} bodyHtml
|
|
434
|
+
* @param {Set<string>} [seen] Keys (`stylesheetKeys`) already linked in the head (mutated).
|
|
435
|
+
* @param {string} [base] The page's URL, which hrefs resolve against for the key.
|
|
436
|
+
* @returns {{ tags: string[], body: string }}
|
|
437
|
+
*/
|
|
438
|
+
export function hoistOwnedStylesheets(bodyHtml, seen = new Set(), base) {
|
|
439
|
+
if (!bodyHtml.includes(BOUNDARY_OPEN)) return { tags: [], body: bodyHtml };
|
|
440
|
+
/** @type {string[]} */
|
|
441
|
+
const tags = [];
|
|
442
|
+
let out = '';
|
|
443
|
+
let pos = 0;
|
|
444
|
+
for (;;) {
|
|
445
|
+
const open = bodyHtml.indexOf(BOUNDARY_OPEN, pos);
|
|
446
|
+
if (open < 0) break;
|
|
447
|
+
const close = bodyHtml.indexOf('-->', open);
|
|
448
|
+
if (close < 0) break;
|
|
449
|
+
const markerEnd = close + 3;
|
|
450
|
+
out += bodyHtml.slice(pos, markerEnd);
|
|
451
|
+
// The route-key is everything after the LAST ':' (the router's own split,
|
|
452
|
+
// boundaries.js), so the segment is everything before it.
|
|
453
|
+
const rest = bodyHtml.slice(open + BOUNDARY_OPEN.length, close);
|
|
454
|
+
const cut = rest.lastIndexOf(':');
|
|
455
|
+
const segment = cut > 0 ? rest.slice(0, cut) : null;
|
|
456
|
+
pos = markerEnd;
|
|
457
|
+
if (!segment) continue;
|
|
458
|
+
let m;
|
|
459
|
+
for (;;) {
|
|
460
|
+
OWNED_RUN_TOKEN_RE.lastIndex = pos;
|
|
461
|
+
m = OWNED_RUN_TOKEN_RE.exec(bodyHtml);
|
|
462
|
+
if (!m) break;
|
|
463
|
+
const token = m[1];
|
|
464
|
+
const lead = m[0].slice(0, m[0].length - token.length);
|
|
465
|
+
pos += m[0].length;
|
|
466
|
+
const key = token.startsWith('<!--') ? null : stylesheetTagKey(token, base);
|
|
467
|
+
if (!key) {
|
|
468
|
+
out += lead + token;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
out += lead;
|
|
472
|
+
if (seen.has(key)) continue;
|
|
473
|
+
seen.add(key);
|
|
474
|
+
const owner = ` ${STYLE_OWNER_ATTR}="${escapeAttr(segment)}">`;
|
|
475
|
+
tags.push(token.replace(/\s*\/?>$/, () => owner));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return { tags, body: out + bodyHtml.slice(pos) };
|
|
479
|
+
}
|
|
480
|
+
|
|
333
481
|
/**
|
|
334
482
|
* The initial-load Suspense resolver: swaps each streamed
|
|
335
483
|
* `<template data-webjs-resolve>` into its placeholder.
|
package/src/ssr/preloads.js
CHANGED
|
@@ -91,6 +91,9 @@ export function componentPreloads(usedTags, appDir, elidable, lazyFiles) {
|
|
|
91
91
|
* @param {string[]} entryFiles absolute paths of the SHIPPED page/layout modules (from `moduleUrls`)
|
|
92
92
|
* @param {string} appDir
|
|
93
93
|
* @param {Set<string>} [elidableComponents] absolute paths to skip in the walk
|
|
94
|
+
* @param {Set<string>} [serverFiles] the action / server-file index, never preloaded
|
|
95
|
+
* @param {string[]} [precomputedDeps] the transitive closure `pagePreloadHints` already walked for
|
|
96
|
+
* THESE roots and THIS skip set. Omitted, the walk runs here (a direct caller)
|
|
94
97
|
* @returns {string[]}
|
|
95
98
|
*/
|
|
96
99
|
export function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appDir, serverFiles, elidableComponents, precomputedDeps) {
|
|
@@ -172,6 +175,8 @@ export function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFile
|
|
|
172
175
|
* @param {string} appDir
|
|
173
176
|
* @param {Set<string>} [elidableComponents]
|
|
174
177
|
* @param {Set<string>} [serverFiles] the action / server-file index (`'use server'`, incl. no-`.server.` files)
|
|
178
|
+
* @param {string[]} [precomputedDeps] the transitive closure `pagePreloadHints` already walked for
|
|
179
|
+
* THESE roots and THIS skip set. Omitted, the walk runs here (a direct caller)
|
|
175
180
|
* @returns {Set<string>}
|
|
176
181
|
*/
|
|
177
182
|
export function reachedVendorSpecifiers(graph, entryFiles, componentUrls, appDir, elidableComponents, serverFiles, precomputedDeps) {
|
package/src/ssr/render.js
CHANGED
|
@@ -3,7 +3,7 @@ import { vendorPreloadTargets } from '../importmap.js';
|
|
|
3
3
|
import {
|
|
4
4
|
componentPreloads, pagePreloadHints, toUrlPath,
|
|
5
5
|
} from './preloads.js';
|
|
6
|
-
import { shippedBootModules } from './shipped-modules.js';
|
|
6
|
+
import { shippedBootModules, assembleBootModules } from './shipped-modules.js';
|
|
7
7
|
import { seedingEnabled, collectSeeds, buildSeedScript, SEED_DROP_BLOCK } from '../action-seed.js';
|
|
8
8
|
import {
|
|
9
9
|
readRevalidate,
|
|
@@ -674,34 +674,21 @@ function reportBoundaryLayoutError(err, opts, cfg = {}) {
|
|
|
674
674
|
* @returns {string[]}
|
|
675
675
|
*/
|
|
676
676
|
function boundaryModuleUrls(boundaryFile, wrapLayouts, opts) {
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
// error reporting is installed before anything can throw. A boundary page is
|
|
693
|
-
// where that matters most, so it must not be the one place it is missing.
|
|
694
|
-
// The set is never empty by the time we get here (the boundary file is
|
|
695
|
-
// always pushed above, since only pages and layouts are fed to the elision
|
|
696
|
-
// analysis, so a boundary is never inert and never import-only), which is
|
|
697
|
-
// why this needs no non-empty guard.
|
|
698
|
-
if (opts.instrumentationClient) {
|
|
699
|
-
const u = toUrlPath(opts.instrumentationClient, opts.appDir);
|
|
700
|
-
const i = urls.indexOf(u);
|
|
701
|
-
if (i !== -1) urls.splice(i, 1);
|
|
702
|
-
urls.unshift(u);
|
|
703
|
-
}
|
|
704
|
-
return urls;
|
|
677
|
+
// The happy path's own assembly (`shipped-modules.js`), un-memoized because a
|
|
678
|
+
// boundary's file list is built per render. instrumentation-client.{js,ts}
|
|
679
|
+
// (#848) rides the SAME first-import contract it has on the happy path: it
|
|
680
|
+
// runs before app modules so the app's client error reporting is installed
|
|
681
|
+
// before anything can throw, and a boundary page is where that matters most.
|
|
682
|
+
// `lazyFiles` is deliberately not passed: a boundary render has no lazy map
|
|
683
|
+
// of its own to register a diverted frontier component with, so a `static
|
|
684
|
+
// lazy` component imported by a boundary is imported eagerly, as before.
|
|
685
|
+
return assembleBootModules(boundaryFile ? [boundaryFile, ...wrapLayouts] : wrapLayouts, {
|
|
686
|
+
appDir: opts.appDir,
|
|
687
|
+
inert: opts.inertRouteModules,
|
|
688
|
+
importOnly: opts.importOnlyRouteModules,
|
|
689
|
+
lazyFiles: undefined,
|
|
690
|
+
instrumentationClient: opts.instrumentationClient,
|
|
691
|
+
}).moduleUrls;
|
|
705
692
|
}
|
|
706
693
|
|
|
707
694
|
/**
|
|
@@ -1166,6 +1153,8 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
1166
1153
|
vendorPreloads,
|
|
1167
1154
|
lazyComponents,
|
|
1168
1155
|
nonce,
|
|
1156
|
+
// The page's own URL, which hoisted stylesheet hrefs are keyed against.
|
|
1157
|
+
pageUrl: url.href,
|
|
1169
1158
|
};
|
|
1170
1159
|
// buildDocumentParts picks up a user-supplied <!doctype><html>…</html>
|
|
1171
1160
|
// shell from the body when present; otherwise auto-emits the framework
|
|
@@ -54,6 +54,24 @@ const MEMO = new WeakMap();
|
|
|
54
54
|
export function shippedBootModules(route, deps) {
|
|
55
55
|
const hit = MEMO.get(route);
|
|
56
56
|
if (hit && hit.epoch === buildEpoch()) return hit.value;
|
|
57
|
+
const value = assembleBootModules([route.file, ...route.layouts], deps);
|
|
58
|
+
MEMO.set(route, { epoch: buildEpoch(), value, hinted: null, hintedBasePath: '' });
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The assembly itself, un-memoized: the one loop behind `shippedBootModules`
|
|
64
|
+
* (a matched page and its layouts) and the boundary render in `ssr/render.js`
|
|
65
|
+
* (an error / not-found / forbidden / unauthorized file and the layouts it is
|
|
66
|
+
* wrapped in, a per-render list with no stable object to key a memo on). One
|
|
67
|
+
* function so the two cannot drift, which is how the 103 Early Hints lost the
|
|
68
|
+
* lazy-frontier rule before #1644.
|
|
69
|
+
*
|
|
70
|
+
* @param {string[]} files the route modules in import order (page or boundary first, then its layouts)
|
|
71
|
+
* @param {ShippedDeps} deps
|
|
72
|
+
* @returns {ShippedModules}
|
|
73
|
+
*/
|
|
74
|
+
export function assembleBootModules(files, deps) {
|
|
57
75
|
const { appDir, inert, importOnly, lazyFiles, instrumentationClient } = deps;
|
|
58
76
|
/** @type {string[]} */
|
|
59
77
|
const moduleUrls = [];
|
|
@@ -66,7 +84,7 @@ export function shippedBootModules(route, deps) {
|
|
|
66
84
|
if (tags) { for (const t of tags) frontierLazy[t] = u; return; }
|
|
67
85
|
if (!seen.has(u)) { seen.add(u); moduleUrls.push(u); }
|
|
68
86
|
};
|
|
69
|
-
for (const f of
|
|
87
|
+
for (const f of files) {
|
|
70
88
|
if (inert && inert.has(f)) continue;
|
|
71
89
|
const emit = importOnly && importOnly.get(f);
|
|
72
90
|
if (emit) emit.forEach(push);
|
|
@@ -81,9 +99,37 @@ export function shippedBootModules(route, deps) {
|
|
|
81
99
|
if (i !== -1) moduleUrls.splice(i, 1);
|
|
82
100
|
moduleUrls.unshift(u);
|
|
83
101
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
102
|
+
return { moduleUrls, frontierLazy };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The 103 Early Hints `Link` values for the boot's modules: a plain
|
|
107
|
+
* `rel=preload; as=script; crossorigin=anonymous` per url, NEVER
|
|
108
|
+
* `rel=modulepreload`.
|
|
109
|
+
*
|
|
110
|
+
* A module hint in a 103 arrives before the document, so it is processed
|
|
111
|
+
* before the page's `<script type="importmap">` can be parsed. Firefox treats
|
|
112
|
+
* a modulepreload as the start of module loading and then refuses the import
|
|
113
|
+
* map ("Import maps are not allowed after a module load or preload has
|
|
114
|
+
* started"), so every bare `@webjsdev/core` import failed and no script ran on
|
|
115
|
+
* a Node-served page. The HTML spec gives modulepreload no early-hint
|
|
116
|
+
* behaviour at all (its "process a link header" steps do nothing), and
|
|
117
|
+
* Chromium acts only on preload and preconnect there, so the modulepreload
|
|
118
|
+
* hint was a no-op in Chromium and fatal in Firefox.
|
|
119
|
+
*
|
|
120
|
+
* A plain preload only puts the bytes in the preload cache: it creates no
|
|
121
|
+
* module script, resolves no specifier and fetches no descendants, so it
|
|
122
|
+
* cannot run ahead of the import map on any engine. `as=script` with
|
|
123
|
+
* `crossorigin=anonymous` is a CORS fetch with same-origin credentials,
|
|
124
|
+
* which is exactly the request a module script makes, so the body's
|
|
125
|
+
* `<link rel="modulepreload">` and the boot's import reuse the response
|
|
126
|
+
* instead of fetching it again.
|
|
127
|
+
*
|
|
128
|
+
* @param {string[]} urls the hinted boot module urls (`hintedBootModules`)
|
|
129
|
+
* @returns {string[]}
|
|
130
|
+
*/
|
|
131
|
+
export function earlyHintLinks(urls) {
|
|
132
|
+
return urls.map((u) => `<${u}>; rel=preload; as=script; crossorigin=anonymous`);
|
|
87
133
|
}
|
|
88
134
|
|
|
89
135
|
/**
|
package/src/testing.d.ts
CHANGED
|
@@ -51,7 +51,7 @@ export declare function withSessionCookie(init: RequestInit | undefined, session
|
|
|
51
51
|
export declare function submitForm(
|
|
52
52
|
handle: Handle,
|
|
53
53
|
path: string,
|
|
54
|
-
fields?: Record<string, string> | URLSearchParams | FormData,
|
|
54
|
+
fields?: Record<string, string | string[]> | URLSearchParams | FormData,
|
|
55
55
|
opts?: {
|
|
56
56
|
index?: number;
|
|
57
57
|
match?: string | RegExp;
|
package/src/testing.js
CHANGED
|
@@ -221,7 +221,7 @@ export async function loginAndGetCookies(handle, credentials, opts = {}) {
|
|
|
221
221
|
*
|
|
222
222
|
* @param {Handle} handle
|
|
223
223
|
* @param {string} path the page to render AND, by default, to submit back to
|
|
224
|
-
* @param {Record<string, string> | URLSearchParams | FormData} [fields]
|
|
224
|
+
* @param {Record<string, string | string[]> | URLSearchParams | FormData} [fields]
|
|
225
225
|
* @param {{
|
|
226
226
|
* index?: number,
|
|
227
227
|
* match?: string | RegExp,
|
|
@@ -299,7 +299,12 @@ export async function submitForm(handle, path, fields = {}, opts = {}) {
|
|
|
299
299
|
const entries = fields instanceof URLSearchParams || fields instanceof FormData
|
|
300
300
|
? fields.entries()
|
|
301
301
|
: Object.entries(fields);
|
|
302
|
-
|
|
302
|
+
// An array value posts once per element, the way a checkbox group submits,
|
|
303
|
+
// so a test can exercise the `name[]` convention (#1664).
|
|
304
|
+
for (const [k, v] of entries) {
|
|
305
|
+
if (Array.isArray(v)) for (const item of v) body.append(k, String(item));
|
|
306
|
+
else body.append(k, String(v));
|
|
307
|
+
}
|
|
303
308
|
|
|
304
309
|
const init = {
|
|
305
310
|
method: 'POST',
|
package/webjs-config.schema.json
CHANGED
|
@@ -175,7 +175,7 @@
|
|
|
175
175
|
"items": { "type": "string" }
|
|
176
176
|
},
|
|
177
177
|
"regenerate": {
|
|
178
|
-
"description": "On-request build-output regeneration (#967),
|
|
178
|
+
"description": "On-request build-output regeneration (#967). In dev, each rule rebuilds an `output` that does not match its build record (.webjs/regenerate.json, #1672) before the server serves it, and `webjs regenerate` runs the same rules from dev.before / start.before / CI, instead of relying on a live watcher that can die and serve stale output. The scaffold uses this to keep the static public/tailwind.css fresh (#947) with no `tailwindcss --watch`. Read by the server (readRegenerateRules in dev-regenerate.js).",
|
|
179
179
|
"type": "array",
|
|
180
180
|
"items": {
|
|
181
181
|
"type": "object",
|
|
@@ -191,7 +191,7 @@
|
|
|
191
191
|
"type": "string"
|
|
192
192
|
},
|
|
193
193
|
"inputs": {
|
|
194
|
-
"description": "Source paths (files or directories)
|
|
194
|
+
"description": "Source paths (files or directories) the output is built from. Every build records a fingerprint of the files under them (path, size, mtime) in .webjs/regenerate.json, and the command runs again when that fingerprint, the command, or the output no longer matches the record, or the output is missing (#1672). For Tailwind: the @source dirs plus public/input.css.",
|
|
195
195
|
"type": "array",
|
|
196
196
|
"items": { "type": "string" }
|
|
197
197
|
}
|