@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 +25 -0
- package/package.json +2 -2
- package/src/client.d.ts +13 -0
- package/src/client.js +178 -12
- package/src/server.d.ts +2 -0
- package/src/server.js +124 -22
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.
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
468
|
-
if (hy.modules[
|
|
469
|
-
const entryUrl = mapping[
|
|
470
|
-
if (!hy.loading[
|
|
471
|
-
hy.loading[
|
|
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[
|
|
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[
|
|
597
|
+
delete hy.loading[key];
|
|
478
598
|
throw err;
|
|
479
599
|
}
|
|
480
600
|
);
|
|
481
601
|
}
|
|
482
|
-
pending.push(hy.loading[
|
|
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
|
|
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)
|
|
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
|
-
|
|
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[
|
|
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,
|
|
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(
|
|
216
|
+
styles.add(value);
|
|
189
217
|
}
|
|
190
|
-
tracking.emittedAssets.add(
|
|
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,
|
|
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(
|
|
363
|
+
styles.add(value);
|
|
324
364
|
}
|
|
325
|
-
if (!tracking.emittedAssets.has(
|
|
326
|
-
tracking.emittedAssets.add(
|
|
365
|
+
if (!tracking.emittedAssets.has(value)) {
|
|
366
|
+
tracking.emittedAssets.add(value);
|
|
327
367
|
if (firstFlushed && type === "module") {
|
|
328
|
-
buffer.write(`<link rel="modulepreload" href="${
|
|
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
|
-
|
|
422
|
-
|
|
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}${
|
|
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
|
|
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
|
-
|
|
1106
|
-
const
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
|
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() {
|