@opentf/web 0.5.0 → 0.7.0
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/README.md +43 -0
- package/components/Link.jsx +18 -4
- package/core/reactive.js +190 -0
- package/core/signals.js +19 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/runtime/clipboard.js +47 -0
- package/runtime/code-block.js +47 -0
- package/runtime/context.js +13 -3
- package/runtime/dom.js +181 -24
- package/runtime/error-boundary.js +5 -5
- package/runtime/hydrate.js +268 -0
- package/runtime/index.js +7 -1
- package/runtime/portal.js +4 -4
- package/runtime/raw-html.js +33 -0
- package/runtime/router.js +216 -10
- package/server/builtins.js +8 -3
- package/server/head.js +191 -0
- package/server/index.js +1 -0
- package/server/render.js +42 -13
- package/server/ssg-runtime.js +55 -1
package/runtime/dom.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// no diffing — just direct, fine-grained DOM writes wired to signals.
|
|
4
4
|
|
|
5
5
|
import { effect, signal } from "../core/signals.js";
|
|
6
|
+
import { claimRegionEnd, claimRegionStart } from "./hydrate.js";
|
|
6
7
|
|
|
7
8
|
// Attribute names that must be assigned as JS properties (not setAttribute) for
|
|
8
9
|
// correct behavior. Mirrors the set the old runtime relied on (core/constants).
|
|
@@ -64,6 +65,13 @@ export function toText(value) {
|
|
|
64
65
|
return value == null || value === false ? "" : String(value);
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
/** Sentinel for "nothing written yet" — distinct from any real attribute value. */
|
|
69
|
+
const UNSET = Symbol("unset");
|
|
70
|
+
|
|
71
|
+
/** A value safe to compare by identity for write elision (no internal mutation). */
|
|
72
|
+
const isPrimitive = (v) =>
|
|
73
|
+
v === null || (typeof v !== "object" && typeof v !== "function");
|
|
74
|
+
|
|
67
75
|
/**
|
|
68
76
|
* clsx-style class normalization. Falsy entries are skipped; strings/numbers are
|
|
69
77
|
* kept; arrays recurse; objects contribute the keys whose values are truthy. Lets
|
|
@@ -165,30 +173,64 @@ export function spread(el, obj, asProps) {
|
|
|
165
173
|
*/
|
|
166
174
|
export function bindText(node, fn) {
|
|
167
175
|
let inserted = [];
|
|
176
|
+
let lastText; // undefined sentinel — toText() never returns undefined
|
|
168
177
|
return effect(() => {
|
|
169
178
|
const value = fn();
|
|
179
|
+
|
|
180
|
+
// Primitive text: the common case. Skip the write when the rendered string
|
|
181
|
+
// is unchanged (a shared dependency can re-run this effect with the same
|
|
182
|
+
// result), so unchanged text nodes don't churn.
|
|
183
|
+
if (!(value instanceof Node) && !Array.isArray(value)) {
|
|
184
|
+
const text = toText(value);
|
|
185
|
+
if (text === lastText) return;
|
|
186
|
+
lastText = text;
|
|
187
|
+
for (const n of inserted) {
|
|
188
|
+
if (n.parentNode) n.parentNode.removeChild(n);
|
|
189
|
+
}
|
|
190
|
+
inserted = [];
|
|
191
|
+
node.data = text;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Node / array value (e.g. JSX stored as a value): insert before the stable
|
|
196
|
+
// text anchor. Reset the text memo so a later primitive value re-applies.
|
|
197
|
+
lastText = undefined;
|
|
170
198
|
for (const n of inserted) {
|
|
171
199
|
if (n.parentNode) n.parentNode.removeChild(n);
|
|
172
200
|
}
|
|
173
201
|
inserted = [];
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
inserted.push(n);
|
|
181
|
-
}
|
|
202
|
+
node.data = "";
|
|
203
|
+
const parent = node.parentNode;
|
|
204
|
+
if (parent) {
|
|
205
|
+
for (const n of toNodes(value)) {
|
|
206
|
+
parent.insertBefore(n, node);
|
|
207
|
+
inserted.push(n);
|
|
182
208
|
}
|
|
183
|
-
} else {
|
|
184
|
-
node.data = toText(value);
|
|
185
209
|
}
|
|
186
210
|
});
|
|
187
211
|
}
|
|
188
212
|
|
|
189
|
-
/**
|
|
213
|
+
/**
|
|
214
|
+
* Wire an element attribute/prop to a reactive expression. Returns the disposer.
|
|
215
|
+
*
|
|
216
|
+
* No-op writes are elided for primitive values: when the expression recomputes
|
|
217
|
+
* to the same primitive it last wrote, the DOM write is skipped. This matters
|
|
218
|
+
* when many elements share one dependency — e.g. a keyed list where every row's
|
|
219
|
+
* `class={selected === row.id ? "danger" : ""}` subscribes to a single
|
|
220
|
+
* `selected` signal. Changing it re-runs every row's effect, but only the two
|
|
221
|
+
* rows whose class actually changes should touch the DOM; the rest would
|
|
222
|
+
* otherwise call `setAttribute` with an unchanged value and trigger needless
|
|
223
|
+
* style invalidation. Object values (style/class objects, spreads) may have
|
|
224
|
+
* mutated internally, so they are always re-applied.
|
|
225
|
+
*/
|
|
190
226
|
export function bindAttr(el, name, fn) {
|
|
191
|
-
|
|
227
|
+
let last = UNSET;
|
|
228
|
+
return effect(() => {
|
|
229
|
+
const value = fn();
|
|
230
|
+
if (isPrimitive(value) && Object.is(value, last)) return;
|
|
231
|
+
last = value;
|
|
232
|
+
setAttr(el, name, value);
|
|
233
|
+
});
|
|
192
234
|
}
|
|
193
235
|
|
|
194
236
|
/** Flatten a reactive child value into DOM nodes: nodes pass through, arrays
|
|
@@ -212,9 +254,40 @@ function toNodes(value) {
|
|
|
212
254
|
* Returns the effect disposer.
|
|
213
255
|
*/
|
|
214
256
|
export function bindChild(anchor, fn) {
|
|
215
|
-
|
|
257
|
+
return childEffect(anchor, fn, [], false);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Hydrate a server-rendered conditional / dynamic-node region (docs/HYDRATION.md §3.1,
|
|
262
|
+
* 2.1b). The cursor `cur` is at the `<!--[-->` marker; between it and `<!--]-->` sits the
|
|
263
|
+
* one branch the server rendered (or nothing). `adoptFn(cur)` evaluates the same branch
|
|
264
|
+
* expression with *adopt* calls — only the taken branch's builder runs, claiming its nodes
|
|
265
|
+
* off the shared cursor — so the adopted nodes become the region's initial content with no
|
|
266
|
+
* rebuild. `buildFn()` is the CSR version (build calls) the effect swaps to when a later
|
|
267
|
+
* reactive change selects a different branch. The closing `<!--]-->` is the swap anchor.
|
|
268
|
+
* Returns the effect disposer.
|
|
269
|
+
*/
|
|
270
|
+
export function hydrateChild(cur, adoptFn, buildFn) {
|
|
271
|
+
claimRegionStart(cur);
|
|
272
|
+
const adopted = toNodes(adoptFn(cur));
|
|
273
|
+
const anchor = claimRegionEnd(cur);
|
|
274
|
+
// Seed with the adopted nodes and skip the first effect run's DOM write: the branch is
|
|
275
|
+
// already in place. The first run still evaluates `buildFn` to subscribe to its reactive
|
|
276
|
+
// deps (the built nodes are discarded); from the second run on it swaps normally.
|
|
277
|
+
return childEffect(anchor, buildFn, adopted, true);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The child-region effect shared by {@link bindChild} (empty seed, no skip) and
|
|
281
|
+
* {@link hydrateChild} (seeded with adopted nodes, first DOM write skipped). On each run
|
|
282
|
+
* the previous nodes are replaced with the new ones, inserted before `anchor`. */
|
|
283
|
+
function childEffect(anchor, fn, current, skipFirst) {
|
|
284
|
+
let first = skipFirst;
|
|
216
285
|
return effect(() => {
|
|
217
286
|
const next = toNodes(fn());
|
|
287
|
+
if (first) {
|
|
288
|
+
first = false;
|
|
289
|
+
return; // hydration: keep the adopted DOM on first paint (subscribe only)
|
|
290
|
+
}
|
|
218
291
|
const host = anchor.parentNode;
|
|
219
292
|
for (const n of current) {
|
|
220
293
|
if (n.parentNode === host) host.removeChild(n);
|
|
@@ -238,13 +311,57 @@ export function bindChild(anchor, fn) {
|
|
|
238
311
|
export function bindList(parent, sourceFn, renderItem, keyFn) {
|
|
239
312
|
const anchor = document.createComment("");
|
|
240
313
|
parent.appendChild(anchor);
|
|
241
|
-
|
|
314
|
+
return reconcileList(anchor, sourceFn, renderItem, keyFn, new Map(), []);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Hydrate a server-rendered list region (docs/HYDRATION.md §3.1/2.1). The cursor `cur` is
|
|
319
|
+
* positioned at the `<!--[-->` marker; the region holds one root node per item, then
|
|
320
|
+
* `<!--]-->`. Adopts each item's server node via `adoptItem(cur, itemSignal, index)`
|
|
321
|
+
* (claiming off the shared cursor, which advances to the next item), seeds the reconcile
|
|
322
|
+
* cache with the adopted `{sig, node}` pairs, then wires the *same* keyed-reconcile effect
|
|
323
|
+
* `bindList` uses — so a later data change builds/moves/removes with no first-paint flash.
|
|
324
|
+
* The closing `<!--]-->` becomes the reconcile anchor. Returns the effect disposer.
|
|
325
|
+
*/
|
|
326
|
+
export function hydrateList(cur, sourceFn, adoptItem, renderItem, keyFn) {
|
|
327
|
+
claimRegionStart(cur);
|
|
328
|
+
const data = sourceFn();
|
|
329
|
+
const items = Array.isArray(data) ? data : [];
|
|
330
|
+
const cache = new Map();
|
|
331
|
+
const prevKeys = [];
|
|
332
|
+
for (let index = 0; index < items.length; index++) {
|
|
333
|
+
const item = items[index];
|
|
334
|
+
const key = keyFn ? keyFn(item, index) : index;
|
|
335
|
+
const sig = signal(item);
|
|
336
|
+
const node = adoptItem(cur, sig, index); // claims one item subtree off `cur`
|
|
337
|
+
cache.set(key, { sig, node });
|
|
338
|
+
prevKeys.push(key);
|
|
339
|
+
}
|
|
340
|
+
const anchor = claimRegionEnd(cur);
|
|
341
|
+
// The seeded effect's first run re-reads the same data: every key hits the cache, so it
|
|
342
|
+
// subscribes for future updates without mutating the already-correct adopted DOM.
|
|
343
|
+
return reconcileList(anchor, sourceFn, renderItem, keyFn, cache, prevKeys);
|
|
344
|
+
}
|
|
242
345
|
|
|
346
|
+
/**
|
|
347
|
+
* The keyed-reconcile effect shared by {@link bindList} (empty seed) and
|
|
348
|
+
* {@link hydrateList} (seeded from adopted nodes). `anchor` is the trailing comment new
|
|
349
|
+
* items are inserted before; `cache` (key → `{sig, node}`) and `prevKeys` (keys in DOM
|
|
350
|
+
* order) carry reconciliation state across runs. Returns the effect disposer.
|
|
351
|
+
*/
|
|
352
|
+
function reconcileList(anchor, sourceFn, renderItem, keyFn, cache, prevKeys) {
|
|
243
353
|
return effect(() => {
|
|
244
354
|
const data = sourceFn();
|
|
245
355
|
const items = Array.isArray(data) ? data : [];
|
|
246
356
|
const next = new Map();
|
|
247
|
-
const nodes =
|
|
357
|
+
const nodes = new Array(items.length);
|
|
358
|
+
const newKeys = new Array(items.length);
|
|
359
|
+
// For each new position, the node's index in the *previous* order, or -1 if
|
|
360
|
+
// it is newly created. Drives the LIS-based minimal move below.
|
|
361
|
+
const prevIndex = new Array(items.length);
|
|
362
|
+
|
|
363
|
+
const prevPos = new Map();
|
|
364
|
+
for (let i = 0; i < prevKeys.length; i++) prevPos.set(prevKeys[i], i);
|
|
248
365
|
|
|
249
366
|
for (let index = 0; index < items.length; index++) {
|
|
250
367
|
const item = items[index];
|
|
@@ -252,13 +369,16 @@ export function bindList(parent, sourceFn, renderItem, keyFn) {
|
|
|
252
369
|
let entry = cache.get(key);
|
|
253
370
|
if (entry) {
|
|
254
371
|
cache.delete(key);
|
|
255
|
-
entry.sig.value = item; // fine-grained per-item update
|
|
372
|
+
entry.sig.value = item; // fine-grained per-item update (no-op if unchanged)
|
|
373
|
+
prevIndex[index] = prevPos.has(key) ? prevPos.get(key) : -1;
|
|
256
374
|
} else {
|
|
257
375
|
const sig = signal(item);
|
|
258
376
|
entry = { sig, node: renderItem(sig, index) };
|
|
377
|
+
prevIndex[index] = -1;
|
|
259
378
|
}
|
|
260
379
|
next.set(key, entry);
|
|
261
|
-
|
|
380
|
+
newKeys[index] = key;
|
|
381
|
+
nodes[index] = entry.node;
|
|
262
382
|
}
|
|
263
383
|
|
|
264
384
|
// Remove nodes whose keys disappeared.
|
|
@@ -266,15 +386,52 @@ export function bindList(parent, sourceFn, renderItem, keyFn) {
|
|
|
266
386
|
if (entry.node.parentNode) entry.node.parentNode.removeChild(entry.node);
|
|
267
387
|
}
|
|
268
388
|
cache = next;
|
|
389
|
+
prevKeys = newKeys;
|
|
269
390
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
391
|
+
// Minimal-move placement. Nodes whose previous indices form the longest
|
|
392
|
+
// increasing subsequence are already in relative order and stay put; only
|
|
393
|
+
// the rest (and newly created nodes) are inserted. A 2-row swap moves 2
|
|
394
|
+
// nodes, not the ~n nodes a naive "fix every wrong nextSibling" pass would
|
|
395
|
+
// cascade into. Walk back to front so each insertion's reference node is
|
|
396
|
+
// already in its final position.
|
|
397
|
+
const keep = longestIncreasingRun(prevIndex);
|
|
398
|
+
const host = anchor.parentNode;
|
|
399
|
+
if (!host) return; // anchor detached (list torn down) — nothing to place
|
|
274
400
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
ref
|
|
401
|
+
if (keep.has(i)) continue;
|
|
402
|
+
const ref = i + 1 < nodes.length ? nodes[i + 1] : anchor;
|
|
403
|
+
if (nodes[i].nextSibling !== ref) host.insertBefore(nodes[i], ref);
|
|
278
404
|
}
|
|
279
405
|
});
|
|
280
406
|
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Indices `i` of `seq` whose values form a longest strictly-increasing
|
|
410
|
+
* subsequence (patience sorting, O(n log n)). Entries equal to -1 (new nodes)
|
|
411
|
+
* are excluded — they always need insertion. Returned as a Set for O(1) lookup
|
|
412
|
+
* during placement; these are the nodes that can stay where they are.
|
|
413
|
+
*/
|
|
414
|
+
function longestIncreasingRun(seq) {
|
|
415
|
+
const piles = []; // piles[k] = index of the smallest tail of a length-(k+1) run
|
|
416
|
+
const prev = new Array(seq.length).fill(-1);
|
|
417
|
+
for (let i = 0; i < seq.length; i++) {
|
|
418
|
+
const v = seq[i];
|
|
419
|
+
if (v < 0) continue; // new node — never part of the stable run
|
|
420
|
+
let lo = 0;
|
|
421
|
+
let hi = piles.length;
|
|
422
|
+
while (lo < hi) {
|
|
423
|
+
const mid = (lo + hi) >> 1;
|
|
424
|
+
if (seq[piles[mid]] < v) lo = mid + 1;
|
|
425
|
+
else hi = mid;
|
|
426
|
+
}
|
|
427
|
+
if (lo > 0) prev[i] = piles[lo - 1];
|
|
428
|
+
piles[lo] = i;
|
|
429
|
+
}
|
|
430
|
+
const keep = new Set();
|
|
431
|
+
let k = piles.length ? piles[piles.length - 1] : -1;
|
|
432
|
+
while (k >= 0) {
|
|
433
|
+
keep.add(k);
|
|
434
|
+
k = prev[k];
|
|
435
|
+
}
|
|
436
|
+
return keep;
|
|
437
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// A descendant component's connectedCallback funnels a thrown error through
|
|
10
10
|
// `handleError(this, …)` (emitted by the compiler): it reports the error (dev
|
|
11
11
|
// overlay / logging) and walks up the DOM — hopping across portals, like
|
|
12
|
-
// `readContext` — to the nearest `web-error-boundary`, which swaps its children
|
|
12
|
+
// `readContext` — to the nearest `web-internal-error-boundary`, which swaps its children
|
|
13
13
|
// for the fallback. `reset()` rebuilds the original subtree (snapshotted before it
|
|
14
14
|
// ran) to retry. Only synchronous render/mount errors are caught; later
|
|
15
15
|
// effect/event errors still go to global reporting (as with React boundaries).
|
|
@@ -24,7 +24,7 @@ export function handleError(el, error, context = {}) {
|
|
|
24
24
|
reportError(error, context); // always log / surface in the dev overlay
|
|
25
25
|
let node = el;
|
|
26
26
|
while (node) {
|
|
27
|
-
const boundary = node.closest ? node.closest("web-error-boundary") : null;
|
|
27
|
+
const boundary = node.closest ? node.closest("web-internal-error-boundary") : null;
|
|
28
28
|
if (boundary) {
|
|
29
29
|
boundary.catch(error, context);
|
|
30
30
|
return;
|
|
@@ -102,8 +102,8 @@ export class ErrorBoundaryElement extends HTMLElement {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
// Exported so `import { ErrorBoundary } from "@opentf/web"` resolves; the import
|
|
105
|
-
// side effect registers the element (JSX uses the `web-error-boundary` tag).
|
|
105
|
+
// side effect registers the element (JSX uses the `web-internal-error-boundary` tag).
|
|
106
106
|
export const ErrorBoundary = ErrorBoundaryElement;
|
|
107
|
-
if (typeof customElements !== "undefined" && !customElements.get("web-error-boundary")) {
|
|
108
|
-
customElements.define("web-error-boundary", ErrorBoundaryElement);
|
|
107
|
+
if (typeof customElements !== "undefined" && !customElements.get("web-internal-error-boundary")) {
|
|
108
|
+
customElements.define("web-internal-error-boundary", ErrorBoundaryElement);
|
|
109
109
|
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Hydration primitives (Phase 2 — see docs/HYDRATION.md). The client *adopts* the
|
|
2
|
+
// server-rendered DOM instead of rebuilding it: it claims existing nodes and wires
|
|
3
|
+
// reactivity (bindText/bindAttr/events from dom.js) onto them. There is no separate
|
|
4
|
+
// "hydrate" binding logic — those helpers already operate on existing nodes; the only
|
|
5
|
+
// new work is *node acquisition*, which is what this module provides.
|
|
6
|
+
//
|
|
7
|
+
// Acquisition is a **cursor walk** (the decided approach, like Solid): the Hydrate
|
|
8
|
+
// codegen emits claim calls in the exact order the SSG backend emitted nodes, so a
|
|
9
|
+
// cursor over a parent's childNodes lines up 1:1 with the template. The server output
|
|
10
|
+
// carries no inter-element whitespace (ssg.rs concatenates with no padding), so the
|
|
11
|
+
// re-parsed DOM has text nodes only where the template does — the walk stays aligned.
|
|
12
|
+
//
|
|
13
|
+
// Markers (only where structure is variable): a dynamic text hole is delimited by
|
|
14
|
+
// `<!--$-->value<!--/-->` so it is findable even when the value is empty or adjacent
|
|
15
|
+
// to static text (which the HTML parser would otherwise merge into one text node).
|
|
16
|
+
// Static structure carries no markers and is claimed by position.
|
|
17
|
+
|
|
18
|
+
/** Comment markers bounding a dynamic text hole in server HTML. */
|
|
19
|
+
export const HOLE_START = "$";
|
|
20
|
+
export const HOLE_END = "/";
|
|
21
|
+
|
|
22
|
+
/** Comment markers bounding a variable structural region — a list or a conditional —
|
|
23
|
+
* in server HTML (docs/HYDRATION.md §3.1): `<!--[-->` opens, `<!--]-->` closes. A list
|
|
24
|
+
* holds one item root node per item; a conditional holds the currently rendered branch
|
|
25
|
+
* (or nothing). The closing marker becomes the reconcile/swap anchor on hydration. */
|
|
26
|
+
export const REGION_START = "[";
|
|
27
|
+
export const REGION_END = "]";
|
|
28
|
+
|
|
29
|
+
/** Markers bounding a *component's* light-DOM `{children}` slot (docs/HYDRATION.md §3.1,
|
|
30
|
+
* 2.1d) — distinct bytes from the list/conditional region markers so a component's own
|
|
31
|
+
* internal regions never collide with its slot. The slot's content is the *parent's* JSX
|
|
32
|
+
* (server-rendered here), so the parent finds this slot by its `<!--c[-->` marker within the
|
|
33
|
+
* host and adopts the children (its reactivity), while the component steps over the slot. */
|
|
34
|
+
export const SLOT_START = "c[";
|
|
35
|
+
export const SLOT_END = "c]";
|
|
36
|
+
|
|
37
|
+
const ELEMENT = 1;
|
|
38
|
+
const TEXT = 3;
|
|
39
|
+
const COMMENT = 8;
|
|
40
|
+
|
|
41
|
+
/** Thrown when the server DOM doesn't match the template at a claim site. The
|
|
42
|
+
* component boundary catches this and recovers by rebuilding via CSR (never silent;
|
|
43
|
+
* see docs/HYDRATION.md §3.5). */
|
|
44
|
+
export class HydrationMismatch extends Error {
|
|
45
|
+
constructor(message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = "HydrationMismatch";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── the hydration flag ────────────────────────────────────────────────────────
|
|
52
|
+
// True during the initial hydration pass. A component's connectedCallback reads it
|
|
53
|
+
// to decide whether to *adopt* its server children or *build* a fresh subtree — the
|
|
54
|
+
// custom-element-as-island coordination (docs/HYDRATION.md §3.4).
|
|
55
|
+
//
|
|
56
|
+
// Why a flag and not the per-instance `this.firstChild` test: a server-rendered host
|
|
57
|
+
// and a client-`createElement`'d host that was handed call-site children both have a
|
|
58
|
+
// `firstChild`, so the structural test alone can't tell "adopt the server DOM" from
|
|
59
|
+
// "build and slot these children" (it mis-adopts on plain SPA navigation). The flag is
|
|
60
|
+
// the unambiguous signal — it is only ever set during the one-shot first-paint pass.
|
|
61
|
+
let _hydrating = false;
|
|
62
|
+
|
|
63
|
+
/** Is the client mid-hydration right now? */
|
|
64
|
+
export function isHydrating() {
|
|
65
|
+
return _hydrating;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Run `fn` with the hydration flag set, restoring the prior value (nesting-safe).
|
|
69
|
+
* Synchronous only — for the async first-paint pass use {@link beginHydration} /
|
|
70
|
+
* {@link endHydration}, which span the route module's `import()`. */
|
|
71
|
+
export function runHydration(fn) {
|
|
72
|
+
const prev = _hydrating;
|
|
73
|
+
_hydrating = true;
|
|
74
|
+
try {
|
|
75
|
+
return fn();
|
|
76
|
+
} finally {
|
|
77
|
+
_hydrating = prev;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Route modules are code-split (`() => import(...)`), so a route's custom elements are
|
|
82
|
+
// defined — and its server-rendered hosts upgrade synchronously — *during* the router's
|
|
83
|
+
// `await import()`, before the page `hydrate` factory runs. The flag therefore has to be
|
|
84
|
+
// set across that whole async region, which a synchronous `runHydration` can't do; the
|
|
85
|
+
// router brackets first paint with these instead. First-paint hydration is a single,
|
|
86
|
+
// sequential boot step (no concurrent navigation), so a plain module-global set is safe.
|
|
87
|
+
|
|
88
|
+
/** Begin the first-paint hydration pass; pair with {@link endHydration}. Set *before*
|
|
89
|
+
* the route module is imported so every server host upgrading at `customElements.define`
|
|
90
|
+
* observes it and adopts (docs/HYDRATION.md §3.4). */
|
|
91
|
+
export function beginHydration() {
|
|
92
|
+
_hydrating = true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** End the first-paint hydration pass — subsequent client navigations build fresh. */
|
|
96
|
+
export function endHydration() {
|
|
97
|
+
_hydrating = false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── the serialized props payload (compiler-driven data hydration) ─────────────
|
|
101
|
+
// SSG/SSR embeds each island's rich props in a `<script type="application/json"
|
|
102
|
+
// id="__otfw_h">`, keyed by the host's `data-h` id (see server/ssg-runtime.js). A
|
|
103
|
+
// hydrating component reads its props from here — real JS values (objects, arrays,
|
|
104
|
+
// numbers), not lossy string attributes — so it resumes with correct data, with no flash
|
|
105
|
+
// and no dependence on a parent walk re-applying props. Read once and cached.
|
|
106
|
+
let _payload; // undefined = not yet read; null = none present; else the parsed array
|
|
107
|
+
|
|
108
|
+
function hydrationPayload() {
|
|
109
|
+
if (_payload === undefined) {
|
|
110
|
+
const el = typeof document !== "undefined" ? document.getElementById("__otfw_h") : null;
|
|
111
|
+
try {
|
|
112
|
+
_payload = el ? JSON.parse(el.textContent || "[]") : null;
|
|
113
|
+
} catch {
|
|
114
|
+
_payload = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return _payload;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The rich hydration props recorded for host `el` (keyed by its `data-h` id), or `null`
|
|
122
|
+
* when there is no payload for it — a client-`createElement`'d element on SPA navigation,
|
|
123
|
+
* or a plain CSR build. A hydrate-target component constructor reads this to initialize
|
|
124
|
+
* its prop signals, falling back to attributes/defaults when it returns `null`.
|
|
125
|
+
*/
|
|
126
|
+
export function hydrationProps(el) {
|
|
127
|
+
const data = hydrationPayload();
|
|
128
|
+
if (!data || !el || typeof el.getAttribute !== "function") return null;
|
|
129
|
+
const id = el.getAttribute("data-h");
|
|
130
|
+
if (id == null) return null;
|
|
131
|
+
const entry = data[+id];
|
|
132
|
+
return entry == null ? null : entry;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Reset the cached payload (tests only — a fresh document between cases). */
|
|
136
|
+
export function __resetHydrationPayload() {
|
|
137
|
+
_payload = undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── the cursor ────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/** A walker over `parent`'s children, positioned at the next node to claim. */
|
|
143
|
+
export function cursor(parent) {
|
|
144
|
+
return { node: parent.firstChild };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Advance past the next node without claiming it (a static text node between
|
|
148
|
+
* dynamic siblings), returning it. The template only emits a skip for a static
|
|
149
|
+
* `ViewNode::Text`, which the server renders as a real text node; assert that so a
|
|
150
|
+
* cursor misalignment surfaces *here* (with a clear message) instead of downstream at
|
|
151
|
+
* an unrelated claim. Throws {@link HydrationMismatch} on a wrong/absent node. */
|
|
152
|
+
export function skipNode(cur) {
|
|
153
|
+
const n = cur.node;
|
|
154
|
+
if (!n || n.nodeType !== TEXT) {
|
|
155
|
+
throw new HydrationMismatch(`expected static text, found ${describe(n)}`);
|
|
156
|
+
}
|
|
157
|
+
cur.node = n.nextSibling;
|
|
158
|
+
return n;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Claim the next node as an element, optionally asserting its tag, and advance the
|
|
163
|
+
* cursor past it. Returns the element so the caller can claim its children with a
|
|
164
|
+
* fresh `cursor(el)`. A wrong/absent node throws {@link HydrationMismatch}.
|
|
165
|
+
*/
|
|
166
|
+
export function claimElement(cur, tag) {
|
|
167
|
+
const el = cur.node;
|
|
168
|
+
if (!el || el.nodeType !== ELEMENT || (tag && el.tagName.toLowerCase() !== tag.toLowerCase())) {
|
|
169
|
+
throw new HydrationMismatch(`expected <${tag ?? "element"}>, found ${describe(el)}`);
|
|
170
|
+
}
|
|
171
|
+
cur.node = el.nextSibling;
|
|
172
|
+
return el;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Claim a dynamic text hole delimited by `<!--$-->…<!--/-->`, returning the text node
|
|
177
|
+
* to bind onto (created empty when the server rendered no value), and advance the
|
|
178
|
+
* cursor past the closing marker. Wire reactivity with `bindText(node, fn)` from
|
|
179
|
+
* dom.js — the binding logic is shared with CSR; only the node is adopted, not built.
|
|
180
|
+
*/
|
|
181
|
+
export function claimText(cur) {
|
|
182
|
+
const start = cur.node;
|
|
183
|
+
if (!start || start.nodeType !== COMMENT || start.data !== HOLE_START) {
|
|
184
|
+
throw new HydrationMismatch(`expected a text-hole marker, found ${describe(start)}`);
|
|
185
|
+
}
|
|
186
|
+
let node = start.nextSibling;
|
|
187
|
+
let textNode = null;
|
|
188
|
+
while (node && !(node.nodeType === COMMENT && node.data === HOLE_END)) {
|
|
189
|
+
if (node.nodeType === TEXT && !textNode) textNode = node;
|
|
190
|
+
node = node.nextSibling;
|
|
191
|
+
}
|
|
192
|
+
const end = node; // the `<!--/-->` marker (or null if the markup is malformed)
|
|
193
|
+
if (!textNode) {
|
|
194
|
+
// Empty hole (`<!--$--><!--/-->`): synthesize the anchor bindText will write into.
|
|
195
|
+
textNode = document.createTextNode("");
|
|
196
|
+
(start.parentNode || document).insertBefore(textNode, end);
|
|
197
|
+
}
|
|
198
|
+
cur.node = end ? end.nextSibling : null;
|
|
199
|
+
return textNode;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Claim the `<!--[-->` marker that opens a server-rendered region (list or conditional),
|
|
203
|
+
* advancing the cursor past it. Throws {@link HydrationMismatch} on a wrong/absent node. */
|
|
204
|
+
export function claimRegionStart(cur) {
|
|
205
|
+
const n = cur.node;
|
|
206
|
+
if (!n || n.nodeType !== COMMENT || n.data !== REGION_START) {
|
|
207
|
+
throw new HydrationMismatch(`expected a region start marker, found ${describe(n)}`);
|
|
208
|
+
}
|
|
209
|
+
cur.node = n.nextSibling;
|
|
210
|
+
return n;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Claim the `<!--]-->` marker that closes a region and return it — it becomes the
|
|
214
|
+
* reconcile/swap anchor (`hydrateList`/`hydrateChild` insert later nodes before it).
|
|
215
|
+
* Advances the cursor past it. Throws {@link HydrationMismatch} on a wrong/absent node. */
|
|
216
|
+
export function claimRegionEnd(cur) {
|
|
217
|
+
const n = cur.node;
|
|
218
|
+
if (!n || n.nodeType !== COMMENT || n.data !== REGION_END) {
|
|
219
|
+
throw new HydrationMismatch(`expected a region end marker, found ${describe(n)}`);
|
|
220
|
+
}
|
|
221
|
+
cur.node = n.nextSibling;
|
|
222
|
+
return n;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Step a component's own adopt walk past its `{children}` slot — claim the
|
|
226
|
+
* `<!--c[-->`…`<!--c]-->` markers and skip the slotted nodes (the parent owns their
|
|
227
|
+
* reactivity, wired separately by {@link hydrateSlot}), leaving them in place. Advances the
|
|
228
|
+
* cursor past the closing marker. Throws {@link HydrationMismatch} on a missing start marker. */
|
|
229
|
+
export function skipSlot(cur) {
|
|
230
|
+
const start = cur.node;
|
|
231
|
+
if (!start || start.nodeType !== COMMENT || start.data !== SLOT_START) {
|
|
232
|
+
throw new HydrationMismatch(`expected a children-slot marker, found ${describe(start)}`);
|
|
233
|
+
}
|
|
234
|
+
let n = start.nextSibling;
|
|
235
|
+
while (n && !(n.nodeType === COMMENT && n.data === SLOT_END)) n = n.nextSibling;
|
|
236
|
+
cur.node = n ? n.nextSibling : null; // step past the `<!--c]-->` end marker
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Adopt a component's slotted children — the *parent's* JSX, server-rendered inside the
|
|
241
|
+
* component host at its `{children}` slot. The parent owns their reactivity but the
|
|
242
|
+
* component decides where they sit, so the parent locates the slot by its `<!--c[-->` marker
|
|
243
|
+
* within `host` and adopts from the first slotted node via `adoptFn(cursor)`. Independent of
|
|
244
|
+
* when the component upgrades: the markers are static server DOM and adoption only *wires*
|
|
245
|
+
* (never moves) nodes, so it commutes with the component's own `skipSlot`. A missing marker
|
|
246
|
+
* (a rebuilt component, or no children) is a no-op.
|
|
247
|
+
*/
|
|
248
|
+
export function hydrateSlot(host, adoptFn) {
|
|
249
|
+
const start = findComment(host, SLOT_START);
|
|
250
|
+
if (start) adoptFn({ node: start.nextSibling });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The first descendant comment of `root` whose data is `data` (tree order). */
|
|
254
|
+
function findComment(root, data) {
|
|
255
|
+
const walker = document.createTreeWalker(root, 128 /* NodeFilter.SHOW_COMMENT */);
|
|
256
|
+
let n;
|
|
257
|
+
while ((n = walker.nextNode())) if (n.data === data) return n;
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** A short human description of a node for mismatch messages. */
|
|
262
|
+
function describe(node) {
|
|
263
|
+
if (!node) return "nothing";
|
|
264
|
+
if (node.nodeType === ELEMENT) return `<${node.tagName.toLowerCase()}>`;
|
|
265
|
+
if (node.nodeType === TEXT) return `text "${(node.data || "").slice(0, 20)}"`;
|
|
266
|
+
if (node.nodeType === COMMENT) return `comment <!--${node.data}-->`;
|
|
267
|
+
return `node(${node.nodeType})`;
|
|
268
|
+
}
|
package/runtime/index.js
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
// Built-in custom elements self-register on import. They're used in JSX as *tags*
|
|
2
|
-
// (`<Portal>` → `web-portal`), so their bindings are never referenced and a plain
|
|
2
|
+
// (`<Portal>` → `web-internal-portal`), so their bindings are never referenced and a plain
|
|
3
3
|
// re-export would be tree-shaken away (the element never registers). Bare imports
|
|
4
4
|
// force the registration side effect to be retained.
|
|
5
5
|
import "./context.js";
|
|
6
6
|
import "./error-boundary.js";
|
|
7
7
|
import "./portal.js";
|
|
8
|
+
import "./raw-html.js";
|
|
9
|
+
import "./code-block.js";
|
|
8
10
|
|
|
9
11
|
export * from "./dom.js";
|
|
10
12
|
export * from "./events.js";
|
|
11
13
|
export * from "./mount.js";
|
|
14
|
+
export * from "./hydrate.js";
|
|
12
15
|
export * from "./lifecycle.js";
|
|
13
16
|
export * from "./router.js";
|
|
14
17
|
export * from "./context.js";
|
|
15
18
|
export * from "./error-boundary.js";
|
|
16
19
|
export * from "./portal.js";
|
|
20
|
+
export * from "./raw-html.js";
|
|
21
|
+
export * from "./code-block.js";
|
|
22
|
+
export * from "./clipboard.js";
|
package/runtime/portal.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// <Portal to="#toast-root"> … </Portal> // selector, element, or default body
|
|
5
5
|
//
|
|
6
|
-
// `<Portal>` compiles to this built-in `web-portal` element (capitalized name →
|
|
6
|
+
// `<Portal>` compiles to this built-in `web-internal-portal` element (capitalized name →
|
|
7
7
|
// tag, like <ContextProvider>) — no compiler changes. At connect it moves its
|
|
8
8
|
// light-DOM children to the target; on disconnect it removes them so their
|
|
9
9
|
// custom-element disconnect + effect cleanups run (the part userland leaks).
|
|
@@ -52,8 +52,8 @@ export class PortalElement extends HTMLElement {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// Exported so `import { Portal } from "@opentf/web"` resolves; the import side
|
|
55
|
-
// effect registers the element (JSX uses the `web-portal` tag).
|
|
55
|
+
// effect registers the element (JSX uses the `web-internal-portal` tag).
|
|
56
56
|
export const Portal = PortalElement;
|
|
57
|
-
if (typeof customElements !== "undefined" && !customElements.get("web-portal")) {
|
|
58
|
-
customElements.define("web-portal", PortalElement);
|
|
57
|
+
if (typeof customElements !== "undefined" && !customElements.get("web-internal-portal")) {
|
|
58
|
+
customElements.define("web-internal-portal", PortalElement);
|
|
59
59
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//! RawHtml — render a trusted HTML string as light-DOM content.
|
|
2
|
+
//
|
|
3
|
+
// <RawHtml html={"<pre>…</pre>"} /> // compiles to the built-in `web-internal-raw-html`
|
|
4
|
+
//
|
|
5
|
+
// Used by the MDX front-end for build-time syntax-highlighted code blocks: the
|
|
6
|
+
// highlighted markup is static (no reactivity), so the element just assigns its
|
|
7
|
+
// `html` property to `innerHTML`. The string is treated as trusted — it comes from
|
|
8
|
+
// the compiler, not user input. SSG renders the string inline (server/builtins.js).
|
|
9
|
+
|
|
10
|
+
export class RawHtmlElement extends HTMLElement {
|
|
11
|
+
set html(v) {
|
|
12
|
+
this._html = v;
|
|
13
|
+
if (this.isConnected) this.innerHTML = v == null ? "" : String(v);
|
|
14
|
+
}
|
|
15
|
+
get html() {
|
|
16
|
+
return this._html;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
connectedCallback() {
|
|
20
|
+
// Apply once on connect (the property is set before append during CSR build).
|
|
21
|
+
if (this._html != null && !this._applied) {
|
|
22
|
+
this.innerHTML = String(this._html);
|
|
23
|
+
this._applied = true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Exported so `import { RawHtml } from "@opentf/web"` resolves; the import side
|
|
29
|
+
// effect registers the element (JSX uses the `web-internal-raw-html` tag).
|
|
30
|
+
export const RawHtml = RawHtmlElement;
|
|
31
|
+
if (typeof customElements !== "undefined" && !customElements.get("web-internal-raw-html")) {
|
|
32
|
+
customElements.define("web-internal-raw-html", RawHtmlElement);
|
|
33
|
+
}
|