@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/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
- if (value instanceof Node || Array.isArray(value)) {
175
- node.data = "";
176
- const parent = node.parentNode;
177
- if (parent) {
178
- for (const n of toNodes(value)) {
179
- parent.insertBefore(n, node);
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
- /** Wire an element attribute/prop to a reactive expression. Returns the disposer. */
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
- return effect(() => setAttr(el, name, fn()));
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
- let current = [];
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
- let cache = new Map(); // key -> { sig, node }
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
- nodes.push(entry.node);
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
- // Place nodes in order, just before the anchor (works even after the
271
- // initial fragment has been flushed into the real parent).
272
- const host = anchor.parentNode || parent;
273
- let ref = anchor;
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
- const node = nodes[i];
276
- if (node.nextSibling !== ref) host.insertBefore(node, ref);
277
- ref = node;
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
+ }