@dom-expressions/runtime 0.50.0-next.15 → 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 +78 -0
- package/package.json +2 -2
- package/src/client.d.ts +14 -0
- package/src/client.js +325 -37
- package/src/server.d.ts +3 -0
- package/src/server.js +153 -26
- package/src/universal.d.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,83 @@
|
|
|
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
|
+
|
|
28
|
+
## 0.50.0-next.16
|
|
29
|
+
|
|
30
|
+
### Patch Changes
|
|
31
|
+
|
|
32
|
+
- 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`.
|
|
33
|
+
- 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.
|
|
34
|
+
- 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)
|
|
35
|
+
- 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.
|
|
36
|
+
- c2a542b: Fix hydration key mismatches when async holes defer past eager siblings
|
|
37
|
+
(solidjs/solid#2801 bug 2). Dynamic element children that can allocate
|
|
38
|
+
hydration ids (conditionals, component-children access, call expressions)
|
|
39
|
+
are now compiled with their own id scope on both generates: the dom and ssr
|
|
40
|
+
generates wrap the hole expression in a new `scope()` runtime helper using a
|
|
41
|
+
shared predicate, so marking cannot desync.
|
|
42
|
+
|
|
43
|
+
On the client, `scope(fn)` tags the accessor and `insert()` makes the outer
|
|
44
|
+
render effect non-transparent (its own id scope) for tagged accessors; the
|
|
45
|
+
inner unwrapping effect stays transparent so content ids keep a fixed depth.
|
|
46
|
+
On the server, `scope` (framework-provided via rxcore as `ssrScope`) reserves
|
|
47
|
+
one id slot at registration and evaluates the hole — including async retries
|
|
48
|
+
— under that reserved id with a zeroed child counter, so retry timing can no
|
|
49
|
+
longer shift sibling ids. The ssr generate's `orderedInsert` sibling
|
|
50
|
+
thunk-wrapping is removed; it is superseded by hole scopes.
|
|
51
|
+
|
|
52
|
+
Hole content ids gain one nesting level (e.g. `_hk=10` instead of `_hk=1`)
|
|
53
|
+
identically on both sides. rxcore implementations must provide an `ssrScope`
|
|
54
|
+
export and honor a `scope: true` effect option (mapped to a non-transparent
|
|
55
|
+
render effect).
|
|
56
|
+
|
|
57
|
+
- fa24389: Fix delegated events never reaching outer roots when a render root is
|
|
58
|
+
rendered inside another root's DOM (embedded widgets, microfrontends).
|
|
59
|
+
The first (innermost) container listener marked the event consumed for
|
|
60
|
+
every other root, so an outer root's delegated handlers were silently
|
|
61
|
+
skipped even though the native event bubbled through its elements: a
|
|
62
|
+
plain `addEventListener` on the same element fired while the delegated
|
|
63
|
+
handler didn't.
|
|
64
|
+
|
|
65
|
+
`$$EVENT_OWNER` now records the boundary of the most recent walk instead
|
|
66
|
+
of a consumed flag: an ancestor container whose subtree contains that boundary
|
|
67
|
+
resumes the handler walk from it up to its own boundary, so each root's
|
|
68
|
+
handlers fire exactly once, innermost-out, matching native bubbling.
|
|
69
|
+
`stopPropagation()` inside a nested root still suppresses outer roots (it
|
|
70
|
+
stops the native event before their listeners run), and hydration event
|
|
71
|
+
replay now relays queued events through all matching roots innermost-first
|
|
72
|
+
so pre- and post-hydration clicks behave identically. Apps that relied on
|
|
73
|
+
nested roots to isolate clicks from outer handlers should use
|
|
74
|
+
`stopPropagation()`, which remains the documented mechanism. Non-nested
|
|
75
|
+
apps are unaffected; the resume path is unreachable unless an inner root
|
|
76
|
+
already handled the event.
|
|
77
|
+
|
|
78
|
+
- 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.
|
|
79
|
+
- 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.
|
|
80
|
+
|
|
3
81
|
## 0.50.0-next.15
|
|
4
82
|
|
|
5
83
|
### 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
|
@@ -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;
|
|
@@ -102,6 +103,19 @@ export function getNextMatch(start: Node, elementName: string): Element;
|
|
|
102
103
|
export function getNextMarker(start: Node): [Node, Array<Node>];
|
|
103
104
|
export function useAssets(fn: () => JSX.Element): void;
|
|
104
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;
|
|
105
119
|
export function HydrationScript(props?: { nonce?: string; eventNames?: string[] }): JSX.Element;
|
|
106
120
|
export function generateHydrationScript(options?: {
|
|
107
121
|
nonce?: string;
|
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
|
|
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")
|
|
254
|
-
|
|
255
|
-
|
|
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 !==
|
|
260
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -385,20 +459,147 @@ export function assign(node, props, skipChildren, prevProps = {}, skipRef = fals
|
|
|
385
459
|
}
|
|
386
460
|
}
|
|
387
461
|
|
|
388
|
-
//
|
|
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.
|
|
389
583
|
function loadModuleAssets(mapping) {
|
|
390
584
|
const hy = globalThis._$HY;
|
|
391
585
|
if (!hy) return;
|
|
392
586
|
const pending = [];
|
|
393
|
-
for (const
|
|
394
|
-
if (hy.modules[
|
|
395
|
-
const entryUrl = mapping[
|
|
396
|
-
if (!hy.loading[
|
|
397
|
-
hy.loading[
|
|
398
|
-
|
|
399
|
-
|
|
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(
|
|
592
|
+
mod => {
|
|
593
|
+
hy.modules[key] = mod;
|
|
594
|
+
},
|
|
595
|
+
err => {
|
|
596
|
+
// drop the rejected entry so a later boundary/navigation can retry
|
|
597
|
+
delete hy.loading[key];
|
|
598
|
+
throw err;
|
|
599
|
+
}
|
|
600
|
+
);
|
|
400
601
|
}
|
|
401
|
-
pending.push(hy.loading[
|
|
602
|
+
pending.push(hy.loading[key]);
|
|
402
603
|
}
|
|
403
604
|
return pending.length ? Promise.all(pending).then(() => {}) : undefined;
|
|
404
605
|
}
|
|
@@ -460,8 +661,15 @@ export function hydrate(code, element, options = {}) {
|
|
|
460
661
|
sharedConfig.hydrating = false;
|
|
461
662
|
}
|
|
462
663
|
},
|
|
463
|
-
|
|
664
|
+
err => {
|
|
665
|
+
// A chunk failed to preload; hydration can't claim the server DOM
|
|
666
|
+
// (lazy components have no module). Fall back to a fresh client
|
|
667
|
+
// render replacing the server markup — lazy's own import() gets to
|
|
668
|
+
// retry through normal channels — instead of a silently dead page.
|
|
669
|
+
console.error("Hydration module preload failed, falling back to client render:", err);
|
|
464
670
|
sharedConfig.hydrating = false;
|
|
671
|
+
sharedConfig.registry = undefined;
|
|
672
|
+
disposer = render(code, element, [...element.childNodes], options);
|
|
465
673
|
}
|
|
466
674
|
);
|
|
467
675
|
return () => disposer && disposer();
|
|
@@ -597,14 +805,34 @@ export function runHydrationEvents() {
|
|
|
597
805
|
const [el, e] = events[0];
|
|
598
806
|
if (!completed.has(el)) return;
|
|
599
807
|
events.shift();
|
|
600
|
-
let
|
|
808
|
+
let matchContainer, matchState, matchDistance, matches;
|
|
601
809
|
for (const [container, state] of delegatedContainers) {
|
|
602
810
|
if (!state.handlers.has(e.type)) continue;
|
|
603
811
|
const entry = findOwner(e.target, state);
|
|
604
|
-
if (
|
|
605
|
-
|
|
812
|
+
if (!entry) continue;
|
|
813
|
+
if (matchContainer) {
|
|
814
|
+
if (!matches)
|
|
815
|
+
matches = [
|
|
816
|
+
{
|
|
817
|
+
container: matchContainer,
|
|
818
|
+
state: matchState,
|
|
819
|
+
distance: matchDistance
|
|
820
|
+
}
|
|
821
|
+
];
|
|
822
|
+
matches.push({ container, state, distance: entry.distance });
|
|
823
|
+
} else {
|
|
824
|
+
matchContainer = container;
|
|
825
|
+
matchState = state;
|
|
826
|
+
matchDistance = entry.distance;
|
|
827
|
+
}
|
|
606
828
|
}
|
|
607
|
-
if (
|
|
829
|
+
if (matches) {
|
|
830
|
+
// Replay innermost-first so queued hydration events follow the same
|
|
831
|
+
// root-boundary handoff as live native bubbling.
|
|
832
|
+
matches.sort((a, b) => a.distance - b.distance);
|
|
833
|
+
for (let i = 0; i < matches.length; i++)
|
|
834
|
+
eventHandler(e, matches[i].container, matches[i].state);
|
|
835
|
+
} else if (matchContainer) eventHandler(e, matchContainer, matchState);
|
|
608
836
|
}
|
|
609
837
|
if (sharedConfig.done) {
|
|
610
838
|
sharedConfig.events = _$HY.events = null;
|
|
@@ -696,7 +924,15 @@ function eventHandler(e, container, state) {
|
|
|
696
924
|
if (sharedConfig.registry && sharedConfig.events) {
|
|
697
925
|
if (sharedConfig.events.find(([el, ev]) => ev === e)) return;
|
|
698
926
|
}
|
|
699
|
-
|
|
927
|
+
const prev = e[$$EVENT_OWNER];
|
|
928
|
+
let resumeNode;
|
|
929
|
+
if (prev) {
|
|
930
|
+
// An inner root already walked its segment. Ancestor roots resume from
|
|
931
|
+
// that boundary; unrelated/shared containers must not see the event as
|
|
932
|
+
// theirs. Native stopPropagation still prevents this listener from running.
|
|
933
|
+
if (prev === true || prev === container || !container.contains(prev)) return;
|
|
934
|
+
resumeNode = prev;
|
|
935
|
+
}
|
|
700
936
|
const owner =
|
|
701
937
|
state &&
|
|
702
938
|
(state.owners.size === 1 && state.owners.has(container)
|
|
@@ -705,7 +941,7 @@ function eventHandler(e, container, state) {
|
|
|
705
941
|
if (state && !owner) return;
|
|
706
942
|
e[$$EVENT_OWNER] = owner || true;
|
|
707
943
|
|
|
708
|
-
let node = e.target;
|
|
944
|
+
let node = resumeNode || e.target;
|
|
709
945
|
const key = `$$${e.type}`;
|
|
710
946
|
const oriTarget = e.target;
|
|
711
947
|
const boundary = owner || container || e.currentTarget;
|
|
@@ -742,7 +978,13 @@ function eventHandler(e, container, state) {
|
|
|
742
978
|
return node || boundary || document;
|
|
743
979
|
}
|
|
744
980
|
});
|
|
745
|
-
if (
|
|
981
|
+
if (resumeNode) {
|
|
982
|
+
// If the boundary was the target, the inner walk already fired it.
|
|
983
|
+
// Resume above it so boundary handlers do not run twice.
|
|
984
|
+
if (resumeNode === e.target)
|
|
985
|
+
node = resumeNode._$host || resumeNode.parentNode || resumeNode.host;
|
|
986
|
+
if (node && node !== boundary) walkUpTree();
|
|
987
|
+
} else if (e.composedPath) {
|
|
746
988
|
const path = e.composedPath();
|
|
747
989
|
if (path.length) {
|
|
748
990
|
retarget(path[0]);
|
|
@@ -777,7 +1019,16 @@ function insertExpression(parent, value, current, marker) {
|
|
|
777
1019
|
const tc = typeof current;
|
|
778
1020
|
if (tc === "string" || tc === "number") {
|
|
779
1021
|
parent.firstChild.data = value;
|
|
780
|
-
} 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
|
+
}
|
|
781
1032
|
} else if (value === undefined) {
|
|
782
1033
|
cleanChildren(parent, current, marker);
|
|
783
1034
|
} else if (value.nodeType) {
|
|
@@ -806,7 +1057,7 @@ function insertExpression(parent, value, current, marker) {
|
|
|
806
1057
|
appendNodes(parent, value, marker);
|
|
807
1058
|
} else reconcileArrays(parent, current, value, marker);
|
|
808
1059
|
} else {
|
|
809
|
-
current && cleanChildren(parent);
|
|
1060
|
+
current && cleanChildren(parent, current);
|
|
810
1061
|
appendNodes(parent, value);
|
|
811
1062
|
}
|
|
812
1063
|
} else if ("_DX_DEV_") console.warn(`Unrecognized value. Skipped inserting`, value);
|
|
@@ -855,8 +1106,45 @@ function appendNodes(parent, array, marker = null) {
|
|
|
855
1106
|
}
|
|
856
1107
|
}
|
|
857
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
|
+
|
|
858
1137
|
function cleanChildren(parent, current, marker, replacement) {
|
|
859
|
-
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
|
+
}
|
|
860
1148
|
if (current.length) {
|
|
861
1149
|
let inserted = false;
|
|
862
1150
|
for (let i = current.length - 1; i >= 0; i--) {
|
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;
|
|
@@ -191,3 +192,5 @@ export function ref(
|
|
|
191
192
|
): void;
|
|
192
193
|
/** @deprecated not supported on the server side */
|
|
193
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
|
@@ -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
|
|
40
|
-
if (e.css) for (let i = 0; i < e.css.length; i++) css.push(base
|
|
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);
|
|
@@ -65,25 +82,49 @@ function createAssetTracking() {
|
|
|
65
82
|
const boundaryModules = new Map();
|
|
66
83
|
const boundaryStyles = new Map();
|
|
67
84
|
const emittedAssets = new Set();
|
|
85
|
+
const inlineStyles = new Map();
|
|
68
86
|
let currentBoundaryId = null;
|
|
69
87
|
return {
|
|
70
88
|
boundaryModules,
|
|
71
89
|
boundaryStyles,
|
|
72
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
|
+
},
|
|
73
111
|
get currentBoundaryId() {
|
|
74
112
|
return currentBoundaryId;
|
|
75
113
|
},
|
|
76
114
|
set currentBoundaryId(v) {
|
|
77
115
|
currentBoundaryId = v;
|
|
78
116
|
},
|
|
79
|
-
|
|
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) {
|
|
80
121
|
const id = currentBoundaryId || "";
|
|
81
122
|
let map = boundaryModules.get(id);
|
|
82
123
|
if (!map) {
|
|
83
124
|
map = {};
|
|
84
125
|
boundaryModules.set(id, map);
|
|
85
126
|
}
|
|
86
|
-
map[
|
|
127
|
+
map[key] = entryUrl;
|
|
87
128
|
},
|
|
88
129
|
getBoundaryModules(id) {
|
|
89
130
|
return boundaryModules.get(id) || null;
|
|
@@ -161,16 +202,20 @@ export function renderToString(code, options = {}) {
|
|
|
161
202
|
}
|
|
162
203
|
serializer.write(id, p);
|
|
163
204
|
},
|
|
164
|
-
registerAsset(type,
|
|
205
|
+
registerAsset(type, value) {
|
|
206
|
+
if (type === "inline-style") {
|
|
207
|
+
tracking.registerInlineStyle(value);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
165
210
|
if (tracking.currentBoundaryId && type === "style") {
|
|
166
211
|
let styles = tracking.boundaryStyles.get(tracking.currentBoundaryId);
|
|
167
212
|
if (!styles) {
|
|
168
213
|
styles = new Set();
|
|
169
214
|
tracking.boundaryStyles.set(tracking.currentBoundaryId, styles);
|
|
170
215
|
}
|
|
171
|
-
styles.add(
|
|
216
|
+
styles.add(value);
|
|
172
217
|
}
|
|
173
|
-
tracking.emittedAssets.add(
|
|
218
|
+
tracking.emittedAssets.add(value);
|
|
174
219
|
}
|
|
175
220
|
};
|
|
176
221
|
applyAssetTracking(sharedConfig.context, tracking, manifest);
|
|
@@ -187,6 +232,7 @@ export function renderToString(code, options = {}) {
|
|
|
187
232
|
serializer.close();
|
|
188
233
|
html = injectAssets(sharedConfig.context.assets, html);
|
|
189
234
|
html = injectPreloadLinks(tracking.emittedAssets, html, nonce);
|
|
235
|
+
html = injectInlineStyles(tracking.inlineStyles, html, nonce);
|
|
190
236
|
if (scripts.length) html = injectScripts(html, scripts, options.nonce);
|
|
191
237
|
return html;
|
|
192
238
|
}
|
|
@@ -296,19 +342,30 @@ export function renderToStream(code, options = {}) {
|
|
|
296
342
|
async: true,
|
|
297
343
|
assets: [],
|
|
298
344
|
nonce,
|
|
299
|
-
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
|
+
}
|
|
300
357
|
if (tracking.currentBoundaryId && type === "style") {
|
|
301
358
|
let styles = tracking.boundaryStyles.get(tracking.currentBoundaryId);
|
|
302
359
|
if (!styles) {
|
|
303
360
|
styles = new Set();
|
|
304
361
|
tracking.boundaryStyles.set(tracking.currentBoundaryId, styles);
|
|
305
362
|
}
|
|
306
|
-
styles.add(
|
|
363
|
+
styles.add(value);
|
|
307
364
|
}
|
|
308
|
-
if (!tracking.emittedAssets.has(
|
|
309
|
-
tracking.emittedAssets.add(
|
|
365
|
+
if (!tracking.emittedAssets.has(value)) {
|
|
366
|
+
tracking.emittedAssets.add(value);
|
|
310
367
|
if (firstFlushed && type === "module") {
|
|
311
|
-
buffer.write(`<link rel="modulepreload" href="${
|
|
368
|
+
buffer.write(`<link rel="modulepreload" href="${value}">`);
|
|
312
369
|
}
|
|
313
370
|
}
|
|
314
371
|
},
|
|
@@ -401,10 +458,15 @@ export function renderToStream(code, options = {}) {
|
|
|
401
458
|
serializeFragmentAssets(key, tracking.boundaryModules, context);
|
|
402
459
|
const styles = collectStreamStyles(key, tracking, headStyles);
|
|
403
460
|
const deferActivation = !!revealGroup;
|
|
404
|
-
|
|
405
|
-
|
|
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})`);
|
|
406
468
|
writeTasks();
|
|
407
|
-
for (const url of styles) {
|
|
469
|
+
for (const url of styles.links) {
|
|
408
470
|
buffer.write(
|
|
409
471
|
`<link rel="stylesheet" href="${url}" onload="$dfc('${key}')" onerror="$dfc('${key}')">`
|
|
410
472
|
);
|
|
@@ -499,6 +561,7 @@ export function renderToStream(code, options = {}) {
|
|
|
499
561
|
if (url.endsWith(".css")) headStyles.add(url);
|
|
500
562
|
}
|
|
501
563
|
html = injectPreloadLinks(tracking.emittedAssets, html, nonce);
|
|
564
|
+
html = injectInlineStyles(tracking.inlineStyles, html, nonce);
|
|
502
565
|
serializeRootAssets();
|
|
503
566
|
if (tasks.length) html = injectScripts(html, tasks, nonce);
|
|
504
567
|
buffer.write(html);
|
|
@@ -853,11 +916,17 @@ export function ssrStyleProperty(name, value) {
|
|
|
853
916
|
|
|
854
917
|
// review with new ssr
|
|
855
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() : "";
|
|
856
925
|
if (props == null) props = {};
|
|
857
926
|
else if (typeof props === "function") props = props();
|
|
858
927
|
const skipChildren = VOID_ELEMENTS.test(tag);
|
|
859
928
|
const keys = Object.keys(props);
|
|
860
|
-
let result = `<${tag}${
|
|
929
|
+
let result = `<${tag}${hk} `;
|
|
861
930
|
for (let i = 0; i < keys.length; i++) {
|
|
862
931
|
const prop = keys[i];
|
|
863
932
|
if (ChildProperties.has(prop)) {
|
|
@@ -918,7 +987,16 @@ export function escape(s, attr) {
|
|
|
918
987
|
for (let i = 0; i < s.length; i++) s[i] = escape(s[i]);
|
|
919
988
|
return s;
|
|
920
989
|
}
|
|
921
|
-
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
|
+
}
|
|
922
1000
|
return s;
|
|
923
1001
|
}
|
|
924
1002
|
// Fast path: single forward pass over the string. Most values (color
|
|
@@ -1083,16 +1161,56 @@ function propagateBoundaryStyles(childKey, parentKey, tracking) {
|
|
|
1083
1161
|
}
|
|
1084
1162
|
}
|
|
1085
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.
|
|
1086
1168
|
function collectStreamStyles(key, tracking, headStyles) {
|
|
1087
1169
|
const styles = tracking.getBoundaryStyles(key);
|
|
1088
|
-
|
|
1089
|
-
const
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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);
|
|
1093
1179
|
}
|
|
1094
1180
|
}
|
|
1095
|
-
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;
|
|
1096
1214
|
}
|
|
1097
1215
|
|
|
1098
1216
|
function injectScripts(html, scripts, nonce) {
|
|
@@ -1178,6 +1296,12 @@ function tryResolveString(node) {
|
|
|
1178
1296
|
return s;
|
|
1179
1297
|
}
|
|
1180
1298
|
if (node.h && node.h.length > 0) return { merge: node };
|
|
1299
|
+
if (node.t === undefined) {
|
|
1300
|
+
// Not a template object — mirror the client's dev warn-and-skip
|
|
1301
|
+
// instead of crashing downstream on a malformed template shape.
|
|
1302
|
+
if ("_DX_DEV_") console.warn(`Unrecognized value. Skipped inserting`, node);
|
|
1303
|
+
return "";
|
|
1304
|
+
}
|
|
1181
1305
|
return Array.isArray(node.t) ? node.t[0] : node.t;
|
|
1182
1306
|
}
|
|
1183
1307
|
if (t === "function") {
|
|
@@ -1225,7 +1349,9 @@ function resolveSSRNode(
|
|
|
1225
1349
|
result.h.push(...node.h);
|
|
1226
1350
|
result.p.push(...node.p);
|
|
1227
1351
|
}
|
|
1228
|
-
} else
|
|
1352
|
+
} else if (node.t !== undefined) {
|
|
1353
|
+
result.t[result.t.length - 1] += node.t;
|
|
1354
|
+
} else if ("_DX_DEV_") console.warn(`Unrecognized value. Skipped inserting`, node);
|
|
1229
1355
|
} else if (t === "function") {
|
|
1230
1356
|
try {
|
|
1231
1357
|
resolveSSRNode(node(), result);
|
|
@@ -1292,7 +1418,8 @@ export {
|
|
|
1292
1418
|
notSup as getNextMarker,
|
|
1293
1419
|
notSup as runHydrationEvents,
|
|
1294
1420
|
notSup as ref,
|
|
1295
|
-
notSup as setStyleProperty
|
|
1421
|
+
notSup as setStyleProperty,
|
|
1422
|
+
notSup as acquireAsset
|
|
1296
1423
|
};
|
|
1297
1424
|
|
|
1298
1425
|
function notSup() {
|
package/src/universal.d.ts
CHANGED
|
@@ -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;
|