@dom-expressions/runtime 0.50.0-next.15 → 0.50.0-next.16

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/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # dom-expressions
2
2
 
3
+ ## 0.50.0-next.16
4
+
5
+ ### Patch Changes
6
+
7
+ - f2e56fe: fix(client): re-claim a hole's live DOM region when a streamed `$df` fragment swap replaced its tracked nodes mid-hydration (solidjs/solid#2801 bug 1, pending-stream case). A Loading fallback claimed during hydration is swapped out by `$df` before the boundary resumes; insert's node bookkeeping still pointed at the removed fallback, so the content pass fabricated detached text nodes and the first post-hydration refresh appended duplicates. When the tracked nodes are disconnected while hydrating, insert now re-derives the region (parent children, or back to the matching `<!--$-->` for marker-bounded holes) so loose text re-claims positionally — elements already recovered via `_hk`.
8
+ - b431fe7: Handle module preload failures during hydration instead of hanging silently (solidjs/solid#2817 layer 3). `loadModuleAssets` drops rejected entries from the loading cache so later boundaries/navigations can retry, and the root `_assets` path in `hydrate()` falls back to a fresh client render (with a console diagnostic) instead of leaving the page permanently dead.
9
+ - 016b460: Server rendering a plain (non-template) object child now dev-warns and skips it, matching the client, instead of crashing with `Cannot read properties of undefined (reading 'fn')` (solidjs/solid#2801 bug 6)
10
+ - c40ac21: Fix style object updates so shared or constant style objects are not mutated while diffing, and nullish property values correctly remove the applied style.
11
+ - c2a542b: Fix hydration key mismatches when async holes defer past eager siblings
12
+ (solidjs/solid#2801 bug 2). Dynamic element children that can allocate
13
+ hydration ids (conditionals, component-children access, call expressions)
14
+ are now compiled with their own id scope on both generates: the dom and ssr
15
+ generates wrap the hole expression in a new `scope()` runtime helper using a
16
+ shared predicate, so marking cannot desync.
17
+
18
+ On the client, `scope(fn)` tags the accessor and `insert()` makes the outer
19
+ render effect non-transparent (its own id scope) for tagged accessors; the
20
+ inner unwrapping effect stays transparent so content ids keep a fixed depth.
21
+ On the server, `scope` (framework-provided via rxcore as `ssrScope`) reserves
22
+ one id slot at registration and evaluates the hole — including async retries
23
+ — under that reserved id with a zeroed child counter, so retry timing can no
24
+ longer shift sibling ids. The ssr generate's `orderedInsert` sibling
25
+ thunk-wrapping is removed; it is superseded by hole scopes.
26
+
27
+ Hole content ids gain one nesting level (e.g. `_hk=10` instead of `_hk=1`)
28
+ identically on both sides. rxcore implementations must provide an `ssrScope`
29
+ export and honor a `scope: true` effect option (mapped to a non-transparent
30
+ render effect).
31
+
32
+ - fa24389: Fix delegated events never reaching outer roots when a render root is
33
+ rendered inside another root's DOM (embedded widgets, microfrontends).
34
+ The first (innermost) container listener marked the event consumed for
35
+ every other root, so an outer root's delegated handlers were silently
36
+ skipped even though the native event bubbled through its elements: a
37
+ plain `addEventListener` on the same element fired while the delegated
38
+ handler didn't.
39
+
40
+ `$$EVENT_OWNER` now records the boundary of the most recent walk instead
41
+ of a consumed flag: an ancestor container whose subtree contains that boundary
42
+ resumes the handler walk from it up to its own boundary, so each root's
43
+ handlers fire exactly once, innermost-out, matching native bubbling.
44
+ `stopPropagation()` inside a nested root still suppresses outer roots (it
45
+ stops the native event before their listeners run), and hydration event
46
+ replay now relays queued events through all matching roots innermost-first
47
+ so pre- and post-hydration clicks behave identically. Apps that relied on
48
+ nested roots to isolate clicks from outer handlers should use
49
+ `stopPropagation()`, which remains the documented mechanism. Non-nested
50
+ apps are unaffected; the resume path is unreachable unless an inner root
51
+ already handled the event.
52
+
53
+ - 75b4ab2: Normalize manifest asset URL joining in `resolveAssets` (solidjs/solid#2817 layers 1-2). A non-string `_base` (e.g. a dev-manifest proxy answering every key) falls back to `/`, leading-slash `file` values no longer produce `//` URLs, and absolute/protocol-relative URLs pass through untouched — the server runtime emits sane module URLs for any reasonable manifest shape instead of relying on bundler plugins getting the contract exactly right.
54
+ - 668264f: Universal JSX now passes compile-time static host props to `createElement(tag, staticProps)` so custom renderers can configure nodes before children are inserted. Dynamic props and elements with spreads continue to use the existing `setProp` / `spread` paths.
55
+
3
56
  ## 0.50.0-next.15
4
57
 
5
58
  ### Patch Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dom-expressions/runtime",
3
3
  "description": "A Fine-Grained Runtime for Performant DOM Rendering",
4
- "version": "0.50.0-next.15",
4
+ "version": "0.50.0-next.16",
5
5
  "author": "Ryan Carniato",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -26,7 +26,7 @@
26
26
  "csstype": "^3.1",
27
27
  "seroval": "~1.5.0",
28
28
  "seroval-plugins": "~1.5.0",
29
- "@dom-expressions/babel-plugin-jsx": "0.50.0-next.15"
29
+ "@dom-expressions/babel-plugin-jsx": "0.50.0-next.16"
30
30
  },
31
31
  "scripts": {
32
32
  "test": "jest --no-cache",
package/src/client.d.ts CHANGED
@@ -23,6 +23,7 @@ export function render(
23
23
  * - `2` — the template html is wrapped; the outer tag is stripped at clone time.
24
24
  */
25
25
  export function template(html: string, flag?: 1 | 2): () => Element;
26
+ export function scope<T extends () => any>(fn: T): T;
26
27
  export function effect<T>(fn: (prev?: T) => T, effect: (value: T, prev?: T) => void): void;
27
28
  export function memo<T>(fn: () => T, equal: boolean): () => T;
28
29
  export function untrack<T>(fn: () => T): T;
package/src/client.js CHANGED
@@ -246,20 +246,45 @@ export function addEvent(node, name, handler, delegate) {
246
246
 
247
247
  export function style(node, value, prev) {
248
248
  if (!value) {
249
- if (prev) setAttribute(node, "style");
249
+ if (prev || node._$styles) {
250
+ setAttribute(node, "style");
251
+ node._$styles = undefined;
252
+ }
250
253
  return;
251
254
  }
252
255
  const nodeStyle = node.style;
253
- if (typeof value === "string") return (nodeStyle.cssText = value);
254
- typeof prev === "string" && (nodeStyle.cssText = prev = undefined);
255
- prev || (prev = {});
256
+ if (typeof value === "string") {
257
+ node._$styles = undefined;
258
+ return (nodeStyle.cssText = value);
259
+ }
260
+ if (typeof prev === "string") {
261
+ nodeStyle.cssText = "";
262
+ prev = undefined;
263
+ }
264
+ // Track declarations applied by style() itself. value/prev are user-owned
265
+ // and may be the same object on shared-effect reruns.
266
+ let applied = node._$styles;
267
+ if (!applied) {
268
+ // seed from prev so direct callers that track their own previous value
269
+ // still get removals on their first call here
270
+ applied = node._$styles = prev ? { ...prev } : {};
271
+ }
256
272
  let v, s;
273
+ for (s in applied) {
274
+ if (value[s] == null) {
275
+ nodeStyle.removeProperty(s);
276
+ delete applied[s];
277
+ }
278
+ }
279
+ // Diff against applied state so in-place mutations are detected without
280
+ // rewriting unchanged DOM styles.
257
281
  for (s in value) {
258
282
  v = value[s];
259
- if (v !== prev[s]) nodeStyle.setProperty(s, v);
260
- delete prev[s];
283
+ if (v != null && v !== applied[s]) {
284
+ nodeStyle.setProperty(s, v);
285
+ applied[s] = v;
286
+ }
261
287
  }
262
- for (s in prev) value[s] == null && nodeStyle.removeProperty(s);
263
288
  }
264
289
 
265
290
  export function setStyleProperty(node, name, value) {
@@ -311,20 +336,35 @@ export function ref(fn, element) {
311
336
  runWithOwner(null, () => applyRef(resolved, element));
312
337
  }
313
338
 
339
+ // Compiler tag for holes that can allocate hydration ids: the outer insert
340
+ // effect gets its own (non-transparent) id scope, mirroring the server's
341
+ // `scope()` owner. Keeps sibling ids stable no matter when the hole runs.
342
+ export function scope(fn) {
343
+ fn.$s = true;
344
+ return fn;
345
+ }
346
+
347
+ const SCOPE_OPTIONS = { scope: true };
348
+
349
+ // Drop the `<!--!$-->` text-hole separators the server emits so adjacent
350
+ // text nodes stay individually claimable; the array is compacted in place.
351
+ function stripTextSeparators(nodes) {
352
+ let j = 0;
353
+ for (let i = 0; i < nodes.length; i++) {
354
+ if (nodes[i].nodeType === 8 && nodes[i].nodeValue === "!$") nodes[i].remove();
355
+ else nodes[j++] = nodes[i];
356
+ }
357
+ nodes.length = j;
358
+ return nodes;
359
+ }
360
+
314
361
  export function insert(parent, accessor, marker, initial, options) {
315
362
  const multi = marker !== undefined;
316
363
  const host = options && options.host;
317
364
  if (multi && !initial) initial = [];
318
365
  if (isHydrating(parent)) {
319
366
  if (!multi && initial === undefined && parent) initial = [...parent.childNodes];
320
- if (Array.isArray(initial)) {
321
- let j = 0;
322
- for (let i = 0; i < initial.length; i++) {
323
- if (initial[i].nodeType === 8 && initial[i].nodeValue === "!$") initial[i].remove();
324
- else initial[j++] = initial[i];
325
- }
326
- initial.length = j;
327
- }
367
+ if (Array.isArray(initial)) stripTextSeparators(initial);
328
368
  }
329
369
  if (typeof accessor !== "function") {
330
370
  accessor = normalize(accessor, initial, multi, true);
@@ -340,12 +380,44 @@ export function insert(parent, accessor, marker, initial, options) {
340
380
  initial = [placeholder];
341
381
  }
342
382
  let current = initial;
383
+ // A streamed `$df` fragment swap replaces a hole's region out from under
384
+ // its bookkeeping (Loading fallback claimed during hydration, settled
385
+ // content swapped in later). When the tracked nodes are gone mid-hydration,
386
+ // re-claim the live region so the content pass can match loose text
387
+ // positionally — elements recover through the registry, text only has
388
+ // position. The region is `parent`'s children, or for marker-bounded holes
389
+ // the nodes back to the matching `<!--$-->` start marker.
390
+ function reclaimSwappedRegion() {
391
+ if (!sharedConfig.hydrating || !current || !parent.isConnected) return;
392
+ const first = Array.isArray(current) ? current[0] : current;
393
+ if (!first || !first.nodeType || first.isConnected) return;
394
+ let nodes;
395
+ if (marker) {
396
+ nodes = [];
397
+ let node = marker.previousSibling,
398
+ depth = 0;
399
+ while (node) {
400
+ if (node.nodeType === 8) {
401
+ const v = node.nodeValue;
402
+ if (v === "/") depth++;
403
+ else if (v === "$") {
404
+ if (depth === 0) break;
405
+ depth--;
406
+ }
407
+ }
408
+ nodes.unshift(node);
409
+ node = node.previousSibling;
410
+ }
411
+ } else nodes = [...parent.childNodes];
412
+ current = stripTextSeparators(nodes);
413
+ }
343
414
  effect(
344
415
  prev => {
416
+ reclaimSwappedRegion();
345
417
  const value = normalize(accessor(), current, multi, true);
346
418
  if (typeof value !== "function") return value;
347
419
  effect(
348
- () => normalize(value, current, multi),
420
+ () => (reclaimSwappedRegion(), normalize(value, current, multi)),
349
421
  inner => {
350
422
  insertExpression(parent, inner, current, marker);
351
423
  current = inner;
@@ -363,7 +435,9 @@ export function insert(parent, accessor, marker, initial, options) {
363
435
  current = value;
364
436
  host && tagHost(current, host);
365
437
  },
366
- options
438
+ // Only the OUTER effect takes the scope — the inner unwrapping effect
439
+ // stays transparent so content ids keep a fixed depth per hole.
440
+ accessor.$s ? (options ? { ...options, scope: true } : SCOPE_OPTIONS) : options
367
441
  );
368
442
  }
369
443
 
@@ -394,9 +468,16 @@ function loadModuleAssets(mapping) {
394
468
  if (hy.modules[moduleUrl]) continue;
395
469
  const entryUrl = mapping[moduleUrl];
396
470
  if (!hy.loading[moduleUrl]) {
397
- hy.loading[moduleUrl] = import(/* @vite-ignore */ entryUrl).then(mod => {
398
- hy.modules[moduleUrl] = mod;
399
- });
471
+ hy.loading[moduleUrl] = import(/* @vite-ignore */ entryUrl).then(
472
+ mod => {
473
+ hy.modules[moduleUrl] = mod;
474
+ },
475
+ err => {
476
+ // drop the rejected entry so a later boundary/navigation can retry
477
+ delete hy.loading[moduleUrl];
478
+ throw err;
479
+ }
480
+ );
400
481
  }
401
482
  pending.push(hy.loading[moduleUrl]);
402
483
  }
@@ -460,8 +541,15 @@ export function hydrate(code, element, options = {}) {
460
541
  sharedConfig.hydrating = false;
461
542
  }
462
543
  },
463
- () => {
544
+ err => {
545
+ // A chunk failed to preload; hydration can't claim the server DOM
546
+ // (lazy components have no module). Fall back to a fresh client
547
+ // render replacing the server markup — lazy's own import() gets to
548
+ // retry through normal channels — instead of a silently dead page.
549
+ console.error("Hydration module preload failed, falling back to client render:", err);
464
550
  sharedConfig.hydrating = false;
551
+ sharedConfig.registry = undefined;
552
+ disposer = render(code, element, [...element.childNodes], options);
465
553
  }
466
554
  );
467
555
  return () => disposer && disposer();
@@ -597,14 +685,34 @@ export function runHydrationEvents() {
597
685
  const [el, e] = events[0];
598
686
  if (!completed.has(el)) return;
599
687
  events.shift();
600
- let match;
688
+ let matchContainer, matchState, matchDistance, matches;
601
689
  for (const [container, state] of delegatedContainers) {
602
690
  if (!state.handlers.has(e.type)) continue;
603
691
  const entry = findOwner(e.target, state);
604
- if (entry && (!match || entry.distance < match.distance))
605
- match = { container, state, distance: entry.distance };
692
+ if (!entry) continue;
693
+ if (matchContainer) {
694
+ if (!matches)
695
+ matches = [
696
+ {
697
+ container: matchContainer,
698
+ state: matchState,
699
+ distance: matchDistance
700
+ }
701
+ ];
702
+ matches.push({ container, state, distance: entry.distance });
703
+ } else {
704
+ matchContainer = container;
705
+ matchState = state;
706
+ matchDistance = entry.distance;
707
+ }
606
708
  }
607
- if (match) eventHandler(e, match.container, match.state);
709
+ if (matches) {
710
+ // Replay innermost-first so queued hydration events follow the same
711
+ // root-boundary handoff as live native bubbling.
712
+ matches.sort((a, b) => a.distance - b.distance);
713
+ for (let i = 0; i < matches.length; i++)
714
+ eventHandler(e, matches[i].container, matches[i].state);
715
+ } else if (matchContainer) eventHandler(e, matchContainer, matchState);
608
716
  }
609
717
  if (sharedConfig.done) {
610
718
  sharedConfig.events = _$HY.events = null;
@@ -696,7 +804,15 @@ function eventHandler(e, container, state) {
696
804
  if (sharedConfig.registry && sharedConfig.events) {
697
805
  if (sharedConfig.events.find(([el, ev]) => ev === e)) return;
698
806
  }
699
- if (e[$$EVENT_OWNER]) return;
807
+ const prev = e[$$EVENT_OWNER];
808
+ let resumeNode;
809
+ if (prev) {
810
+ // An inner root already walked its segment. Ancestor roots resume from
811
+ // that boundary; unrelated/shared containers must not see the event as
812
+ // theirs. Native stopPropagation still prevents this listener from running.
813
+ if (prev === true || prev === container || !container.contains(prev)) return;
814
+ resumeNode = prev;
815
+ }
700
816
  const owner =
701
817
  state &&
702
818
  (state.owners.size === 1 && state.owners.has(container)
@@ -705,7 +821,7 @@ function eventHandler(e, container, state) {
705
821
  if (state && !owner) return;
706
822
  e[$$EVENT_OWNER] = owner || true;
707
823
 
708
- let node = e.target;
824
+ let node = resumeNode || e.target;
709
825
  const key = `$$${e.type}`;
710
826
  const oriTarget = e.target;
711
827
  const boundary = owner || container || e.currentTarget;
@@ -742,7 +858,13 @@ function eventHandler(e, container, state) {
742
858
  return node || boundary || document;
743
859
  }
744
860
  });
745
- if (e.composedPath) {
861
+ if (resumeNode) {
862
+ // If the boundary was the target, the inner walk already fired it.
863
+ // Resume above it so boundary handlers do not run twice.
864
+ if (resumeNode === e.target)
865
+ node = resumeNode._$host || resumeNode.parentNode || resumeNode.host;
866
+ if (node && node !== boundary) walkUpTree();
867
+ } else if (e.composedPath) {
746
868
  const path = e.composedPath();
747
869
  if (path.length) {
748
870
  retarget(path[0]);
package/src/server.d.ts CHANGED
@@ -75,6 +75,7 @@ export function ssrStyle(value: string | { [k: string]: string }): string;
75
75
  export function ssrStyleProperty(name: string, value: any): string;
76
76
  export function ssrAttribute(key: string, value: any): string;
77
77
  export function ssrGroup<T extends () => any[]>(fn: T, n: number): T;
78
+ export function scope<T>(fn: () => T): () => unknown;
78
79
  export function ssrHydrationKey(): string;
79
80
  export function resolveSSRNode(node: any, result?: any, top?: boolean): any;
80
81
  export function escape(s: any, attr?: boolean): any;
package/src/server.js CHANGED
@@ -7,6 +7,10 @@ import { createSerializer, getLocalHeaderScript } from "./serializer";
7
7
  // core, and a local copy here drifts from them (it resolved function sources
8
8
  // for key enumeration only, dropping their values in SSR output).
9
9
  export { createComponent, effect, memo, untrack, mergeProps } from "rxcore";
10
+ // Hole owner scope (`_$scope(...)` in compiled ssr output) — owner-creating
11
+ // wrapper for deferred child holes that can allocate hydration ids. The
12
+ // framework owns the implementation (owner creation + per-attempt reset).
13
+ export { ssrScope as scope } from "rxcore";
10
14
  export { getOwner };
11
15
 
12
16
  export {
@@ -23,9 +27,22 @@ export {
23
27
 
24
28
  // ---- Asset Manifest ----
25
29
 
30
+ // Join defensively rather than trusting the manifest's shape: dev manifests
31
+ // have answered `_base` with non-strings and emitted `file` values with a
32
+ // leading slash (solidjs/solid#2817 layers 1-2). Normalizing here keeps the
33
+ // emitted URLs sane for any reasonable manifest instead of playing contract
34
+ // ping-pong with bundler plugins.
35
+ function joinAssetPath(base, file) {
36
+ // absolute (`https://cdn/x.js`) and protocol-relative (`//cdn/x.js`) pass through
37
+ if (/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(file)) return file;
38
+ if (typeof base !== "string" || !base) base = "/";
39
+ if (base[base.length - 1] !== "/") base += "/";
40
+ return base + (file[0] === "/" ? file.slice(1) : file);
41
+ }
42
+
26
43
  function resolveAssets(moduleUrl, manifest) {
27
44
  if (!manifest) return null;
28
- const base = manifest._base || "/";
45
+ const base = manifest._base;
29
46
  const entry = manifest[moduleUrl];
30
47
  if (!entry) return null;
31
48
  const css = [];
@@ -36,8 +53,8 @@ function resolveAssets(moduleUrl, manifest) {
36
53
  visited.add(key);
37
54
  const e = manifest[key];
38
55
  if (!e) return;
39
- js.push(base + e.file);
40
- if (e.css) for (let i = 0; i < e.css.length; i++) css.push(base + e.css[i]);
56
+ js.push(joinAssetPath(base, e.file));
57
+ if (e.css) for (let i = 0; i < e.css.length; i++) css.push(joinAssetPath(base, e.css[i]));
41
58
  if (e.imports) for (let i = 0; i < e.imports.length; i++) walk(e.imports[i]);
42
59
  };
43
60
  walk(moduleUrl);
@@ -1178,6 +1195,12 @@ function tryResolveString(node) {
1178
1195
  return s;
1179
1196
  }
1180
1197
  if (node.h && node.h.length > 0) return { merge: node };
1198
+ if (node.t === undefined) {
1199
+ // Not a template object — mirror the client's dev warn-and-skip
1200
+ // instead of crashing downstream on a malformed template shape.
1201
+ if ("_DX_DEV_") console.warn(`Unrecognized value. Skipped inserting`, node);
1202
+ return "";
1203
+ }
1181
1204
  return Array.isArray(node.t) ? node.t[0] : node.t;
1182
1205
  }
1183
1206
  if (t === "function") {
@@ -1225,7 +1248,9 @@ function resolveSSRNode(
1225
1248
  result.h.push(...node.h);
1226
1249
  result.p.push(...node.p);
1227
1250
  }
1228
- } else result.t[result.t.length - 1] += node.t;
1251
+ } else if (node.t !== undefined) {
1252
+ result.t[result.t.length - 1] += node.t;
1253
+ } else if ("_DX_DEV_") console.warn(`Unrecognized value. Skipped inserting`, node);
1229
1254
  } else if (t === "function") {
1230
1255
  try {
1231
1256
  resolveSSRNode(node(), result);
@@ -1,5 +1,5 @@
1
1
  export interface RendererOptions<NodeType> {
2
- createElement(tag: string): NodeType;
2
+ createElement(tag: string, staticProps?: Record<string, unknown>): NodeType;
3
3
  createTextNode(value: string): NodeType;
4
4
  createSentinel?(): NodeType;
5
5
  replaceText(textNode: NodeType, value: string): void;
@@ -18,7 +18,7 @@ export interface Renderer<NodeType> {
18
18
  effect<T>(fn: (prev?: T) => T, effect: (value: T, prev?: T) => void): void;
19
19
  memo<T>(fn: () => T, equal: boolean): () => T;
20
20
  createComponent<T>(Comp: (props: T) => NodeType, props: T): NodeType;
21
- createElement(tag: string): NodeType;
21
+ createElement(tag: string, staticProps?: Record<string, unknown>): NodeType;
22
22
  createTextNode(value: string): NodeType;
23
23
  insertNode(parent: NodeType, node: NodeType, anchor?: NodeType): void;
24
24
  insert<T>(parent: any, accessor: (() => T) | T, marker?: any | null, initial?: any): NodeType;