@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.21",
3
+ "version": "0.8.23",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
@@ -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} headHtml
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
- controller.enqueue(encoder.encode(prefix + bodyHtml));
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
- return { id: p.id, html: `<p>error: ${escapeHtml(msg)}</p>` };
1517
+ const html = dev ? `<p>error: ${escapeHtml(msg)}</p>` : '';
1518
+ return { id: p.id, html };
1503
1519
  }
1504
1520
  })
1505
1521
  );