@webjsdev/server 0.8.20 → 0.8.22

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.
@@ -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
@@ -262,13 +262,22 @@ async function parseFile(file, appDir, graph, seen) {
262
262
  // Mask of `src` with all string / template-literal / comment / regex
263
263
  // CONTENT blanked to spaces (positions preserved). Used to reject an
264
264
  // `import '…'` / `export … from '…'` that appears as TEXT inside a
265
- // template literal (e.g. example code shown in a `<pre>` inside an
266
- // `html\`\`` template, as the docs site does) rather than as a real
267
- // statement. We still read the specifier from the RAW `src` (the
268
- // specifier is itself a string, blanked in the mask), and only consult
269
- // the mask to confirm the `import` / `export` KEYWORD survived
270
- // redaction, i.e. sits in code position and not inside a literal.
271
- const masked = redactStringsAndTemplates(src);
265
+ // literal (e.g. example code shown in a `<pre>` inside an `html\`\``
266
+ // template, as the docs site does, OR a code-example written as a plain
267
+ // quoted string) rather than as a real statement. We still read the
268
+ // specifier from the RAW `src` (the specifier is itself a string,
269
+ // blanked in the mask), and only consult the mask to confirm the
270
+ // `import` / `export` KEYWORD survived redaction, i.e. sits in code
271
+ // position and not inside a literal.
272
+ //
273
+ // `blankStrings: true` is load-bearing: the default mask keeps PLAIN
274
+ // string + verbatim-template bodies verbatim (so `register('tag')` stays
275
+ // readable for other scanners), which would leave an `import` written
276
+ // inside a plain string looking like a real keyword and create a phantom
277
+ // graph edge to whatever path that string names. Since this caller only
278
+ // checks keyword-in-code-position, blank every literal body so a
279
+ // code-example `import` string never becomes an edge.
280
+ const masked = redactStringsAndTemplates(src, true);
272
281
  const deps = new Set();
273
282
  for (const re of [IMPORT_RE, EXPORT_FROM_RE]) {
274
283
  for (const m of src.matchAll(re)) {
@@ -196,9 +196,11 @@ export async function runPageAction(route, params, url, loaded, req, ssrOpts) {
196
196
  } catch (err) {
197
197
  if (isRedirect(err)) {
198
198
  const e = /** @type any */ (err);
199
- // A thrown redirect from an action is honored as the page render does.
200
- // Use the action's chosen status (307/308) so an explicit redirect()
201
- // keeps its semantics; PRG (303) is the SUCCESS-result path below.
199
+ // A thrown redirect from an action (a POST) defaults to 307 Temporary
200
+ // Redirect, which is method-preserving so the action's intent survives
201
+ // the bounce; an explicit `redirect(url, status)` overrides it. This is
202
+ // deliberately NOT the GET gate's 302 default (see ssr.js). PRG (303) is
203
+ // the SUCCESS-result path below.
202
204
  return new Response(null, { status: e.status || 307, headers: { location: e.url } });
203
205
  }
204
206
  if (isNotFound(err)) {
package/src/ssr.js CHANGED
@@ -198,7 +198,12 @@ export async function ssrPage(route, params, url, opts) {
198
198
  } catch (err) {
199
199
  if (isRedirect(err)) {
200
200
  const e = /** @type any */ (err);
201
- return new Response(null, { status: e.status || 307, headers: { location: e.url } });
201
+ // A redirect thrown during a GET page/layout render is a GET-to-GET
202
+ // navigation (an auth bounce, a gate). 302 Found is the conventional
203
+ // code there, so it is the default when the caller did not pick one. An
204
+ // explicit `redirect(url, status)` overrides it. (Action redirects, a
205
+ // POST, default to 307 in page-action.js so the method is preserved.)
206
+ return new Response(null, { status: e.status || 302, headers: { location: e.url } });
202
207
  }
203
208
  if (isNotFound(err)) {
204
209
  const html = await ssrNotFoundHtml(null, opts);
@@ -1476,7 +1481,14 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
1476
1481
 
1477
1482
  const stream = new ReadableStream({
1478
1483
  async start(controller) {
1479
- controller.enqueue(encoder.encode(prefix + bodyHtml));
1484
+ // Flush the shell (prefix + body with fallbacks) immediately, followed by
1485
+ // a shell-ready sentinel comment IN THE SAME chunk. The resolved boundary
1486
+ // templates and the `</body></html>` closer are emitted LATER (after the
1487
+ // slow data settles), so without this sentinel a streaming soft-nav client
1488
+ // could not tell "shell complete, awaiting the slow boundary" from "shell
1489
+ // still arriving" and would block its progressive swap until the slow
1490
+ // boundary (#473). The comment is inert for the native initial-load parse.
1491
+ controller.enqueue(encoder.encode(prefix + bodyHtml + '<!--wj-stream-shell-->'));
1480
1492
  try {
1481
1493
  // Loop: resolve all currently-pending promises in parallel; nested
1482
1494
  // Suspense inside resolved content adds more pending entries.