@webjsdev/server 0.8.21 → 0.8.23
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/package.json +1 -1
- package/src/component-elision.js +61 -0
- package/src/ssr.js +25 -9
package/package.json
CHANGED
package/src/component-elision.js
CHANGED
|
@@ -111,6 +111,9 @@ export const CLIENT_LIFECYCLE_HOOKS = [
|
|
|
111
111
|
'firstUpdated',
|
|
112
112
|
'getUpdateComplete',
|
|
113
113
|
'renderError',
|
|
114
|
+
// The async-render re-fetch loading UI (#469): defining it means the
|
|
115
|
+
// component suspends and re-renders on the client, so it must ship.
|
|
116
|
+
'renderFallback',
|
|
114
117
|
];
|
|
115
118
|
|
|
116
119
|
/**
|
|
@@ -122,6 +125,24 @@ export const CLIENT_LIFECYCLE_HOOKS = [
|
|
|
122
125
|
*/
|
|
123
126
|
export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'requestUpdate'];
|
|
124
127
|
|
|
128
|
+
/**
|
|
129
|
+
* A bare `async render()` is NOT a standalone ship signal (#474). Its SSR
|
|
130
|
+
* pass bakes the resolved data into the first paint, so a light-DOM async
|
|
131
|
+
* component with no OTHER client signal renders identical HTML with or
|
|
132
|
+
* without its JS and is elidable like any display-only component, saving a
|
|
133
|
+
* module download plus a redundant on-hydration re-fetch. It ships only when
|
|
134
|
+
* it ALSO carries an independent signal: an `@event`, a non-`state` reactive
|
|
135
|
+
* prop, a reactive import, a lifecycle hook (`renderFallback()` included, via
|
|
136
|
+
* CLIENT_LIFECYCLE_HOOKS), a `<slot>`, `static shadow = true`, `static refresh
|
|
137
|
+
* = true`, cross-module observation, or a transitively-reachable interactive
|
|
138
|
+
* dep / child (the fixpoint's import + render rules). The two genuinely new
|
|
139
|
+
* carve-outs, both handled per-class below, are `static shadow` (Declarative
|
|
140
|
+
* Shadow DOM only attaches during HTML parsing, so a streamed / soft-navigated
|
|
141
|
+
* shadow component needs its module to re-run `attachShadow`) and the explicit
|
|
142
|
+
* `static refresh = true` opt-in (keeps the stale-while-revalidate on-load
|
|
143
|
+
* re-fetch that eliding would drop).
|
|
144
|
+
*/
|
|
145
|
+
|
|
125
146
|
/** Match a `@event=${...}` binding inside a template (unquoted per invariant 4). */
|
|
126
147
|
const EVENT_BINDING_RE = /@[A-Za-z][\w-]*\s*=\s*\$\{/;
|
|
127
148
|
|
|
@@ -396,6 +417,23 @@ export function analyzeComponentSource(src) {
|
|
|
396
417
|
}
|
|
397
418
|
|
|
398
419
|
for (const body of bodies) {
|
|
420
|
+
// Shadow DOM always ships (#474). Declarative Shadow DOM attaches ONLY
|
|
421
|
+
// during HTML parsing; an elided component that arrives via a client DOM
|
|
422
|
+
// insertion (a soft-nav swap, a streamed <webjs-suspense> boundary's
|
|
423
|
+
// replaceWith) would never re-attach its shadow root, because eliding
|
|
424
|
+
// drops the module that runs `attachShadow`. The analyser is context-free
|
|
425
|
+
// (it cannot tell whether a given component will be streamed or
|
|
426
|
+
// navigated to), so any shadow component is shipped.
|
|
427
|
+
if (declaresStaticTrue(body, 'shadow')) {
|
|
428
|
+
return { interactive: true, reason: 'declares static shadow (DSD must re-attach on a client swap)' };
|
|
429
|
+
}
|
|
430
|
+
// `static refresh = true` is the explicit opt-in to ship a bare async
|
|
431
|
+
// component so its stale-while-revalidate refresh runs ~after SSR; eliding
|
|
432
|
+
// a bare async component drops that on-load re-fetch (moot for request-
|
|
433
|
+
// stable data, the default), so an author who wants fresh-on-load opts in.
|
|
434
|
+
if (declaresStaticTrue(body, 'refresh')) {
|
|
435
|
+
return { interactive: true, reason: 'declares static refresh = true (keeps the on-load re-fetch)' };
|
|
436
|
+
}
|
|
399
437
|
for (const hook of CLIENT_LIFECYCLE_HOOKS) {
|
|
400
438
|
// A client lifecycle hook as a method (`hook(`) OR as an arrow class
|
|
401
439
|
// field (`hook = () =>`), which shadows the prototype method and still
|
|
@@ -420,6 +458,29 @@ export function analyzeComponentSource(src) {
|
|
|
420
458
|
return { interactive: false, reason: null };
|
|
421
459
|
}
|
|
422
460
|
|
|
461
|
+
/**
|
|
462
|
+
* True if a class body declares a `static <name>` whose value is not the
|
|
463
|
+
* literal `false`. Backs the `static shadow` (DSD-on-client-swap) and
|
|
464
|
+
* `static refresh = true` (ship-the-async-refetch opt-in) ship signals (#474).
|
|
465
|
+
*
|
|
466
|
+
* Conservative on anything it cannot evaluate: a getter form
|
|
467
|
+
* (`static get <name>()`) ships, and a non-`false` value of any shape
|
|
468
|
+
* (`true`, a variable, an expression) ships. Only an absent declaration or a
|
|
469
|
+
* literal `= false` is cleared as inert, matching the light-DOM /
|
|
470
|
+
* no-refresh defaults.
|
|
471
|
+
*
|
|
472
|
+
* @param {string} classBody redacted class body
|
|
473
|
+
* @param {string} name the static field name (`shadow` / `refresh`)
|
|
474
|
+
* @returns {boolean}
|
|
475
|
+
*/
|
|
476
|
+
function declaresStaticTrue(classBody, name) {
|
|
477
|
+
// A getter cannot be evaluated statically; ship.
|
|
478
|
+
if (new RegExp(`\\bstatic\\s+get\\s+${name}\\b`).test(classBody)) return true;
|
|
479
|
+
const m = new RegExp(`\\bstatic\\s+${name}\\b\\s*=\\s*([^;\\n]*)`).exec(classBody);
|
|
480
|
+
if (!m) return false; // not declared: the default (light DOM / no refresh)
|
|
481
|
+
return !/^false\b/.test(m[1].trim()); // `= false` is inert; anything else ships
|
|
482
|
+
}
|
|
483
|
+
|
|
423
484
|
/**
|
|
424
485
|
* @param {string} src
|
|
425
486
|
* @returns {string | null} the offending imported name, or null
|
package/src/ssr.js
CHANGED
|
@@ -82,7 +82,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
82
82
|
const metadata = await collectMetadata(route, ctx, opts.dev);
|
|
83
83
|
|
|
84
84
|
try {
|
|
85
|
-
const suspenseCtx = { pending: [], nextId: 1, usedComponents: new Set() };
|
|
85
|
+
const suspenseCtx = { pending: [], nextId: 1, usedComponents: new Set(), dev: opts.dev };
|
|
86
86
|
// Parse the partial-nav "have" header from the client. The header
|
|
87
87
|
// lists comma-separated marker paths the client already has rendered
|
|
88
88
|
// in its DOM. The server walks the target route's layout chain
|
|
@@ -183,6 +183,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
183
183
|
url,
|
|
184
184
|
metadata,
|
|
185
185
|
nonce,
|
|
186
|
+
opts.dev,
|
|
186
187
|
);
|
|
187
188
|
// Server HTML cache write (#241). The page opted in via `revalidate`, so
|
|
188
189
|
// FLAG this candidate for the response funnel rather than writing here: the
|
|
@@ -233,7 +234,7 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
233
234
|
const mod = await loadModule(route.errors[i], opts.dev);
|
|
234
235
|
if (!mod.default) continue;
|
|
235
236
|
const tree = await mod.default({ ...ctx, error: err });
|
|
236
|
-
const body = await renderToString(tree);
|
|
237
|
+
const body = await renderToString(tree, { ssr: true, dev: opts.dev });
|
|
237
238
|
const moduleUrls = [route.file, ...route.layouts].map((f) => toUrlPath(f, opts.appDir));
|
|
238
239
|
const html = wrapInDocument(body, { metadata, moduleUrls, dev: opts.dev, nonce: errNonce });
|
|
239
240
|
return htmlResponse(html, 500, opts.req, url);
|
|
@@ -333,7 +334,7 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
|
|
|
333
334
|
if (notFoundFile) {
|
|
334
335
|
try {
|
|
335
336
|
const mod = await loadModule(notFoundFile, opts.dev);
|
|
336
|
-
if (mod.default) body = await renderToString(await mod.default({}));
|
|
337
|
+
if (mod.default) body = await renderToString(await mod.default({}), { ssr: true, dev: opts.dev });
|
|
337
338
|
} catch (e) {
|
|
338
339
|
body = `<h1>404: Not found</h1><pre>${escapeHtml(String(e))}</pre>`;
|
|
339
340
|
}
|
|
@@ -430,7 +431,7 @@ async function loadingTemplates(route, ctx, dev) {
|
|
|
430
431
|
const mod = await loadModule(file, dev);
|
|
431
432
|
if (!mod.default) continue;
|
|
432
433
|
const tree = await mod.default(ctx);
|
|
433
|
-
const html = await renderToString(tree, { ssr: true });
|
|
434
|
+
const html = await renderToString(tree, { ssr: true, dev });
|
|
434
435
|
const segmentPath = loadingSegmentPath(file);
|
|
435
436
|
parts.push(`<template id="wj-loading:${segmentPath}">${html}</template>`);
|
|
436
437
|
} catch { /* skip broken loading file */ }
|
|
@@ -1444,15 +1445,18 @@ function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appD
|
|
|
1444
1445
|
* Build a streaming Response. Degrades to a single-flush response when
|
|
1445
1446
|
* there are no pending Suspense boundaries.
|
|
1446
1447
|
*
|
|
1447
|
-
* @param {string}
|
|
1448
|
+
* @param {string} prefix
|
|
1448
1449
|
* @param {string} bodyHtml
|
|
1450
|
+
* @param {string} closer
|
|
1449
1451
|
* @param {{ pending: {id: string, promise: Promise<unknown>}[], nextId: number }} ctx
|
|
1450
1452
|
* @param {number} status
|
|
1451
1453
|
* @param {Request | undefined} req
|
|
1452
1454
|
* @param {URL | undefined} url
|
|
1453
1455
|
* @param {Record<string, any>} [metadata]
|
|
1456
|
+
* @param {string} [nonce]
|
|
1457
|
+
* @param {boolean} [dev] dev surfaces a streamed-boundary error message; prod stays silent
|
|
1454
1458
|
*/
|
|
1455
|
-
function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url, metadata, nonce) {
|
|
1459
|
+
function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url, metadata, nonce, dev) {
|
|
1456
1460
|
const encoder = new TextEncoder();
|
|
1457
1461
|
const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
|
|
1458
1462
|
// Default: no caching. Pages are dynamic by default: the developer
|
|
@@ -1481,7 +1485,14 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
|
|
|
1481
1485
|
|
|
1482
1486
|
const stream = new ReadableStream({
|
|
1483
1487
|
async start(controller) {
|
|
1484
|
-
|
|
1488
|
+
// Flush the shell (prefix + body with fallbacks) immediately, followed by
|
|
1489
|
+
// a shell-ready sentinel comment IN THE SAME chunk. The resolved boundary
|
|
1490
|
+
// templates and the `</body></html>` closer are emitted LATER (after the
|
|
1491
|
+
// slow data settles), so without this sentinel a streaming soft-nav client
|
|
1492
|
+
// could not tell "shell complete, awaiting the slow boundary" from "shell
|
|
1493
|
+
// still arriving" and would block its progressive swap until the slow
|
|
1494
|
+
// boundary (#473). The comment is inert for the native initial-load parse.
|
|
1495
|
+
controller.enqueue(encoder.encode(prefix + bodyHtml + '<!--wj-stream-shell-->'));
|
|
1485
1496
|
try {
|
|
1486
1497
|
// Loop: resolve all currently-pending promises in parallel; nested
|
|
1487
1498
|
// Suspense inside resolved content adds more pending entries.
|
|
@@ -1492,14 +1503,19 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
|
|
|
1492
1503
|
batch.map(async (p) => {
|
|
1493
1504
|
try {
|
|
1494
1505
|
const resolved = await p.promise;
|
|
1495
|
-
const sub = { pending: [], nextId: ctx.nextId };
|
|
1506
|
+
const sub = { pending: [], nextId: ctx.nextId, dev: ctx.dev };
|
|
1496
1507
|
const html = await renderToString(resolved, { ssr: true, suspenseCtx: sub });
|
|
1497
1508
|
ctx.nextId = sub.nextId;
|
|
1498
1509
|
for (const n of sub.pending) ctx.pending.push(n);
|
|
1499
1510
|
return { id: p.id, html };
|
|
1500
1511
|
} catch (e) {
|
|
1512
|
+
// Match the SSR error-isolation policy (render-server.js's
|
|
1513
|
+
// defaultSSRErrorTemplate): dev surfaces the message so the
|
|
1514
|
+
// failure is obvious, prod stays SILENT so no internal detail
|
|
1515
|
+
// (a DB error, a stack-derived path) leaks to the client (#478).
|
|
1501
1516
|
const msg = e instanceof Error ? e.message : String(e);
|
|
1502
|
-
|
|
1517
|
+
const html = dev ? `<p>error: ${escapeHtml(msg)}</p>` : '';
|
|
1518
|
+
return { id: p.id, html };
|
|
1503
1519
|
}
|
|
1504
1520
|
})
|
|
1505
1521
|
);
|