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

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,30 @@
1
1
  # dom-expressions
2
2
 
3
+ ## 0.50.0-next.17
4
+
5
+ ### Patch Changes
6
+
7
+ - ef2864e: Add a client-side asset registry and an `inline-style` server asset type, closing the CSS lifecycle loop for SSR'd applications.
8
+
9
+ **`acquireAsset(descriptor)` (client)** — ref-counted ownership of shared document assets. Consumers (routers, lazy wrappers, metadata components) acquire an asset when content that needs it mounts and call the returned release function on cleanup:
10
+
11
+ ```js
12
+ const release = acquireAsset({ type: "style", href: "/assets/route.css" });
13
+ // … on unmount:
14
+ release();
15
+ ```
16
+
17
+ - First acquire creates the element in `<head>` — or adopts an SSR/stream-emitted one (links matched by `href`, inline styles by their `data-asset` id) instead of duplicating it.
18
+ - Last release removes the element after a short grace period, so release/re-acquire cycles during route transitions keep the live stylesheet instead of flashing unstyled content.
19
+ - Supported descriptors: `{ type: "style", href, attrs? }`, `{ type: "inline-style", id, content?, attrs? }`, `{ type: "module", href }`.
20
+ - Additionally, `{ policy: "exclusive", key, value, get, set }` provides singleton-slot semantics (last-writer-wins with restore-on-release) as the substrate for future `<Title>`/`<Meta>`-style metadata components.
21
+
22
+ **`registerAsset("inline-style", { id, content, attrs? })` (server)** — registers CSS by content rather than URL, for styles that have no `.css` file to link (dev-mode CSS collected from the bundler's module graph, critical CSS). Entries dedupe by `id` and emit as `<style data-asset="…">` tags: in `<head>` for anything registered before the shell flushes, inline in the stream for late boundary styles. Extra `attrs` pass through to the tag (e.g. `data-vite-dev-id` so Vite's HMR client adopts the server-rendered style in dev). Unlike stylesheet links, inline styles never gate streamed fragment reveal — they are applied as soon as they are parsed.
23
+
24
+ - 241ff76: Fix a spread element with dynamic props being left unclaimed on hydration. `mergeProps` with a function source creates a memo, which consumes a hydration child id. The ssr generate evaluated `mergeProps(...)` in `ssrElement`'s argument position — before the element's own hydration key was allocated — while the client claims the element (`getNextElement`) before applying the spread. The element's id shifted by one on the server and the client re-created it instead of claiming (later siblings re-synced, hiding the drift; a `<title>` rendered this way duplicated on every hydration). The ssr generate now defers the merge behind a thunk when hydratable and `ssrElement` allocates the hydration key before resolving function props, matching the client's allocation order.
25
+ - 2c6852f: Root-level inserts no longer wipe foreign sibling nodes when clearing or replacing their content. Streaming appends late-flushed `<link rel="stylesheet">` tags to the end of `<body>`, inside the region a document-level hydration root tracks; previously a root expression that emptied (`textContent = ""` fast path) or swapped to text took those links with it, dropping loaded CSS (FOUC). `insert` now removes only the nodes it tracks when the parent contains children it doesn't own, keeping the fast path when it owns everything. Also documents that `registerModule` / `loadModuleAssets` mapping keys are opaque to the runtime — the reactive library chooses them (e.g. hydration ids) on both sides of the wire.
26
+ - 2275d59: Align SSR serialization of non-string attribute values (arrays, objects) with client-side `setAttribute` coercion so both environments produce the same final attribute string.
27
+
3
28
  ## 0.50.0-next.16
4
29
 
5
30
  ### 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.16",
4
+ "version": "0.50.0-next.17",
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.16"
29
+ "@dom-expressions/babel-plugin-jsx": "0.50.0-next.17"
30
30
  },
31
31
  "scripts": {
32
32
  "test": "jest --no-cache",
package/src/client.d.ts CHANGED
@@ -103,6 +103,19 @@ export function getNextMatch(start: Node, elementName: string): Element;
103
103
  export function getNextMarker(start: Node): [Node, Array<Node>];
104
104
  export function useAssets(fn: () => JSX.Element): void;
105
105
  export function getAssets(): string;
106
+ export type AssetDescriptor =
107
+ | { type: "style"; href: string; attrs?: Record<string, string> }
108
+ | { type: "inline-style"; id: string; content?: string; attrs?: Record<string, string> }
109
+ | { type: "module"; href: string }
110
+ | ExclusiveAssetDescriptor<any>;
111
+ export interface ExclusiveAssetDescriptor<T> {
112
+ policy: "exclusive";
113
+ key: string;
114
+ value: T;
115
+ get(): T;
116
+ set(value: T): void;
117
+ }
118
+ export function acquireAsset(descriptor: AssetDescriptor): () => void;
106
119
  export function HydrationScript(props?: { nonce?: string; eventNames?: string[] }): JSX.Element;
107
120
  export function generateHydrationScript(options?: {
108
121
  nonce?: string;
package/src/client.js CHANGED
@@ -459,27 +459,147 @@ export function assign(node, props, skipChildren, prevProps = {}, skipRef = fals
459
459
  }
460
460
  }
461
461
 
462
- // Module asset loading for hydration
462
+ // ---- Asset Registry ----
463
+ //
464
+ // Ref-counted client-side ownership of shared document assets. Consumers
465
+ // (routers, lazy wrappers, metadata components) acquire an asset when content
466
+ // that needs it mounts and release it on cleanup; the element is created — or
467
+ // an SSR/stream-emitted one adopted — on the first acquire and removed
468
+ // shortly after the last release. The removal grace period lets quick
469
+ // release/re-acquire cycles (route transitions sharing CSS) reuse the live
470
+ // element instead of flashing unstyled content while it reloads.
471
+ //
472
+ // Descriptor forms:
473
+ // { type: "style", href, attrs? } → <link rel="stylesheet">
474
+ // { type: "inline-style", id, content?, attrs? } → <style data-asset={id}>
475
+ // { type: "module", href } → <link rel="modulepreload">
476
+ // { policy: "exclusive", key, value, get, set } → singleton slot
477
+ //
478
+ // Exclusive slots implement last-writer-wins with restore-on-release (the
479
+ // title/meta ownership model): the newest acquire's value is applied via
480
+ // `set`; releasing it re-applies the previous writer's value, and the
481
+ // original document value (captured with `get` on first acquire) once every
482
+ // writer has released.
483
+
484
+ const ASSET_REMOVAL_GRACE = 100;
485
+ const assetRegistry = new Map();
486
+
487
+ function assetEntryKey(descriptor) {
488
+ if (descriptor.policy === "exclusive") return "x|" + descriptor.key;
489
+ return descriptor.type === "inline-style"
490
+ ? "i|" + descriptor.id
491
+ : descriptor.type + "|" + descriptor.href;
492
+ }
493
+
494
+ // Attribute-compared lookup (instead of an attribute selector) so href/id
495
+ // values never need selector escaping.
496
+ function findAssetElement(selector, attr, value) {
497
+ const nodes = document.querySelectorAll(selector);
498
+ for (let i = 0; i < nodes.length; i++) {
499
+ if (nodes[i].getAttribute(attr) === value) return nodes[i];
500
+ }
501
+ return null;
502
+ }
503
+
504
+ function mountAssetElement(descriptor) {
505
+ let el;
506
+ if (descriptor.type === "inline-style") {
507
+ el = findAssetElement("style[data-asset]", "data-asset", descriptor.id);
508
+ if (!el) {
509
+ el = document.createElement("style");
510
+ el.setAttribute("data-asset", descriptor.id);
511
+ el.textContent = descriptor.content || "";
512
+ }
513
+ } else {
514
+ const rel = descriptor.type === "module" ? "modulepreload" : "stylesheet";
515
+ el = findAssetElement(`link[rel="${rel}"]`, "href", descriptor.href);
516
+ if (!el) {
517
+ el = document.createElement("link");
518
+ el.rel = rel;
519
+ el.href = descriptor.href;
520
+ }
521
+ }
522
+ if (descriptor.attrs) {
523
+ for (const name in descriptor.attrs) el.setAttribute(name, descriptor.attrs[name]);
524
+ }
525
+ if (!el.isConnected) document.head.appendChild(el);
526
+ return el;
527
+ }
528
+
529
+ export function acquireAsset(descriptor) {
530
+ const key = assetEntryKey(descriptor);
531
+ let entry = assetRegistry.get(key);
532
+ if (descriptor.policy === "exclusive") {
533
+ if (!entry) {
534
+ entry = { original: descriptor.get(), set: descriptor.set, writers: [] };
535
+ assetRegistry.set(key, entry);
536
+ }
537
+ const writer = { value: descriptor.value };
538
+ entry.writers.push(writer);
539
+ entry.set(writer.value);
540
+ let released = false;
541
+ return () => {
542
+ if (released) return;
543
+ released = true;
544
+ const index = entry.writers.indexOf(writer);
545
+ const wasTop = index === entry.writers.length - 1;
546
+ entry.writers.splice(index, 1);
547
+ if (!wasTop) return;
548
+ if (entry.writers.length) {
549
+ entry.set(entry.writers[entry.writers.length - 1].value);
550
+ } else {
551
+ entry.set(entry.original);
552
+ assetRegistry.delete(key);
553
+ }
554
+ };
555
+ }
556
+ if (!entry) {
557
+ entry = { count: 0, element: null, timer: null };
558
+ assetRegistry.set(key, entry);
559
+ }
560
+ if (entry.timer) {
561
+ clearTimeout(entry.timer);
562
+ entry.timer = null;
563
+ }
564
+ entry.count++;
565
+ if (!entry.element || !entry.element.isConnected) entry.element = mountAssetElement(descriptor);
566
+ let released = false;
567
+ return () => {
568
+ if (released) return;
569
+ released = true;
570
+ if (--entry.count > 0) return;
571
+ entry.timer = setTimeout(() => {
572
+ assetRegistry.delete(key);
573
+ entry.element && entry.element.remove();
574
+ }, ASSET_REMOVAL_GRACE);
575
+ };
576
+ }
577
+
578
+ // Module asset loading for hydration. `mapping` pairs opaque keys with
579
+ // client-loadable entry URLs. Keys are chosen by the reactive library's
580
+ // server-side lazy() (e.g. hydration ids) and are never interpreted here —
581
+ // they only need to match what the client-side lazy() looks up in
582
+ // `_$HY.modules` after preload.
463
583
  function loadModuleAssets(mapping) {
464
584
  const hy = globalThis._$HY;
465
585
  if (!hy) return;
466
586
  const pending = [];
467
- for (const moduleUrl in mapping) {
468
- if (hy.modules[moduleUrl]) continue;
469
- const entryUrl = mapping[moduleUrl];
470
- if (!hy.loading[moduleUrl]) {
471
- hy.loading[moduleUrl] = import(/* @vite-ignore */ entryUrl).then(
587
+ for (const key in mapping) {
588
+ if (hy.modules[key]) continue;
589
+ const entryUrl = mapping[key];
590
+ if (!hy.loading[key]) {
591
+ hy.loading[key] = import(/* @vite-ignore */ entryUrl).then(
472
592
  mod => {
473
- hy.modules[moduleUrl] = mod;
593
+ hy.modules[key] = mod;
474
594
  },
475
595
  err => {
476
596
  // drop the rejected entry so a later boundary/navigation can retry
477
- delete hy.loading[moduleUrl];
597
+ delete hy.loading[key];
478
598
  throw err;
479
599
  }
480
600
  );
481
601
  }
482
- pending.push(hy.loading[moduleUrl]);
602
+ pending.push(hy.loading[key]);
483
603
  }
484
604
  return pending.length ? Promise.all(pending).then(() => {}) : undefined;
485
605
  }
@@ -899,7 +1019,16 @@ function insertExpression(parent, value, current, marker) {
899
1019
  const tc = typeof current;
900
1020
  if (tc === "string" || tc === "number") {
901
1021
  parent.firstChild.data = value;
902
- } else parent.textContent = value;
1022
+ } else {
1023
+ const owned = ownedChildCount(current);
1024
+ if (owned === -1 || owned === parent.childNodes.length) parent.textContent = value;
1025
+ else {
1026
+ // Foreign nodes present (e.g. stream-injected stylesheet links) —
1027
+ // replace only our own nodes, keeping text content leading.
1028
+ removeOwnedChildren(parent, current);
1029
+ parent.insertBefore(document.createTextNode(value), parent.firstChild);
1030
+ }
1031
+ }
903
1032
  } else if (value === undefined) {
904
1033
  cleanChildren(parent, current, marker);
905
1034
  } else if (value.nodeType) {
@@ -928,7 +1057,7 @@ function insertExpression(parent, value, current, marker) {
928
1057
  appendNodes(parent, value, marker);
929
1058
  } else reconcileArrays(parent, current, value, marker);
930
1059
  } else {
931
- current && cleanChildren(parent);
1060
+ current && cleanChildren(parent, current);
932
1061
  appendNodes(parent, value);
933
1062
  }
934
1063
  } else if ("_DX_DEV_") console.warn(`Unrecognized value. Skipped inserting`, value);
@@ -977,8 +1106,45 @@ function appendNodes(parent, array, marker = null) {
977
1106
  }
978
1107
  }
979
1108
 
1109
+ // Number of parent children the tracked `current` value accounts for, or -1
1110
+ // when unknown (initial render / untracked content — the whole parent is
1111
+ // considered owned, preserving the designed clear-on-first-render behavior).
1112
+ function ownedChildCount(current) {
1113
+ if (Array.isArray(current)) return current.length;
1114
+ if (current == null) return -1;
1115
+ if (current === "") return 0; // `textContent = ""` left no node behind
1116
+ return 1; // single node, or a string/number rendered as one text node
1117
+ }
1118
+
1119
+ // Remove only the nodes tracked by `current` from a root-level (markerless)
1120
+ // region, leaving foreign siblings in place.
1121
+ function removeOwnedChildren(parent, current) {
1122
+ if (Array.isArray(current)) {
1123
+ for (let i = 0; i < current.length; i++) {
1124
+ const el = current[i];
1125
+ if (el.parentNode === parent) el.remove();
1126
+ }
1127
+ } else if (current.nodeType) {
1128
+ if (current.parentNode === parent) current.remove();
1129
+ } else {
1130
+ // string/number content lives in a leading text node (set via
1131
+ // `textContent = value`, before any foreign node was appended).
1132
+ const first = parent.firstChild;
1133
+ if (first && first.nodeType === 3) first.remove();
1134
+ }
1135
+ }
1136
+
980
1137
  function cleanChildren(parent, current, marker, replacement) {
981
- if (marker === undefined) return (parent.textContent = "");
1138
+ if (marker === undefined) {
1139
+ // Root-level clear (no marker). `textContent = ""` wipes every child,
1140
+ // which is only safe when the nodes we track are the parent's only
1141
+ // children. Streaming can append foreign nodes to the root (late-flushed
1142
+ // stylesheet <link>s land at the end of <body>) and those must survive a
1143
+ // re-render — wiping them drops loaded CSS.
1144
+ const owned = ownedChildCount(current);
1145
+ if (owned === -1 || owned === parent.childNodes.length) return (parent.textContent = "");
1146
+ return removeOwnedChildren(parent, current);
1147
+ }
982
1148
  if (current.length) {
983
1149
  let inserted = false;
984
1150
  for (let i = current.length - 1; i >= 0; i--) {
package/src/server.d.ts CHANGED
@@ -192,3 +192,5 @@ export function ref(
192
192
  ): void;
193
193
  /** @deprecated not supported on the server side */
194
194
  export function setStyleProperty(node: Element, name: string, value: any): void;
195
+ /** @deprecated not supported on the server side — register assets through the render context instead */
196
+ export function acquireAsset(descriptor: unknown): () => void;
package/src/server.js CHANGED
@@ -82,25 +82,49 @@ function createAssetTracking() {
82
82
  const boundaryModules = new Map();
83
83
  const boundaryStyles = new Map();
84
84
  const emittedAssets = new Set();
85
+ const inlineStyles = new Map();
85
86
  let currentBoundaryId = null;
86
87
  return {
87
88
  boundaryModules,
88
89
  boundaryStyles,
89
90
  emittedAssets,
91
+ inlineStyles,
92
+ // Inline styles (dev CSS collected from the module graph, critical CSS)
93
+ // dedupe by `id` — repeated registrations reuse the same entry object so
94
+ // boundary Sets and the head injection never emit the same style twice.
95
+ registerInlineStyle(desc) {
96
+ let entry = inlineStyles.get(desc.id);
97
+ if (!entry) {
98
+ entry = { id: desc.id, content: desc.content || "", attrs: desc.attrs, emitted: false };
99
+ inlineStyles.set(desc.id, entry);
100
+ }
101
+ if (currentBoundaryId) {
102
+ let styles = boundaryStyles.get(currentBoundaryId);
103
+ if (!styles) {
104
+ styles = new Set();
105
+ boundaryStyles.set(currentBoundaryId, styles);
106
+ }
107
+ styles.add(entry);
108
+ }
109
+ return entry;
110
+ },
90
111
  get currentBoundaryId() {
91
112
  return currentBoundaryId;
92
113
  },
93
114
  set currentBoundaryId(v) {
94
115
  currentBoundaryId = v;
95
116
  },
96
- registerModule(moduleUrl, entryUrl) {
117
+ // `key` is opaque to the runtime — the reactive library's server-side
118
+ // lazy() picks it (e.g. a hydration id) and its client-side counterpart
119
+ // looks preloaded modules up under the same key after loadModuleAssets.
120
+ registerModule(key, entryUrl) {
97
121
  const id = currentBoundaryId || "";
98
122
  let map = boundaryModules.get(id);
99
123
  if (!map) {
100
124
  map = {};
101
125
  boundaryModules.set(id, map);
102
126
  }
103
- map[moduleUrl] = entryUrl;
127
+ map[key] = entryUrl;
104
128
  },
105
129
  getBoundaryModules(id) {
106
130
  return boundaryModules.get(id) || null;
@@ -178,16 +202,20 @@ export function renderToString(code, options = {}) {
178
202
  }
179
203
  serializer.write(id, p);
180
204
  },
181
- registerAsset(type, url) {
205
+ registerAsset(type, value) {
206
+ if (type === "inline-style") {
207
+ tracking.registerInlineStyle(value);
208
+ return;
209
+ }
182
210
  if (tracking.currentBoundaryId && type === "style") {
183
211
  let styles = tracking.boundaryStyles.get(tracking.currentBoundaryId);
184
212
  if (!styles) {
185
213
  styles = new Set();
186
214
  tracking.boundaryStyles.set(tracking.currentBoundaryId, styles);
187
215
  }
188
- styles.add(url);
216
+ styles.add(value);
189
217
  }
190
- tracking.emittedAssets.add(url);
218
+ tracking.emittedAssets.add(value);
191
219
  }
192
220
  };
193
221
  applyAssetTracking(sharedConfig.context, tracking, manifest);
@@ -204,6 +232,7 @@ export function renderToString(code, options = {}) {
204
232
  serializer.close();
205
233
  html = injectAssets(sharedConfig.context.assets, html);
206
234
  html = injectPreloadLinks(tracking.emittedAssets, html, nonce);
235
+ html = injectInlineStyles(tracking.inlineStyles, html, nonce);
207
236
  if (scripts.length) html = injectScripts(html, scripts, options.nonce);
208
237
  return html;
209
238
  }
@@ -313,19 +342,30 @@ export function renderToStream(code, options = {}) {
313
342
  async: true,
314
343
  assets: [],
315
344
  nonce,
316
- registerAsset(type, url) {
345
+ registerAsset(type, value) {
346
+ if (type === "inline-style") {
347
+ const entry = tracking.registerInlineStyle(value);
348
+ // Boundary-attributed inline styles flush with their fragment; a late
349
+ // registration outside any boundary has no other emission point, so
350
+ // write the tag into the stream immediately.
351
+ if (firstFlushed && !tracking.currentBoundaryId && !entry.emitted) {
352
+ entry.emitted = true;
353
+ buffer.write(renderInlineStyle(entry, nonce));
354
+ }
355
+ return;
356
+ }
317
357
  if (tracking.currentBoundaryId && type === "style") {
318
358
  let styles = tracking.boundaryStyles.get(tracking.currentBoundaryId);
319
359
  if (!styles) {
320
360
  styles = new Set();
321
361
  tracking.boundaryStyles.set(tracking.currentBoundaryId, styles);
322
362
  }
323
- styles.add(url);
363
+ styles.add(value);
324
364
  }
325
- if (!tracking.emittedAssets.has(url)) {
326
- tracking.emittedAssets.add(url);
365
+ if (!tracking.emittedAssets.has(value)) {
366
+ tracking.emittedAssets.add(value);
327
367
  if (firstFlushed && type === "module") {
328
- buffer.write(`<link rel="modulepreload" href="${url}">`);
368
+ buffer.write(`<link rel="modulepreload" href="${value}">`);
329
369
  }
330
370
  }
331
371
  },
@@ -418,10 +458,15 @@ export function renderToStream(code, options = {}) {
418
458
  serializeFragmentAssets(key, tracking.boundaryModules, context);
419
459
  const styles = collectStreamStyles(key, tracking, headStyles);
420
460
  const deferActivation = !!revealGroup;
421
- if (styles.length) {
422
- emitTask(`$dfs("${key}",${styles.length},${deferActivation ? 1 : 0})`);
461
+ // Inline styles apply as soon as the parser sees them — emit
462
+ // before the template, no load gating needed.
463
+ for (let i = 0; i < styles.inline.length; i++) {
464
+ buffer.write(renderInlineStyle(styles.inline[i], nonce));
465
+ }
466
+ if (styles.links.length) {
467
+ emitTask(`$dfs("${key}",${styles.links.length},${deferActivation ? 1 : 0})`);
423
468
  writeTasks();
424
- for (const url of styles) {
469
+ for (const url of styles.links) {
425
470
  buffer.write(
426
471
  `<link rel="stylesheet" href="${url}" onload="$dfc('${key}')" onerror="$dfc('${key}')">`
427
472
  );
@@ -516,6 +561,7 @@ export function renderToStream(code, options = {}) {
516
561
  if (url.endsWith(".css")) headStyles.add(url);
517
562
  }
518
563
  html = injectPreloadLinks(tracking.emittedAssets, html, nonce);
564
+ html = injectInlineStyles(tracking.inlineStyles, html, nonce);
519
565
  serializeRootAssets();
520
566
  if (tasks.length) html = injectScripts(html, tasks, nonce);
521
567
  buffer.write(html);
@@ -870,11 +916,17 @@ export function ssrStyleProperty(name, value) {
870
916
 
871
917
  // review with new ssr
872
918
  export function ssrElement(tag, props, children, needsId) {
919
+ // The hydration key must be allocated before the props thunk runs: dynamic
920
+ // props (`mergeProps(() => ...)`) create a memo, which consumes a child id.
921
+ // The client claims the element (getNextElement) before applying the spread,
922
+ // so the server must allocate in the same order or the element's own id
923
+ // shifts by one and it is left unclaimed on hydration.
924
+ const hk = needsId ? ssrHydrationKey() : "";
873
925
  if (props == null) props = {};
874
926
  else if (typeof props === "function") props = props();
875
927
  const skipChildren = VOID_ELEMENTS.test(tag);
876
928
  const keys = Object.keys(props);
877
- let result = `<${tag}${needsId ? ssrHydrationKey() : ""} `;
929
+ let result = `<${tag}${hk} `;
878
930
  for (let i = 0; i < keys.length; i++) {
879
931
  const prop = keys[i];
880
932
  if (ChildProperties.has(prop)) {
@@ -935,7 +987,16 @@ export function escape(s, attr) {
935
987
  for (let i = 0; i < s.length; i++) s[i] = escape(s[i]);
936
988
  return s;
937
989
  }
938
- if (attr && t === "boolean") return s;
990
+ if (attr) {
991
+ // Nullish and boolean values pass through so callers can omit the
992
+ // attribute or emit it as a boolean attribute. Numbers can never
993
+ // contain `&` or `"`. Everything else (arrays, objects, symbols)
994
+ // would be stringified by the surrounding template literal anyway,
995
+ // so coerce to the final string here first — matching what the
996
+ // client DOM receives — and run it through the normal string path.
997
+ if (s == null || t === "boolean" || t === "number") return s;
998
+ return escape(String(s), attr);
999
+ }
939
1000
  return s;
940
1001
  }
941
1002
  // Fast path: single forward pass over the string. Most values (color
@@ -1100,16 +1161,56 @@ function propagateBoundaryStyles(childKey, parentKey, tracking) {
1100
1161
  }
1101
1162
  }
1102
1163
 
1164
+ // Boundary style sets hold two kinds of entries: url strings (stylesheet
1165
+ // links, load-gated via $dfs) and inline style entry objects (emitted as
1166
+ // <style> tags, ready as soon as parsed). Splits them for the fragment
1167
+ // flush, consuming inline entries so they emit at most once.
1103
1168
  function collectStreamStyles(key, tracking, headStyles) {
1104
1169
  const styles = tracking.getBoundaryStyles(key);
1105
- if (!styles) return [];
1106
- const result = [];
1107
- for (const url of styles) {
1108
- if (!headStyles || !headStyles.has(url)) {
1109
- result.push(url);
1170
+ const links = [];
1171
+ const inline = [];
1172
+ if (!styles) return { links, inline };
1173
+ for (const entry of styles) {
1174
+ if (typeof entry === "string") {
1175
+ if (!headStyles || !headStyles.has(entry)) links.push(entry);
1176
+ } else if (!entry.emitted) {
1177
+ entry.emitted = true;
1178
+ inline.push(entry);
1110
1179
  }
1111
1180
  }
1112
- return result;
1181
+ return { links, inline };
1182
+ }
1183
+
1184
+ // `</style` inside content would close the tag early; escaping the slash is
1185
+ // valid CSS and neutralizes the sequence.
1186
+ function escapeStyleContent(content) {
1187
+ return content.replace(/<\/(style)/gi, "<\\/$1");
1188
+ }
1189
+
1190
+ function renderInlineStyle(entry, nonce) {
1191
+ let attrs = "";
1192
+ if (entry.attrs) {
1193
+ for (const name in entry.attrs) {
1194
+ attrs += ` ${name}="${escape(String(entry.attrs[name]), true)}"`;
1195
+ }
1196
+ }
1197
+ return `<style${nonce ? ` nonce="${nonce}"` : ""} data-asset="${escape(
1198
+ entry.id,
1199
+ true
1200
+ )}"${attrs}>${escapeStyleContent(entry.content)}</style>`;
1201
+ }
1202
+
1203
+ function injectInlineStyles(inlineStyles, html, nonce) {
1204
+ if (!inlineStyles.size) return html;
1205
+ const index = html.indexOf("</head>");
1206
+ if (index === -1) return html;
1207
+ let out = "";
1208
+ for (const entry of inlineStyles.values()) {
1209
+ if (entry.emitted) continue;
1210
+ entry.emitted = true;
1211
+ out += renderInlineStyle(entry, nonce);
1212
+ }
1213
+ return out ? html.slice(0, index) + out + html.slice(index) : html;
1113
1214
  }
1114
1215
 
1115
1216
  function injectScripts(html, scripts, nonce) {
@@ -1317,7 +1418,8 @@ export {
1317
1418
  notSup as getNextMarker,
1318
1419
  notSup as runHydrationEvents,
1319
1420
  notSup as ref,
1320
- notSup as setStyleProperty
1421
+ notSup as setStyleProperty,
1422
+ notSup as acquireAsset
1321
1423
  };
1322
1424
 
1323
1425
  function notSup() {