what-core 0.12.3 → 0.13.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/src/render.js CHANGED
@@ -2,10 +2,10 @@
2
2
  // Solid-style rendering: components run once, signals create individual DOM effects.
3
3
  // No VDOM diffing — direct DOM manipulation with surgical signal-driven updates.
4
4
 
5
- import { effect, untrack, createRoot, _createItemScope, signal, memo, __DEV__ } from './reactive.js';
5
+ import { effect, untrack, _createItemScope, signal, memo, __DEV__ } from './reactive.js';
6
6
  import { __resetIdCounter } from './a11y.js';
7
- import { createDOM, disposeTree, getCurrentComponent, getComponentStack, addHydrationDisposer, addHydratedComponent, _setSelectValue, _isUnsafeAttr, _isEventProp, _installLazyChildren, _handleNavigationSignal } from './dom.js';
8
- import { _injectIslandRuntime } from './components.js';
7
+ import { createDOM, disposeTree, getComponentStack, addHydrationDisposer, addHydratedComponent, _setSelectValue, _isUnsafeAttr, _isEventProp, _installLazyChildren, _handleNavigationSignal } from './dom.js';
8
+ import { _injectIslandRuntime, reportError } from './components.js';
9
9
  export { effect, untrack };
10
10
  // Re-export memo for compiled output (branch memoization: the compiler emits
11
11
  // _$memo(() => cond) so conditional branches only re-create DOM when the
@@ -112,14 +112,14 @@ function _$templateImpl(html) {
112
112
  const t = document.createElement('template');
113
113
  t.innerHTML = tableInfo.wrap + trimmed + tableInfo.unwrap;
114
114
  // Pre-navigate to the target element once — avoids per-clone traversal.
115
- let target = t.content.firstChild;
116
- for (let i = 0; i < tableInfo.depth; i++) target = target.firstChild;
115
+ let target = /** @type {Node} */ (t.content.firstChild);
116
+ for (let i = 0; i < tableInfo.depth; i++) target = /** @type {Node} */ (target.firstChild);
117
117
  return () => target.cloneNode(true);
118
118
  }
119
119
 
120
120
  const t = document.createElement('template');
121
121
  t.innerHTML = trimmed;
122
- return () => t.content.firstChild.cloneNode(true);
122
+ return () => /** @type {Node} */ (t.content.firstChild).cloneNode(true);
123
123
  }
124
124
 
125
125
  // Public export — warns in dev mode that this is a compiler internal.
@@ -157,13 +157,13 @@ export function svgTemplate(html) {
157
157
  // Complete <svg> element — parse in a div (browsers handle the namespace)
158
158
  const t = document.createElement('template');
159
159
  t.innerHTML = trimmed;
160
- return () => t.content.firstChild.cloneNode(true);
160
+ return () => /** @type {Node} */ (t.content.firstChild).cloneNode(true);
161
161
  }
162
162
 
163
163
  // Inner SVG element (path, circle, g, etc.) — wrap in <svg> for namespace context
164
164
  const t = document.createElement('template');
165
165
  t.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg">${trimmed}</svg>`;
166
- return () => t.content.firstChild.firstChild.cloneNode(true);
166
+ return () => /** @type {Node} */ (/** @type {Node} */ (t.content.firstChild).firstChild).cloneNode(true);
167
167
  }
168
168
 
169
169
  // --- insert(parent, child, marker?) ---
@@ -1094,7 +1094,6 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
1094
1094
  // Backward move: old[from] = new[to], old[to..from-1] = new[to+1..from]
1095
1095
 
1096
1096
  const fromRel = mm1; // first mismatch - the moved item was here in old OR went here in new
1097
- let movedKey = null;
1098
1097
  let fromAbs = -1, toAbs = -1;
1099
1098
  let isMove = false;
1100
1099
 
@@ -1121,7 +1120,6 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
1121
1120
  isMove = true;
1122
1121
  fromAbs = start + fromRel;
1123
1122
  toAbs = start + destRel;
1124
- movedKey = candidateKey;
1125
1123
  }
1126
1124
  }
1127
1125
  }
@@ -1148,7 +1146,6 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
1148
1146
  isMove = true;
1149
1147
  fromAbs = start + srcRel;
1150
1148
  toAbs = start + fromRel;
1151
- movedKey = candidateKey2;
1152
1149
  }
1153
1150
  }
1154
1151
  }
@@ -1359,6 +1356,22 @@ export function spread(el, props) {
1359
1356
  for (const key in props) {
1360
1357
  const value = props[key];
1361
1358
 
1359
+ // Ref — the element, not a reactive getter.
1360
+ //
1361
+ // This is the one prop whose FUNCTION form is a callback taking the element
1362
+ // rather than an accessor returning a value, which is exactly why the other
1363
+ // two call sites special-case it before the reactive-prop test (setProp
1364
+ // below, and applyProps in dom.js). Spread did not, so a function ref fell
1365
+ // into the reactive branch and was invoked as `value()` with NO ARGUMENT.
1366
+ // Every `{...register('email')}`-shaped API broke in silence on the
1367
+ // compiled path: the ref saw `undefined`, guarded, and returned, so no
1368
+ // element was ever registered and nothing threw to say so.
1369
+ if (key === 'ref') {
1370
+ if (typeof value === 'function') value(el);
1371
+ else if (value && typeof value === 'object') value.current = el;
1372
+ continue;
1373
+ }
1374
+
1362
1375
  if (_isEventProp(key)) {
1363
1376
  // Event handler — direct assignment. Use $$name for delegated events.
1364
1377
  if (typeof value !== 'function') continue;
@@ -1376,7 +1389,7 @@ export function spread(el, props) {
1376
1389
  // If a previous spread/setProp already registered an effect for this
1377
1390
  // key, dispose it first to avoid double-tracking.
1378
1391
  if (el._propEffects[key]) {
1379
- try { el._propEffects[key](); } catch (e) { /* already disposed */ }
1392
+ try { el._propEffects[key](); } catch { /* already disposed */ }
1380
1393
  }
1381
1394
  if (key === 'class' || key === 'className') {
1382
1395
  el._propEffects[key] = effect(() => {
@@ -1425,7 +1438,7 @@ export function setProp(el, key, value) {
1425
1438
  if (typeof value === 'function' && !_isEventProp(key)) {
1426
1439
  if (!el._propEffects) el._propEffects = {};
1427
1440
  if (el._propEffects[key]) {
1428
- try { el._propEffects[key](); } catch (e) { /* already disposed */ }
1441
+ try { el._propEffects[key](); } catch { /* already disposed */ }
1429
1442
  }
1430
1443
  el._propEffects[key] = effect(() => setProp(el, key, value()));
1431
1444
  return;
@@ -1478,7 +1491,7 @@ export function setProp(el, key, value) {
1478
1491
  // and property-reflected branches. Reflected props (e.g. el.title) are reset
1479
1492
  // first so removeAttribute() clears both the attribute and the property.
1480
1493
  if (key in el) {
1481
- try { el[key] = ''; } catch (e) { /* read-only reflected prop */ }
1494
+ try { el[key] = ''; } catch { /* read-only reflected prop */ }
1482
1495
  }
1483
1496
  el.removeAttribute(key);
1484
1497
  } else if (key.startsWith('data-') || key.startsWith('aria-')) {
@@ -1513,7 +1526,7 @@ export function setProp(el, key, value) {
1513
1526
  function _wrapPropAccessor(el, key, accessor, apply) {
1514
1527
  if (!el._propEffects) el._propEffects = {};
1515
1528
  if (el._propEffects[key]) {
1516
- try { el._propEffects[key](); } catch (e) { /* already disposed */ }
1529
+ try { el._propEffects[key](); } catch { /* already disposed */ }
1517
1530
  }
1518
1531
  el._propEffects[key] = effect(() => apply(el, accessor()));
1519
1532
  }
@@ -1718,6 +1731,36 @@ function trimUnclaimed(parent) {
1718
1731
  }
1719
1732
  }
1720
1733
 
1734
+ /**
1735
+ * Comment markers that belong to the machinery, not to the page.
1736
+ *
1737
+ * '$' / '/$' and '[]' / '/[]' come from the server's hydratable output. The
1738
+ * rest are planted by the hydration walk itself as it goes: 'fn' / '/fn' bound
1739
+ * a reactive region (the function branch of hydrateNode), 'eb:*' and 'sb:*'
1740
+ * bound an <ErrorBoundary> or a <Suspense> (hydrateBoundary), and 'portal' /
1741
+ * 'portal:empty' are a <Portal>'s placeholder.
1742
+ *
1743
+ * In every case the cursor is advanced past the marker at the moment it goes
1744
+ * in, so a later sibling REACHING one means the cursor has desynced. Skipping
1745
+ * is what keeps that desync from turning destructive. No vnode form in this
1746
+ * framework produces a comment node, so the element and text branches treat a
1747
+ * claimed comment as a mismatch and replaceChild() it away: a sibling that
1748
+ * claimed a region's end marker would delete the marker, leave the region
1749
+ * unterminated, and send its next update walking off the end of the parent.
1750
+ * Losing one node's reuse is a scratch; losing a marker is fatal to the region.
1751
+ */
1752
+ const _HYDRATION_MARKERS = new Set([
1753
+ '$', '/$', '[]', '/[]',
1754
+ 'fn', '/fn',
1755
+ 'eb:start', 'eb:end',
1756
+ 'sb:start', 'sb:end',
1757
+ 'portal', 'portal:empty',
1758
+ ]);
1759
+
1760
+ function _isHydrationMarker(node) {
1761
+ return node.nodeType === 8 && _HYDRATION_MARKERS.has(node.textContent);
1762
+ }
1763
+
1721
1764
  /**
1722
1765
  * Claim the next DOM node from the hydration cursor.
1723
1766
  * Returns the existing DOM node or null if none available.
@@ -1726,17 +1769,9 @@ function claimNode(parent) {
1726
1769
  const children = parent.childNodes;
1727
1770
  while (_hydrationCursor.index < children.length) {
1728
1771
  const node = children[_hydrationCursor.index];
1729
- // Skip hydration comment markers. 'fn' / '/fn' are the reactive-region
1730
- // markers hydration itself inserts as it walks (see the function branch of
1731
- // hydrateNode); the cursor is adjusted when they go in, and skipping them
1732
- // here keeps a later sibling from ever claiming one as its node.
1733
- if (node.nodeType === 8) { // Comment node
1734
- const text = node.textContent;
1735
- if (text === '$' || text === '/$' || text === '[]' || text === '/[]'
1736
- || text === 'fn' || text === '/fn') {
1737
- _hydrationCursor.index++;
1738
- continue;
1739
- }
1772
+ if (_isHydrationMarker(node)) {
1773
+ _hydrationCursor.index++;
1774
+ continue;
1740
1775
  }
1741
1776
  _hydrationCursor.index++;
1742
1777
  return node;
@@ -1756,13 +1791,7 @@ function peekNode(parent) {
1756
1791
  const children = parent.childNodes;
1757
1792
  for (let i = _hydrationCursor.index; i < children.length; i++) {
1758
1793
  const node = children[i];
1759
- if (node.nodeType === 8) {
1760
- const text = node.textContent;
1761
- if (text === '$' || text === '/$' || text === '[]' || text === '/[]'
1762
- || text === 'fn' || text === '/fn') {
1763
- continue;
1764
- }
1765
- }
1794
+ if (_isHydrationMarker(node)) continue;
1766
1795
  return node;
1767
1796
  }
1768
1797
  return null;
@@ -2071,9 +2100,29 @@ function hydrateNode(vnode, parent) {
2071
2100
  if (endChildrenPass) endChildrenPass();
2072
2101
  } catch (error) {
2073
2102
  componentStack.pop();
2074
- // Same classification as createComponent: a navigation signal carries
2075
- // its own handler and is not a render failure.
2076
- if (!_handleNavigationSignal(error)) {
2103
+ // Same classification as createComponent, and it has to be the same or
2104
+ // the two paths disagree about what a throw MEANS:
2105
+ //
2106
+ // - a navigation signal carries its own handler and is not a failure.
2107
+ // - a thrown thenable is a SUSPENSION. It is how lazy() says "my
2108
+ // chunk has not landed yet", and during hydration that is not an
2109
+ // edge case but the normal one: on a real first load the dynamic
2110
+ // import is still in flight when hydrate() runs. Logging it and
2111
+ // returning null left `loading` unflipped, so the <Suspense> region
2112
+ // came out EMPTY, the server's fallback markup was left unclaimed
2113
+ // and then trimmed, and the chunk resolving re-rendered nothing.
2114
+ // The boundary sat permanently blank, which is the one outcome
2115
+ // Suspense exists to prevent.
2116
+ // - anything else is a real error and belongs to the nearest
2117
+ // <ErrorBoundary>, exactly as in a client-only render.
2118
+ //
2119
+ // Unlike createComponent this never RE-THROWS when nothing handles it.
2120
+ // An exception escaping here escapes hydrate() itself and the rest of
2121
+ // the page never hydrates at all; whatever this component was, its
2122
+ // siblings are still recoverable.
2123
+ if (!_handleNavigationSignal(error)
2124
+ && !(error && typeof error.then === 'function' && suspendDuringHydration(error, ctx))
2125
+ && !reportError(error, ctx)) {
2077
2126
  console.error('[what] Error in component during hydration:', Component.name || 'Anonymous', error);
2078
2127
  }
2079
2128
  return null;
@@ -2111,6 +2160,10 @@ function hydrateNode(vnode, parent) {
2111
2160
  // A region root falls back to the parent element instead. That disposes
2112
2161
  // later than ideal (when the parent goes, not when the component does),
2113
2162
  // and disposing late is strictly better than disposing while mounted.
2163
+ //
2164
+ // A boundary root needs no case here: hydrateBoundary returns its start
2165
+ // MARKER rather than its contents, and a marker is stable by
2166
+ // construction.
2114
2167
  const rootIsRegion = typeof result === 'function'
2115
2168
  || (Array.isArray(result) && result.some((child) => typeof child === 'function'));
2116
2169
  const first = Array.isArray(node) ? node[0] : node;
@@ -2122,6 +2175,65 @@ function hydrateNode(vnode, parent) {
2122
2175
  }
2123
2176
  }
2124
2177
 
2178
+ // Boundary marker tags — NOT elements, and never rendered as themselves.
2179
+ //
2180
+ // <ErrorBoundary>, <Suspense> and <Portal> each return one of these instead
2181
+ // of a DOM tag, and every other render path routes them to a boundary
2182
+ // handler rather than to createElement (dom.js createDOM, and the same
2183
+ // three tags in the server's renderer). Hydration was the one path with no
2184
+ // branch for them, so a marker tag fell through to the ELEMENT branch below
2185
+ // and went looking for a `<__errorBoundary>` element in the server HTML.
2186
+ // What it found was the first node of the boundary's OWN subtree, which it
2187
+ // warned about and destroyed:
2188
+ //
2189
+ // server: <div id="x"><p>INNER</p></div>
2190
+ // client: <div id="x"><!--eb:start--></div>
2191
+ //
2192
+ // One <ErrorBoundary> anywhere in a server-rendered page blanked everything
2193
+ // under it. The construct whose entire job is to contain a failure was
2194
+ // itself the failure.
2195
+ if (vnode.tag === '__errorBoundary') {
2196
+ const { errorState, fallback, reset, handleError } = vnode.props;
2197
+ return hydrateBoundary(vnode, parent, {
2198
+ startText: 'eb:start',
2199
+ endText: 'eb:end',
2200
+ ctxExtras: { _errorBoundary: handleError },
2201
+ state: errorState,
2202
+ contentFor: (error) => {
2203
+ if (!error) return vnode.children || [];
2204
+ return typeof fallback === 'function' ? fallback({ error, reset }) : fallback;
2205
+ },
2206
+ });
2207
+ }
2208
+
2209
+ if (vnode.tag === '__suspense') {
2210
+ const { boundary, fallback, loading } = vnode.props;
2211
+ return hydrateBoundary(vnode, parent, {
2212
+ startText: 'sb:start',
2213
+ endText: 'sb:end',
2214
+ ctxExtras: { _suspenseBoundary: boundary },
2215
+ state: loading,
2216
+ contentFor: (isLoading) => (isLoading ? fallback : (vnode.children || [])),
2217
+ });
2218
+ }
2219
+
2220
+ // <Portal> renders NOTHING on the server, by the same decision that makes
2221
+ // Portal() return null when there is no document: its content belongs to a
2222
+ // container somewhere else on the page, not to this position. So there is
2223
+ // no server markup here to claim and the portal mounts client-side exactly
2224
+ // as it does in a client-only render.
2225
+ //
2226
+ // The element branch did the opposite. It CLAIMED the next node, which is
2227
+ // the server's next real sibling, warned about a mismatch that never
2228
+ // existed, and replaced that sibling with the portal's placeholder comment.
2229
+ // The claimed node was destroyed and everything after it shifted, so a
2230
+ // portal in the middle of a server-rendered list cost every node behind it:
2231
+ // a modal host declared before the page content rebuilt the entire page.
2232
+ if (vnode.tag === '__portal') {
2233
+ const placeholder = createDOM(vnode, parent);
2234
+ return placeholder ? insertAtCursor(parent, placeholder) : null;
2235
+ }
2236
+
2125
2237
  // Element — claim existing DOM element
2126
2238
  //
2127
2239
  // The comparison is case-INSENSITIVE. `nodeName` is uppercased for HTML
@@ -2198,6 +2310,360 @@ function hydrateNode(vnode, parent) {
2198
2310
  return insertAtCursor(parent, document.createTextNode(String(vnode)));
2199
2311
  }
2200
2312
 
2313
+ /**
2314
+ * Hand a thrown thenable to the nearest <Suspense> above `ctx`.
2315
+ *
2316
+ * The twin of the private `suspend()` in dom.js: the same walk up the same
2317
+ * `_parentCtx` chain to the same `_suspenseBoundary`. It is written out again
2318
+ * rather than shared because dom.js keeps its copy module-private, and the two
2319
+ * halves it depends on (the chain, and the boundary's onSuspend) are fixed
2320
+ * shapes that createSuspenseBoundary and hydrateBoundary both build.
2321
+ *
2322
+ * Returns false when nothing above can take the suspension, which makes the
2323
+ * thenable an ordinary unhandled error again.
2324
+ */
2325
+ function suspendDuringHydration(promise, ctx) {
2326
+ for (let c = ctx; c; c = c._parentCtx) {
2327
+ if (c._suspenseBoundary) {
2328
+ c._suspenseBoundary.onSuspend(promise);
2329
+ return true;
2330
+ }
2331
+ }
2332
+ return false;
2333
+ }
2334
+
2335
+ /**
2336
+ * Evidence that the node the cursor is parked on is NOT the one `vnode` would
2337
+ * have produced on the server.
2338
+ *
2339
+ * A boundary's region has no delimiter in the server's bytes, so "the markup
2340
+ * here belongs to this boundary" can never be PROVEN from the client. It can
2341
+ * sometimes be refuted, and a refutation is all claimServerArm needs: a plain
2342
+ * element or text vnode names exactly the node it wants, so a <p> facing a
2343
+ * <footer> is a boundary reaching past its own region into its next sibling.
2344
+ *
2345
+ * Anything else — a component, a thunk, a nested boundary marker — cannot
2346
+ * answer without being run, and "cannot tell" is deliberately NOT a refutation.
2347
+ * Refusing there would give up the reuse for `fallback={() => <ErrorMessage />}`,
2348
+ * which is the shape most apps actually write.
2349
+ */
2350
+ function contradictsServerNode(vnode, node) {
2351
+ if (typeof vnode === 'string' || typeof vnode === 'number') return node.nodeType !== 3;
2352
+ if (vnode && vnode._vnode && typeof vnode.tag === 'string') {
2353
+ return node.nodeType !== 1 || node.nodeName.toLowerCase() !== vnode.tag.toLowerCase();
2354
+ }
2355
+ return false;
2356
+ }
2357
+
2358
+ /**
2359
+ * Claim the server's markup for a boundary's FALLBACK, when the server rendered
2360
+ * the fallback too.
2361
+ *
2362
+ * A child that throws during SSR is caught by the server's own boundary branch
2363
+ * (packages/server/src/index.js), so the response carries the fallback and NOT
2364
+ * the children. The same child throws again while hydrating, which flips the
2365
+ * boundary's signal — but by then hydrateBoundary has already walked the happy
2366
+ * arm, and the happy arm does not match a byte of what the server sent. The
2367
+ * fallback markup went unclaimed, the boundary's effect built a second copy of
2368
+ * it, and the first copy was trimmed: the server rendered the fallback and the
2369
+ * client threw it away and rebuilt it.
2370
+ *
2371
+ * What makes this recoverable is that a child which throws before producing
2372
+ * anything claims NOTHING, so the cursor is still parked exactly where the
2373
+ * server's markup for this boundary starts and nothing in the region has been
2374
+ * written over. Then the fallback can hydrate against it like ordinary markup.
2375
+ *
2376
+ * The refusals matter as much as the claim, because the region's extent is not
2377
+ * knowable from the client (see contradictsServerNode):
2378
+ *
2379
+ * - the failed arm produced something first, so the cursor has moved and
2380
+ * whatever it moved over has already been claimed or replaced. A child
2381
+ * ahead of the thrower is the ordinary case here: it claims the server's
2382
+ * fallback node, calls it a mismatch, and destroys it before the boundary
2383
+ * ever learns an error happened. Nothing left to reuse.
2384
+ * - the server left nothing at this position at all. The node at the cursor
2385
+ * then belongs to the boundary's next SIBLING, and claiming it would be the
2386
+ * <Portal> failure again: a boundary eating the footer behind it.
2387
+ * - the node that is there openly disagrees with the fallback's root.
2388
+ *
2389
+ * Every refusal falls back to the boundary's effect rebuilding the region,
2390
+ * which is what this whole path did before and is always correct — a lost
2391
+ * reuse, not a lost node.
2392
+ *
2393
+ * `getContent` is a thunk rather than a value so the refusals above cost
2394
+ * nothing: on a refusal the effect is the one that builds the fallback, and
2395
+ * running a user's `fallback={({ error }) => ...}` twice per catch to throw the
2396
+ * first result away is a side effect this has no business causing.
2397
+ *
2398
+ * Returns true when the region now holds the fallback.
2399
+ */
2400
+ function claimServerArm(parent, regionStart, getContent) {
2401
+ // No cursor in this parent means nothing here was being claimed from the
2402
+ // server in the first place.
2403
+ if (regionStart < 0 || !_hydrationCursor || _hydrationCursor.parent !== parent) return false;
2404
+
2405
+ // The failed arm has to have produced NOTHING. Any movement of the cursor is
2406
+ // a node this region has already committed to, claimed or created.
2407
+ if (_hydrationCursor.index !== regionStart) return false;
2408
+
2409
+ // Nothing at this position means there is nothing to reuse. Asked first
2410
+ // because it is the only question answerable without building the fallback.
2411
+ const candidate = peekNode(parent);
2412
+ if (!candidate) return false;
2413
+
2414
+ const content = getContent();
2415
+ const vnodes = Array.isArray(content) ? content : [content];
2416
+ const root = vnodes.find((v) => v != null && typeof v !== 'boolean');
2417
+
2418
+ // A fallback that renders nothing wants an empty region, and an empty region
2419
+ // is what it already has. Claimed, with nothing to claim — and `candidate` is
2420
+ // left for whoever it really belongs to.
2421
+ if (root === undefined) return true;
2422
+
2423
+ if (contradictsServerNode(root, candidate)) return false;
2424
+
2425
+ for (const v of vnodes) hydrateNode(v, parent);
2426
+ return true;
2427
+ }
2428
+
2429
+ /**
2430
+ * Hydrate an <ErrorBoundary> or a <Suspense>.
2431
+ *
2432
+ * The two are the same machine with a different signal: a marked region whose
2433
+ * contents are the children while the signal is falsy and the fallback once it
2434
+ * is not. The client builds both with createErrorBoundary / createSuspenseBoundary
2435
+ * in dom.js, and this is the hydrating twin of those two functions.
2436
+ *
2437
+ * Three things have to be true when this returns, and each was a separate bug:
2438
+ *
2439
+ * - the server's markup is still on screen. The children hydrate against it
2440
+ * in place; nothing is rebuilt.
2441
+ * - the boundary's context is on the component stack while those children
2442
+ * hydrate. reportError and suspend() both find their boundary by walking
2443
+ * `_parentCtx` up from the component that threw, so a boundary missing from
2444
+ * that chain catches nothing: the error escapes to the console and the page
2445
+ * dies exactly as it would with no boundary at all.
2446
+ * - the region is owned by an effect from here on, bounded by real comment
2447
+ * markers. Without the markers there is no insertion point and no stable
2448
+ * node to hang the disposer on, which is the same pair of failures the
2449
+ * reactive-region branch documents above.
2450
+ *
2451
+ * The first effect run is the subtle one. It must NOT rebuild what hydration
2452
+ * just claimed, or hydrating a boundary would be indistinguishable from
2453
+ * client-rendering it. But it cannot skip unconditionally either: a child that
2454
+ * threw or suspended WHILE hydrating flipped the signal before this effect
2455
+ * existed, and in that case the markup between the markers may be the wrong arm
2456
+ * and has to be replaced.
2457
+ *
2458
+ * "May be", not "is", and that is the whole of claimServerArm below. When a
2459
+ * child throws during the SERVER render the server catches it too and puts the
2460
+ * FALLBACK in the HTML, so the two sides agree on the arm and the fallback is
2461
+ * ordinary server markup that hydration should claim like any other. Hydrating
2462
+ * the happy arm first and then rebuilding on the flipped signal threw that
2463
+ * markup away and built a second copy of it, which is exactly the
2464
+ * destroy-and-rebuild these markers exist to stop.
2465
+ */
2466
+ function hydrateBoundary(vnode, parent, { startText, endText, ctxExtras, state, contentFor }) {
2467
+ const children = vnode.children || [];
2468
+ const cursorInParent = !!(_hydrationCursor && _hydrationCursor.parent === parent);
2469
+ const startComment = document.createComment(startText);
2470
+ const endComment = document.createComment(endText);
2471
+
2472
+ // Same shape as the contexts the client boundaries build, for the same
2473
+ // reasons: `_parentCtx` keeps useContext resolving through the boundary, and
2474
+ // the marker references let a teardown find the region from the context.
2475
+ const boundaryCtx = {
2476
+ hooks: [],
2477
+ hookIndex: 0,
2478
+ effects: [],
2479
+ cleanups: [],
2480
+ mounted: false,
2481
+ disposed: false,
2482
+ _parentCtx: captureOwner(),
2483
+ _startComment: startComment,
2484
+ _endComment: endComment,
2485
+ ...ctxExtras,
2486
+ };
2487
+
2488
+ // Open the region at the slot the cursor points at, before anything is
2489
+ // hydrated into it, so everything the children claim lands inside the pair.
2490
+ // (Anchoring the markers afterwards to whatever the children produced is
2491
+ // wrong for a boundary that produced nothing, and interleaves nested regions
2492
+ // instead of nesting them. See the reactive-region branch.)
2493
+ if (cursorInParent) {
2494
+ parent.insertBefore(startComment, parent.childNodes[_hydrationCursor.index] || null);
2495
+ _hydrationCursor.index++;
2496
+ } else {
2497
+ parent.appendChild(startComment);
2498
+ }
2499
+
2500
+ // Where the region's content begins, in cursor terms. The start marker has
2501
+ // already consumed its slot, so this is the index the server's first node for
2502
+ // this boundary sits at. claimServerArm needs it to tell "the failed arm
2503
+ // touched nothing" from "the failed arm got part way in".
2504
+ const regionStart = cursorInParent ? _hydrationCursor.index : -1;
2505
+
2506
+ const stack = getComponentStack();
2507
+ stack.push(boundaryCtx);
2508
+ try {
2509
+ for (const child of children) {
2510
+ hydrateNode(child, parent);
2511
+ }
2512
+ } finally {
2513
+ stack.pop();
2514
+ }
2515
+
2516
+ // Which arm the boundary is on now that its children have run.
2517
+ //
2518
+ // Read UNTRACKED. This is the hydration walk, not the effect below, and a
2519
+ // hydrate() reached from inside somebody else's effect would otherwise hand
2520
+ // that effect a subscription to this boundary's private error/loading signal:
2521
+ // an unrelated region upstream would re-render every time a boundary caught.
2522
+ const armAfterWalk = untrack(state);
2523
+
2524
+ // Whether the markup between the markers is already the arm `armAfterWalk`
2525
+ // names. True by construction when nothing flipped the signal (the walk just
2526
+ // claimed the children the server rendered), and true again when the fallback
2527
+ // below is claimed in place.
2528
+ let regionHoldsArm = !armAfterWalk;
2529
+
2530
+ if (armAfterWalk) {
2531
+ // Same re-push as the rebuild in the effect, for the same reason: a
2532
+ // fallback that renders a component of its own must see the boundary in its
2533
+ // parent chain, and contentFor is what runs that fallback.
2534
+ stack.push(boundaryCtx);
2535
+ try {
2536
+ regionHoldsArm = claimServerArm(parent, regionStart, () => contentFor(armAfterWalk));
2537
+ } finally {
2538
+ stack.pop();
2539
+ }
2540
+ }
2541
+
2542
+ if (cursorInParent) {
2543
+ parent.insertBefore(endComment, parent.childNodes[_hydrationCursor.index] || null);
2544
+ _hydrationCursor.index++;
2545
+ } else {
2546
+ parent.appendChild(endComment);
2547
+ }
2548
+
2549
+ let claimedFromServer = true;
2550
+ // Generation guard, carried over from createSuspenseBoundary in dom.js, where
2551
+ // it exists because a child suspending mid-rebuild flips the state signal from
2552
+ // inside the loop below: if that re-entered the effect, the inner run would
2553
+ // replace the region and the outer run would then append the rest of the arm
2554
+ // it was already committed to, putting both arms on screen at once.
2555
+ //
2556
+ // It is honest to say this is currently UNREACHABLE and kept for parity. An
2557
+ // effect cannot re-enter itself here: reactive.js's notify() only executes
2558
+ // subscribers at notifyDepth 0 and queues them otherwise, so a write made
2559
+ // during an effect's own run is always drained after that run returns. Every
2560
+ // shape tried against it (two- and three-deep lazy waterfalls, and a staged
2561
+ // suspender behind a signal so the effect was auto-promoted to _stable and
2562
+ // therefore running INLINE) came back with a nesting depth of 1.
2563
+ //
2564
+ // Keeping it costs four lines and removes a way for the two boundary
2565
+ // implementations to disagree. The invariant it leans on lives in another
2566
+ // module and is not part of any contract this one can see.
2567
+ let generation = 0;
2568
+ const dispose = effect(() => {
2569
+ const current = state();
2570
+
2571
+ if (claimedFromServer) {
2572
+ claimedFromServer = false;
2573
+ // The region already holds the arm this run would build: either the
2574
+ // children the server rendered and the walk claimed (the normal case), or
2575
+ // the fallback claimed in place by claimServerArm. The markup already
2576
+ // there IS the answer, so leave it alone.
2577
+ if (regionHoldsArm && current === armAfterWalk) return;
2578
+ }
2579
+
2580
+ const host = startComment.parentNode;
2581
+ if (!host) return; // region detached before this run; nothing to update
2582
+
2583
+ const gen = ++generation;
2584
+
2585
+ // Same teardown as the client boundaries: everything between the markers
2586
+ // goes, disposed first so nested effects and component contexts die with
2587
+ // the nodes rather than outliving them.
2588
+ while (startComment.nextSibling && startComment.nextSibling !== endComment) {
2589
+ const old = startComment.nextSibling;
2590
+ disposeTree(old);
2591
+ host.removeChild(old);
2592
+ }
2593
+
2594
+ // Re-push the boundary for the rebuild. This effect re-runs long after the
2595
+ // hydration walk has unwound the stack, and anything built with an empty
2596
+ // stack gets `parentCtx = null`: a fallback that itself contains a
2597
+ // component would sit outside every context it was written inside.
2598
+ stack.push(boundaryCtx);
2599
+ try {
2600
+ const content = contentFor(current);
2601
+ const vnodes = Array.isArray(content) ? content : [content];
2602
+ for (const v of vnodes) {
2603
+ const node = createDOM(v, host);
2604
+ if (gen !== generation) {
2605
+ // A newer run already rebuilt the region. Whatever this node is, it
2606
+ // belongs to a superseded arm: dispose it rather than insert it
2607
+ // alongside the arm that won.
2608
+ if (node) disposeTree(node);
2609
+ break;
2610
+ }
2611
+ // endComment can be gone if that newer run tore the region down.
2612
+ if (!node) continue;
2613
+ if (endComment.parentNode) endComment.parentNode.insertBefore(node, endComment);
2614
+ else disposeTree(node);
2615
+ }
2616
+ } finally {
2617
+ stack.pop();
2618
+ }
2619
+ });
2620
+
2621
+ // Put the cursor back where the END MARKER actually ended up.
2622
+ //
2623
+ // The effect above runs SYNCHRONOUSLY, and when the state was already truthy
2624
+ // it has just removed R nodes from the region and inserted I of its own. The
2625
+ // cursor was fixed at endComment+1 a few lines earlier and knows nothing
2626
+ // about that, so it is off by (R - I) and the rest of the walk pays:
2627
+ //
2628
+ // - drifting forward SKIPS the boundary's next server sibling, which then
2629
+ // warns "got nothing" and is rendered a SECOND time. A page with a
2630
+ // boundary above the footer got two footers.
2631
+ // - drifting backward makes that sibling claim a node it must not, which
2632
+ // before the marker skip list above meant claiming the boundary's own
2633
+ // end marker and replaceChild()ing it away.
2634
+ //
2635
+ // Re-reading the marker's real index is the same re-sync the _mapArray branch
2636
+ // does, and for the same reason: once something has moved nodes behind the
2637
+ // walk's back, the only trustworthy answer to "where is the cursor now" is
2638
+ // where the marker physically is.
2639
+ if (cursorInParent && _hydrationCursor && _hydrationCursor.parent === parent) {
2640
+ const endIndex = Array.prototype.indexOf.call(parent.childNodes, endComment);
2641
+ if (endIndex >= 0) _hydrationCursor.index = endIndex + 1;
2642
+ }
2643
+
2644
+ boundaryCtx.effects.push(dispose);
2645
+ // The client registers a boundary context in dom.js's comment->ctx WeakMap;
2646
+ // the hydration disposer registry is the same idea reached from out here, and
2647
+ // disposeTree walks both. Registered on BOTH markers, matching the
2648
+ // reactive-region branch: whichever one a teardown happens to walk, the
2649
+ // boundary dies. disposeComponent latches on ctx.disposed, so being reached
2650
+ // twice is harmless.
2651
+ addHydratedComponent(startComment, boundaryCtx);
2652
+ addHydratedComponent(endComment, boundaryCtx);
2653
+
2654
+ // The START MARKER is the boundary's node, not its current contents.
2655
+ //
2656
+ // This matches the client exactly: createErrorBoundary returns a fragment
2657
+ // whose first node is that same start comment. It also matters for whoever
2658
+ // hydrated us. A component anchors its context to the first node its output
2659
+ // produced, and the contents of a boundary are the one thing that is
2660
+ // guaranteed to be replaced later, so returning them handed the enclosing
2661
+ // component a self-destructing anchor: the boundary catching an error
2662
+ // disposed the very component that wrapped the boundary. The markers outlive
2663
+ // every value the region holds, which is what an anchor has to do.
2664
+ return startComment;
2665
+ }
2666
+
2201
2667
  /**
2202
2668
  * Apply props to an existing hydrated element.
2203
2669
  * Attaches event handlers and reactive bindings without re-creating the element.