@webjsdev/server 0.8.21 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.21",
3
+ "version": "0.8.22",
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
@@ -1481,7 +1481,14 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
1481
1481
 
1482
1482
  const stream = new ReadableStream({
1483
1483
  async start(controller) {
1484
- 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-->'));
1485
1492
  try {
1486
1493
  // Loop: resolve all currently-pending promises in parallel; nested
1487
1494
  // Suspense inside resolved content adds more pending entries.