what-core 0.12.2 → 0.12.4
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/dist/{chunk-NCPX66TV.min.js → chunk-M5GDJRVX.min.js} +1 -1
- package/dist/chunk-T2SKNKT5.min.js +11 -0
- package/dist/index.min.js +5 -5
- package/dist/render.min.js +1 -1
- package/dist/testing.min.js +1 -1
- package/index.d.ts +329 -40
- package/package.json +1 -1
- package/src/a11y.js +234 -23
- package/src/agent-context.js +1 -1
- package/src/animation.js +8 -0
- package/src/data.js +730 -105
- package/src/dom.js +52 -0
- package/src/errors.js +12 -1
- package/src/form.js +329 -31
- package/src/hooks.js +20 -3
- package/src/index.js +6 -0
- package/src/render.js +872 -50
- package/src/scheduler.js +17 -0
- package/src/warnings.js +83 -0
- package/dist/chunk-RXISSKLI.min.js +0 -11
package/src/render.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { effect, untrack, createRoot, _createItemScope, signal, memo, __DEV__ } from './reactive.js';
|
|
6
6
|
import { __resetIdCounter } from './a11y.js';
|
|
7
7
|
import { createDOM, disposeTree, getCurrentComponent, getComponentStack, addHydrationDisposer, addHydratedComponent, _setSelectValue, _isUnsafeAttr, _isEventProp, _installLazyChildren, _handleNavigationSignal } from './dom.js';
|
|
8
|
-
import { _injectIslandRuntime } from './components.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
|
|
@@ -196,7 +196,20 @@ export function insert(parent, child, marker) {
|
|
|
196
196
|
let current = null;
|
|
197
197
|
let textNode = null; // non-null while on the text fast path
|
|
198
198
|
let mounted = false;
|
|
199
|
-
|
|
199
|
+
// Capture the owning component at CREATION time. See the identical capture
|
|
200
|
+
// in createDOM's reactive branch (dom.js): this effect re-runs long after
|
|
201
|
+
// the synchronous render that created it, when the component stack is
|
|
202
|
+
// empty, so everything it builds on a re-run got parentCtx = null and the
|
|
203
|
+
// owner chain was severed. useContext then fell through to the context
|
|
204
|
+
// DEFAULT, and an ErrorBoundary stopped catching throws from components
|
|
205
|
+
// created by an inner region. Both work on first paint and only break once
|
|
206
|
+
// the app is interactive, which is why nothing caught it.
|
|
207
|
+
//
|
|
208
|
+
// dom.js was given this fix; this path, the one the COMPILER emits for
|
|
209
|
+
// every `{() => ...}`, was not. So it was broken for exactly the users on
|
|
210
|
+
// the recommended build setup.
|
|
211
|
+
const owner = captureOwner();
|
|
212
|
+
effect(() => withOwner(owner, () => {
|
|
200
213
|
const val = child();
|
|
201
214
|
const vt = typeof val;
|
|
202
215
|
if (!mounted) {
|
|
@@ -223,7 +236,7 @@ export function insert(parent, child, marker) {
|
|
|
223
236
|
// Type changed (or never was text) — full reconcile
|
|
224
237
|
textNode = null;
|
|
225
238
|
current = reconcileInsert(parent, val, current, m);
|
|
226
|
-
});
|
|
239
|
+
}));
|
|
227
240
|
return current;
|
|
228
241
|
}
|
|
229
242
|
|
|
@@ -263,6 +276,32 @@ function isSvgParent(parent) {
|
|
|
263
276
|
&& parent.tagName !== 'foreignObject';
|
|
264
277
|
}
|
|
265
278
|
|
|
279
|
+
// --- Owner capture for effects that outlive their render ---
|
|
280
|
+
//
|
|
281
|
+
// A reactive region's effect re-runs long after the synchronous render that
|
|
282
|
+
// created it, when the component stack has unwound. Anything it builds then has
|
|
283
|
+
// no owning component, which severs the chain that useContext and the
|
|
284
|
+
// ErrorBoundary / Suspense lookups both walk. Capturing the owner at creation
|
|
285
|
+
// and re-pushing it for the duration of each re-run restores it.
|
|
286
|
+
|
|
287
|
+
function captureOwner() {
|
|
288
|
+
const stack = getComponentStack();
|
|
289
|
+
return stack[stack.length - 1] || null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function withOwner(owner, fn) {
|
|
293
|
+
const stack = getComponentStack();
|
|
294
|
+
// Already on top during the initial synchronous run; only re-push when the
|
|
295
|
+
// stack has since unwound.
|
|
296
|
+
const restore = owner !== null && stack[stack.length - 1] !== owner;
|
|
297
|
+
if (restore) stack.push(owner);
|
|
298
|
+
try {
|
|
299
|
+
return fn();
|
|
300
|
+
} finally {
|
|
301
|
+
if (restore) stack.pop();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
266
305
|
function asNodeArray(value) {
|
|
267
306
|
if (value == null) return [];
|
|
268
307
|
return Array.isArray(value) ? value : [value];
|
|
@@ -504,9 +543,36 @@ export function mapArray(source, mapFn, options) {
|
|
|
504
543
|
return endMarker;
|
|
505
544
|
};
|
|
506
545
|
inserter._mapArray = true;
|
|
546
|
+
// The server has no DOM to insert into, so it cannot call the inserter at all.
|
|
547
|
+
// Without these it fell through to the generic reactive-child branch, which
|
|
548
|
+
// calls the value with no arguments: `parent` was undefined, the insertBefore
|
|
549
|
+
// threw, SSR swallowed it, and every compiled keyed list rendered as an EMPTY
|
|
550
|
+
// container. Exposing the inputs lets the server produce the same rows the
|
|
551
|
+
// client will, in the same order, without touching a DOM.
|
|
552
|
+
inserter._mapArraySource = source;
|
|
553
|
+
inserter._mapArrayFn = mapFn;
|
|
554
|
+
inserter._mapArrayKeyed = !!keyFn && !raw;
|
|
507
555
|
return inserter;
|
|
508
556
|
}
|
|
509
557
|
|
|
558
|
+
/**
|
|
559
|
+
* Render a mapArray inserter's rows without a DOM. Server-side only.
|
|
560
|
+
*
|
|
561
|
+
* Mirrors the item protocol reconcileKeyed/reconcileList use, because a row
|
|
562
|
+
* built here is hydrated by one built there: keyed non-raw mode hands the mapFn
|
|
563
|
+
* a signal ACCESSOR (so `item()` works), every other mode hands it the raw item.
|
|
564
|
+
* Getting this wrong produces server HTML that differs from the client's on
|
|
565
|
+
* every row.
|
|
566
|
+
*/
|
|
567
|
+
export function _mapArrayToArray(inserter) {
|
|
568
|
+
const items = inserter._mapArraySource() || [];
|
|
569
|
+
const mapFn = inserter._mapArrayFn;
|
|
570
|
+
const keyed = inserter._mapArrayKeyed;
|
|
571
|
+
return items.map((item, index) => (
|
|
572
|
+
keyed ? mapFn(() => item, index) : mapFn(item, index)
|
|
573
|
+
));
|
|
574
|
+
}
|
|
575
|
+
|
|
510
576
|
function reconcileList(parent, endMarker, oldItems, newItems, mappedNodes, disposeFns, mapFn) {
|
|
511
577
|
const newLen = newItems.length;
|
|
512
578
|
const oldLen = oldItems.length;
|
|
@@ -1293,6 +1359,22 @@ export function spread(el, props) {
|
|
|
1293
1359
|
for (const key in props) {
|
|
1294
1360
|
const value = props[key];
|
|
1295
1361
|
|
|
1362
|
+
// Ref — the element, not a reactive getter.
|
|
1363
|
+
//
|
|
1364
|
+
// This is the one prop whose FUNCTION form is a callback taking the element
|
|
1365
|
+
// rather than an accessor returning a value, which is exactly why the other
|
|
1366
|
+
// two call sites special-case it before the reactive-prop test (setProp
|
|
1367
|
+
// below, and applyProps in dom.js). Spread did not, so a function ref fell
|
|
1368
|
+
// into the reactive branch and was invoked as `value()` with NO ARGUMENT.
|
|
1369
|
+
// Every `{...register('email')}`-shaped API broke in silence on the
|
|
1370
|
+
// compiled path: the ref saw `undefined`, guarded, and returned, so no
|
|
1371
|
+
// element was ever registered and nothing threw to say so.
|
|
1372
|
+
if (key === 'ref') {
|
|
1373
|
+
if (typeof value === 'function') value(el);
|
|
1374
|
+
else if (value && typeof value === 'object') value.current = el;
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1296
1378
|
if (_isEventProp(key)) {
|
|
1297
1379
|
// Event handler — direct assignment. Use $$name for delegated events.
|
|
1298
1380
|
if (typeof value !== 'function') continue;
|
|
@@ -1608,6 +1690,15 @@ export function hydrate(vnode, container) {
|
|
|
1608
1690
|
|
|
1609
1691
|
try {
|
|
1610
1692
|
const result = hydrateNode(vnode, container);
|
|
1693
|
+
// Same trim as every nested element, with one exclusion. A dedicated root
|
|
1694
|
+
// element holds nothing but the app, so anything the walk did not claim is
|
|
1695
|
+
// stranded server markup. <body> and <html> are different: they also hold
|
|
1696
|
+
// the script tags, the hydration payload and whatever the host page put
|
|
1697
|
+
// there, none of which the walk claims and none of which may be removed.
|
|
1698
|
+
// An app that hydrates into <body> keeps the old behavior.
|
|
1699
|
+
if (container !== document.body && container !== document.documentElement) {
|
|
1700
|
+
trimUnclaimed(container);
|
|
1701
|
+
}
|
|
1611
1702
|
return result;
|
|
1612
1703
|
} finally {
|
|
1613
1704
|
_isHydrating = false;
|
|
@@ -1615,6 +1706,64 @@ export function hydrate(vnode, container) {
|
|
|
1615
1706
|
}
|
|
1616
1707
|
}
|
|
1617
1708
|
|
|
1709
|
+
/**
|
|
1710
|
+
* Drop server-rendered nodes that the client's walk never claimed.
|
|
1711
|
+
*
|
|
1712
|
+
* Everything from the cursor to the end of `parent` is content the server
|
|
1713
|
+
* produced and the client tree has no child for. Nothing references it, no
|
|
1714
|
+
* effect owns it, and no later update can ever reach it: it is stranded markup
|
|
1715
|
+
* that simply stays on screen.
|
|
1716
|
+
*
|
|
1717
|
+
* The case that makes this necessary is a reactive region that is empty on the
|
|
1718
|
+
* client and was NOT empty on the server. An empty region deliberately claims
|
|
1719
|
+
* nothing (claiming took the following sibling and destroyed it, cascading a
|
|
1720
|
+
* warn-and-recreate through the rest of the parent), so the element the server
|
|
1721
|
+
* rendered in its place has nothing to remove it. A cart badge the server drew
|
|
1722
|
+
* for a signed-in visitor stayed visible to a signed-out one, underneath the
|
|
1723
|
+
* region that was supposed to have replaced it.
|
|
1724
|
+
*
|
|
1725
|
+
* The two halves are what make each other safe: the walk never destroys a node
|
|
1726
|
+
* it is unsure about, and this removes what the finished walk proves is unused.
|
|
1727
|
+
*/
|
|
1728
|
+
function trimUnclaimed(parent) {
|
|
1729
|
+
if (!_hydrationCursor || _hydrationCursor.parent !== parent) return;
|
|
1730
|
+
while (parent.childNodes.length > _hydrationCursor.index) {
|
|
1731
|
+
const node = parent.lastChild;
|
|
1732
|
+
disposeTree(node);
|
|
1733
|
+
parent.removeChild(node);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
/**
|
|
1738
|
+
* Comment markers that belong to the machinery, not to the page.
|
|
1739
|
+
*
|
|
1740
|
+
* '$' / '/$' and '[]' / '/[]' come from the server's hydratable output. The
|
|
1741
|
+
* rest are planted by the hydration walk itself as it goes: 'fn' / '/fn' bound
|
|
1742
|
+
* a reactive region (the function branch of hydrateNode), 'eb:*' and 'sb:*'
|
|
1743
|
+
* bound an <ErrorBoundary> or a <Suspense> (hydrateBoundary), and 'portal' /
|
|
1744
|
+
* 'portal:empty' are a <Portal>'s placeholder.
|
|
1745
|
+
*
|
|
1746
|
+
* In every case the cursor is advanced past the marker at the moment it goes
|
|
1747
|
+
* in, so a later sibling REACHING one means the cursor has desynced. Skipping
|
|
1748
|
+
* is what keeps that desync from turning destructive. No vnode form in this
|
|
1749
|
+
* framework produces a comment node, so the element and text branches treat a
|
|
1750
|
+
* claimed comment as a mismatch and replaceChild() it away: a sibling that
|
|
1751
|
+
* claimed a region's end marker would delete the marker, leave the region
|
|
1752
|
+
* unterminated, and send its next update walking off the end of the parent.
|
|
1753
|
+
* Losing one node's reuse is a scratch; losing a marker is fatal to the region.
|
|
1754
|
+
*/
|
|
1755
|
+
const _HYDRATION_MARKERS = new Set([
|
|
1756
|
+
'$', '/$', '[]', '/[]',
|
|
1757
|
+
'fn', '/fn',
|
|
1758
|
+
'eb:start', 'eb:end',
|
|
1759
|
+
'sb:start', 'sb:end',
|
|
1760
|
+
'portal', 'portal:empty',
|
|
1761
|
+
]);
|
|
1762
|
+
|
|
1763
|
+
function _isHydrationMarker(node) {
|
|
1764
|
+
return node.nodeType === 8 && _HYDRATION_MARKERS.has(node.textContent);
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1618
1767
|
/**
|
|
1619
1768
|
* Claim the next DOM node from the hydration cursor.
|
|
1620
1769
|
* Returns the existing DOM node or null if none available.
|
|
@@ -1623,13 +1772,9 @@ function claimNode(parent) {
|
|
|
1623
1772
|
const children = parent.childNodes;
|
|
1624
1773
|
while (_hydrationCursor.index < children.length) {
|
|
1625
1774
|
const node = children[_hydrationCursor.index];
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
if (text === '$' || text === '/$' || text === '[]' || text === '/[]') {
|
|
1630
|
-
_hydrationCursor.index++;
|
|
1631
|
-
continue;
|
|
1632
|
-
}
|
|
1775
|
+
if (_isHydrationMarker(node)) {
|
|
1776
|
+
_hydrationCursor.index++;
|
|
1777
|
+
continue;
|
|
1633
1778
|
}
|
|
1634
1779
|
_hydrationCursor.index++;
|
|
1635
1780
|
return node;
|
|
@@ -1637,8 +1782,54 @@ function claimNode(parent) {
|
|
|
1637
1782
|
return null;
|
|
1638
1783
|
}
|
|
1639
1784
|
|
|
1785
|
+
/**
|
|
1786
|
+
* What claimNode would return next, without consuming it.
|
|
1787
|
+
*
|
|
1788
|
+
* Used by the branches that must decide whether the server left something
|
|
1789
|
+
* REUSABLE here before they commit to taking it. Claiming first and putting it
|
|
1790
|
+
* back is not possible: claiming is what advances the walk.
|
|
1791
|
+
*/
|
|
1792
|
+
function peekNode(parent) {
|
|
1793
|
+
if (!_hydrationCursor || _hydrationCursor.parent !== parent) return null;
|
|
1794
|
+
const children = parent.childNodes;
|
|
1795
|
+
for (let i = _hydrationCursor.index; i < children.length; i++) {
|
|
1796
|
+
const node = children[i];
|
|
1797
|
+
if (_isHydrationMarker(node)) continue;
|
|
1798
|
+
return node;
|
|
1799
|
+
}
|
|
1800
|
+
return null;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* Put a client-created node in at the cursor and advance past it.
|
|
1805
|
+
*
|
|
1806
|
+
* The mismatch fallbacks used to appendChild here, which puts the node at the
|
|
1807
|
+
* END of the parent rather than at the position being hydrated, and left the
|
|
1808
|
+
* cursor pointing AT it. Inside a reactive region that was fatal: the region's
|
|
1809
|
+
* end marker is placed at the cursor, so it landed BEFORE the content, the
|
|
1810
|
+
* region owned nothing, and it could never remove or replace what it had just
|
|
1811
|
+
* rendered. A `<Show>` whose server arm produced nothing showed its client arm
|
|
1812
|
+
* once and then ignored the signal forever.
|
|
1813
|
+
*/
|
|
1814
|
+
function insertAtCursor(parent, node) {
|
|
1815
|
+
if (_hydrationCursor && _hydrationCursor.parent === parent) {
|
|
1816
|
+
parent.insertBefore(node, parent.childNodes[_hydrationCursor.index] || null);
|
|
1817
|
+
_hydrationCursor.index++;
|
|
1818
|
+
} else {
|
|
1819
|
+
parent.appendChild(node);
|
|
1820
|
+
}
|
|
1821
|
+
return node;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
// Warnings only. Never gate a DOM CORRECTION on this: see the text branch below.
|
|
1825
|
+
//
|
|
1826
|
+
// This used to test `process.env.NODE_ENV` directly, which is unreachable in a
|
|
1827
|
+
// browser (there is no `process`), so hydration warnings could not fire in the
|
|
1828
|
+
// one environment where hydration actually runs. __DEV__ resolves the same
|
|
1829
|
+
// question across every environment, including a buildless browser app that
|
|
1830
|
+
// opts in with globalThis.__WHAT_DEV__.
|
|
1640
1831
|
function isDevMode() {
|
|
1641
|
-
return
|
|
1832
|
+
return __DEV__;
|
|
1642
1833
|
}
|
|
1643
1834
|
|
|
1644
1835
|
function hydrateNode(vnode, parent) {
|
|
@@ -1648,21 +1839,63 @@ function hydrateNode(vnode, parent) {
|
|
|
1648
1839
|
|
|
1649
1840
|
// Text node
|
|
1650
1841
|
if (typeof vnode === 'string' || typeof vnode === 'number') {
|
|
1651
|
-
const existing = claimNode(parent);
|
|
1652
1842
|
const text = String(vnode);
|
|
1653
1843
|
|
|
1844
|
+
// An empty string never DESTROYS anything to claim it.
|
|
1845
|
+
//
|
|
1846
|
+
// HTML cannot serialize an empty text node, so a reactive child that was
|
|
1847
|
+
// empty on the server emitted nothing at all. Claiming unconditionally took
|
|
1848
|
+
// the next sibling, saw an element where it wanted text, and replaced that
|
|
1849
|
+
// element with an empty text node: the server's real markup was destroyed,
|
|
1850
|
+
// every following sibling shifted, and a warn-and-recreate cascaded through
|
|
1851
|
+
// the rest of the parent. `{() => error()}` next to anything hit it.
|
|
1852
|
+
//
|
|
1853
|
+
// But refusing to claim ANYTHING was the opposite error. When the server
|
|
1854
|
+
// rendered real text here and the client now evaluates to '', the server's
|
|
1855
|
+
// text is exactly what has to be cleared. Skipping it left the stale value
|
|
1856
|
+
// on screen and then rendered the next value ALONGSIDE it ("9 items3
|
|
1857
|
+
// items"), because the region had adopted an empty node of its own while
|
|
1858
|
+
// the server's text sat outside it.
|
|
1859
|
+
//
|
|
1860
|
+
// So: claim a text node if one is there (the client value wins, same as
|
|
1861
|
+
// below), and claim nothing otherwise. The empty and non-empty cases now
|
|
1862
|
+
// differ only in refusing to consume a NON-text node.
|
|
1863
|
+
if (text === '') {
|
|
1864
|
+
const reusable = peekNode(parent);
|
|
1865
|
+
if (reusable && reusable.nodeType === 3) {
|
|
1866
|
+
claimNode(parent);
|
|
1867
|
+
reusable.textContent = '';
|
|
1868
|
+
return reusable;
|
|
1869
|
+
}
|
|
1870
|
+
return insertAtCursor(parent, document.createTextNode(''));
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
const existing = claimNode(parent);
|
|
1874
|
+
|
|
1654
1875
|
if (existing && existing.nodeType === 3) {
|
|
1655
|
-
// Reuse text node
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1876
|
+
// Reuse the text node, but the CLIENT value wins.
|
|
1877
|
+
//
|
|
1878
|
+
// Correcting the DOM used to sit inside the dev-only branch, and dev mode
|
|
1879
|
+
// was decided by `process.env.NODE_ENV`, which no browser has. The result
|
|
1880
|
+
// was that in every real browser a differing value was silently discarded
|
|
1881
|
+
// and the server's text stayed on screen until some later write happened
|
|
1882
|
+
// to touch that node. Any state the server cannot know (a cart restored
|
|
1883
|
+
// from localStorage, a saved theme, a relative timestamp) rendered stale
|
|
1884
|
+
// and looked like a broken store rather than a hydration bug.
|
|
1885
|
+
//
|
|
1886
|
+
// The correction is unconditional now. Only the warning is dev-gated.
|
|
1887
|
+
if (existing.textContent !== text) {
|
|
1888
|
+
if (isDevMode()) {
|
|
1889
|
+
console.warn(
|
|
1890
|
+
`[what] Hydration mismatch: expected text "${text}", got "${existing.textContent}"`
|
|
1891
|
+
);
|
|
1892
|
+
}
|
|
1660
1893
|
existing.textContent = text;
|
|
1661
1894
|
}
|
|
1662
1895
|
return existing;
|
|
1663
1896
|
}
|
|
1664
1897
|
|
|
1665
|
-
// Mismatch: expected text node, got element or nothing
|
|
1898
|
+
// Mismatch: expected text node, got element or nothing.
|
|
1666
1899
|
if (isDevMode()) {
|
|
1667
1900
|
console.warn(
|
|
1668
1901
|
`[what] Hydration mismatch: expected text node "${text}", got ${existing ? existing.nodeName : 'nothing'}. Falling back to client render.`
|
|
@@ -1672,7 +1905,7 @@ function hydrateNode(vnode, parent) {
|
|
|
1672
1905
|
if (existing) {
|
|
1673
1906
|
parent.replaceChild(textNode, existing);
|
|
1674
1907
|
} else {
|
|
1675
|
-
parent
|
|
1908
|
+
insertAtCursor(parent, textNode);
|
|
1676
1909
|
}
|
|
1677
1910
|
return textNode;
|
|
1678
1911
|
}
|
|
@@ -1682,21 +1915,140 @@ function hydrateNode(vnode, parent) {
|
|
|
1682
1915
|
return hydrateNode(vnode(), parent);
|
|
1683
1916
|
}
|
|
1684
1917
|
|
|
1685
|
-
//
|
|
1918
|
+
// Compiled keyed list. `.map()` with a key prop, and `<For>`, lower to a
|
|
1919
|
+
// mapArray INSERTER, which is a function taking (parent, marker) rather than a
|
|
1920
|
+
// thunk returning a value. The generic reactive branch below called it with no
|
|
1921
|
+
// arguments, so it threw on `parent.insertBefore` and the exception escaped
|
|
1922
|
+
// hydrate(): the whole page stopped hydrating and stayed inert. That is the
|
|
1923
|
+
// ordinary shape for a compiled app whose server HTML came from an uncompiled
|
|
1924
|
+
// render, which is exactly what the fullstack template produces.
|
|
1925
|
+
//
|
|
1926
|
+
// The list builds its own rows rather than claiming the server's. That is a
|
|
1927
|
+
// missed reuse, not a correctness problem: the inserter owns its end marker
|
|
1928
|
+
// and its effect from here on, and the server's rows are left unclaimed, so
|
|
1929
|
+
// trimUnclaimed removes them once the walk finishes. Claiming them properly
|
|
1930
|
+
// needs the list's own boundary markers in the server HTML, which is the same
|
|
1931
|
+
// thing reactive regions need and is tracked for 0.13.0.
|
|
1932
|
+
if (typeof vnode === 'function' && vnode._mapArray) {
|
|
1933
|
+
const cursorInParent = !!(_hydrationCursor && _hydrationCursor.parent === parent);
|
|
1934
|
+
const anchor = cursorInParent ? (parent.childNodes[_hydrationCursor.index] || null) : null;
|
|
1935
|
+
const endMarker = vnode(parent, anchor);
|
|
1936
|
+
if (cursorInParent) {
|
|
1937
|
+
const index = Array.prototype.indexOf.call(parent.childNodes, endMarker);
|
|
1938
|
+
if (index >= 0) _hydrationCursor.index = index + 1;
|
|
1939
|
+
}
|
|
1940
|
+
return endMarker;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// Reactive function child: attach an effect to the existing nodes
|
|
1686
1944
|
if (typeof vnode === 'function') {
|
|
1687
|
-
//
|
|
1688
|
-
|
|
1689
|
-
|
|
1945
|
+
// Bound the region with the same comment markers the client render path
|
|
1946
|
+
// uses (see the reactive-function branch of createDOM in dom.js). Hydration
|
|
1947
|
+
// used to create none, and paid for it twice on the first update:
|
|
1948
|
+
//
|
|
1949
|
+
// - reconcileInsert was handed a null marker, so it had no insertion
|
|
1950
|
+
// point and appended to the END of the parent. A hydrated <Show> that
|
|
1951
|
+
// flipped arms jumped to the bottom of its container, because a
|
|
1952
|
+
// component realizes to a DocumentFragment and fragments deliberately
|
|
1953
|
+
// skip the replace-in-place fast path.
|
|
1954
|
+
// - the effect's disposer was attached to the CONTENT node, so removing
|
|
1955
|
+
// that content disposed the effect. The region then stopped reacting
|
|
1956
|
+
// entirely: a <Show> broke position on its first flip and went dead on
|
|
1957
|
+
// its second.
|
|
1958
|
+
//
|
|
1959
|
+
// Markers are stable nodes that outlive every value the region ever holds,
|
|
1960
|
+
// which is exactly why the client path has them. Client-only rendering was
|
|
1961
|
+
// always correct here; only the SSR path was missing them.
|
|
1962
|
+
//
|
|
1963
|
+
// The start marker goes in BEFORE the value is hydrated, at the slot the
|
|
1964
|
+
// cursor is pointing at. Anchoring afterwards to the first content node was
|
|
1965
|
+
// wrong in two ways that both showed up as content in the wrong place:
|
|
1966
|
+
//
|
|
1967
|
+
// - a value of null/false/undefined claims no node, so there was no anchor
|
|
1968
|
+
// and both markers were appended to the END of the parent. `<Show>` with
|
|
1969
|
+
// no fallback, or `{cond && <X/>}`, permanently lost its position: the
|
|
1970
|
+
// content appeared below every following sibling once it filled in.
|
|
1971
|
+
// - a NESTED region hydrates while we are still inside this one and
|
|
1972
|
+
// inserts its own markers around the content first. Anchoring to the
|
|
1973
|
+
// content then put the outer start marker INSIDE the inner pair, so the
|
|
1974
|
+
// regions interleaved instead of nesting. Switching the outer arm
|
|
1975
|
+
// removed the content but neither the inner markers nor the inner
|
|
1976
|
+
// effect, which kept rendering into a region that was switched off and
|
|
1977
|
+
// duplicated it when the outer arm came back. `<Show>` wrapping
|
|
1978
|
+
// `<Show>` or `<For>` is the canonical shape, not an exotic one.
|
|
1979
|
+
//
|
|
1980
|
+
// Opening the region first makes both cases fall out: everything the value
|
|
1981
|
+
// hydrates lands after the start marker, and the end marker closes at
|
|
1982
|
+
// wherever the cursor ends up.
|
|
1983
|
+
const cursorInParent = !!(_hydrationCursor && _hydrationCursor.parent === parent);
|
|
1984
|
+
const startMarker = document.createComment('fn');
|
|
1985
|
+
const endMarker = document.createComment('/fn');
|
|
1986
|
+
|
|
1987
|
+
if (cursorInParent) {
|
|
1988
|
+
parent.insertBefore(startMarker, parent.childNodes[_hydrationCursor.index] || null);
|
|
1989
|
+
_hydrationCursor.index++;
|
|
1990
|
+
} else {
|
|
1991
|
+
parent.appendChild(startMarker);
|
|
1992
|
+
}
|
|
1690
1993
|
|
|
1691
|
-
//
|
|
1692
|
-
|
|
1994
|
+
// Hydrate the value for its side effects: it claims the server's nodes and,
|
|
1995
|
+
// if it contains a nested region, inserts that region's markers. What it
|
|
1996
|
+
// RETURNS is deliberately ignored, because it is not the region's contents:
|
|
1997
|
+
// a nested region's markers are not in it. The tracked set is read back from
|
|
1998
|
+
// the DOM below, between the markers, which is the actual boundary.
|
|
1999
|
+
hydrateNode(vnode(), parent);
|
|
2000
|
+
|
|
2001
|
+
if (cursorInParent) {
|
|
2002
|
+
parent.insertBefore(endMarker, parent.childNodes[_hydrationCursor.index] || null);
|
|
2003
|
+
_hydrationCursor.index++;
|
|
2004
|
+
} else {
|
|
2005
|
+
parent.appendChild(endMarker);
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
// The region owns EVERYTHING between its markers, not just the nodes its own
|
|
2009
|
+
// value produced. A nested region leaves its markers in here too, and those
|
|
2010
|
+
// markers carry the disposer for the nested effect.
|
|
2011
|
+
//
|
|
2012
|
+
// Tracking only the value's own nodes meant that switching this region off
|
|
2013
|
+
// removed the visible content and left the inner markers and the inner
|
|
2014
|
+
// effect behind. The orphaned effect kept rendering into a region that was
|
|
2015
|
+
// switched off, and its output reappeared, doubled, when this region came
|
|
2016
|
+
// back. Collecting from the DOM is also more honest than reasoning about
|
|
2017
|
+
// what hydrateNode returned: the markers are the boundary, so whatever sits
|
|
2018
|
+
// between them is the content.
|
|
2019
|
+
const owned = [];
|
|
2020
|
+
for (let node = startMarker.nextSibling; node && node !== endMarker; node = node.nextSibling) {
|
|
2021
|
+
owned.push(node);
|
|
2022
|
+
}
|
|
2023
|
+
let current = owned.length === 0 ? null : (owned.length === 1 ? owned[0] : owned);
|
|
2024
|
+
|
|
2025
|
+
// Set up reactive effect for future updates (normal rendering path).
|
|
2026
|
+
// The owner is captured for the same reason as in insert() and createDOM:
|
|
2027
|
+
// every re-run happens with the component stack unwound.
|
|
2028
|
+
const owner = captureOwner();
|
|
2029
|
+
const dispose = effect(() => withOwner(owner, () => {
|
|
1693
2030
|
const value = vnode();
|
|
1694
2031
|
// After hydration, this runs as normal insert
|
|
1695
2032
|
if (!_isHydrating) {
|
|
1696
|
-
current = reconcileInsert(parent, value, current,
|
|
2033
|
+
current = reconcileInsert(endMarker.parentNode || parent, value, current, endMarker);
|
|
1697
2034
|
}
|
|
1698
|
-
});
|
|
1699
|
-
|
|
2035
|
+
}));
|
|
2036
|
+
|
|
2037
|
+
// The disposer is now reachable from three places (either marker via
|
|
2038
|
+
// disposeTree, and the hydration disposer registry), which is deliberate:
|
|
2039
|
+
// whichever one the teardown happens to walk, the effect dies. It must
|
|
2040
|
+
// therefore be idempotent, or a tree disposed through more than one route
|
|
2041
|
+
// decrements the live-effect count once per route.
|
|
2042
|
+
let disposed = false;
|
|
2043
|
+
const disposeOnce = () => {
|
|
2044
|
+
if (disposed) return;
|
|
2045
|
+
disposed = true;
|
|
2046
|
+
dispose();
|
|
2047
|
+
};
|
|
2048
|
+
|
|
2049
|
+
startMarker._dispose = disposeOnce;
|
|
2050
|
+
endMarker._dispose = disposeOnce;
|
|
2051
|
+
addHydrationDisposer(startMarker, disposeOnce);
|
|
1700
2052
|
return current;
|
|
1701
2053
|
}
|
|
1702
2054
|
|
|
@@ -1751,9 +2103,29 @@ function hydrateNode(vnode, parent) {
|
|
|
1751
2103
|
if (endChildrenPass) endChildrenPass();
|
|
1752
2104
|
} catch (error) {
|
|
1753
2105
|
componentStack.pop();
|
|
1754
|
-
// Same classification as createComponent
|
|
1755
|
-
//
|
|
1756
|
-
|
|
2106
|
+
// Same classification as createComponent, and it has to be the same or
|
|
2107
|
+
// the two paths disagree about what a throw MEANS:
|
|
2108
|
+
//
|
|
2109
|
+
// - a navigation signal carries its own handler and is not a failure.
|
|
2110
|
+
// - a thrown thenable is a SUSPENSION. It is how lazy() says "my
|
|
2111
|
+
// chunk has not landed yet", and during hydration that is not an
|
|
2112
|
+
// edge case but the normal one: on a real first load the dynamic
|
|
2113
|
+
// import is still in flight when hydrate() runs. Logging it and
|
|
2114
|
+
// returning null left `loading` unflipped, so the <Suspense> region
|
|
2115
|
+
// came out EMPTY, the server's fallback markup was left unclaimed
|
|
2116
|
+
// and then trimmed, and the chunk resolving re-rendered nothing.
|
|
2117
|
+
// The boundary sat permanently blank, which is the one outcome
|
|
2118
|
+
// Suspense exists to prevent.
|
|
2119
|
+
// - anything else is a real error and belongs to the nearest
|
|
2120
|
+
// <ErrorBoundary>, exactly as in a client-only render.
|
|
2121
|
+
//
|
|
2122
|
+
// Unlike createComponent this never RE-THROWS when nothing handles it.
|
|
2123
|
+
// An exception escaping here escapes hydrate() itself and the rest of
|
|
2124
|
+
// the page never hydrates at all; whatever this component was, its
|
|
2125
|
+
// siblings are still recoverable.
|
|
2126
|
+
if (!_handleNavigationSignal(error)
|
|
2127
|
+
&& !(error && typeof error.then === 'function' && suspendDuringHydration(error, ctx))
|
|
2128
|
+
&& !reportError(error, ctx)) {
|
|
1757
2129
|
console.error('[what] Error in component during hydration:', Component.name || 'Anonymous', error);
|
|
1758
2130
|
}
|
|
1759
2131
|
return null;
|
|
@@ -1776,22 +2148,108 @@ function hydrateNode(vnode, parent) {
|
|
|
1776
2148
|
// createComponent in dom.js.
|
|
1777
2149
|
try {
|
|
1778
2150
|
const node = hydrateNode(result, parent);
|
|
1779
|
-
// No comment markers exist on this path, so
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
2151
|
+
// No comment markers exist for a COMPONENT on this path, so the ctx has
|
|
2152
|
+
// to hang off some node that disposeTree will reach, or it leaks.
|
|
2153
|
+
//
|
|
2154
|
+
// Anchoring it to the first node the component produced is only valid
|
|
2155
|
+
// when that node is stable. If the component's root is a reactive
|
|
2156
|
+
// region, that node is the region's current CONTENT, and the region
|
|
2157
|
+
// replaces it on the very first update: disposeTree then ran over it and
|
|
2158
|
+
// took the whole component context with it. Every effect, cleanup and
|
|
2159
|
+
// onCleanup the component owns died the first time its own root
|
|
2160
|
+
// re-rendered, which is the same create-outside/dispose-inside-an-effect
|
|
2161
|
+
// shape the region markers exist to prevent.
|
|
2162
|
+
//
|
|
2163
|
+
// A region root falls back to the parent element instead. That disposes
|
|
2164
|
+
// later than ideal (when the parent goes, not when the component does),
|
|
2165
|
+
// and disposing late is strictly better than disposing while mounted.
|
|
2166
|
+
//
|
|
2167
|
+
// A boundary root needs no case here: hydrateBoundary returns its start
|
|
2168
|
+
// MARKER rather than its contents, and a marker is stable by
|
|
2169
|
+
// construction.
|
|
2170
|
+
const rootIsRegion = typeof result === 'function'
|
|
2171
|
+
|| (Array.isArray(result) && result.some((child) => typeof child === 'function'));
|
|
1782
2172
|
const first = Array.isArray(node) ? node[0] : node;
|
|
1783
|
-
|
|
2173
|
+
const anchor = (!rootIsRegion && first && first.nodeType) ? first : parent;
|
|
2174
|
+
addHydratedComponent(anchor, ctx);
|
|
1784
2175
|
return node;
|
|
1785
2176
|
} finally {
|
|
1786
2177
|
componentStack.pop();
|
|
1787
2178
|
}
|
|
1788
2179
|
}
|
|
1789
2180
|
|
|
2181
|
+
// Boundary marker tags — NOT elements, and never rendered as themselves.
|
|
2182
|
+
//
|
|
2183
|
+
// <ErrorBoundary>, <Suspense> and <Portal> each return one of these instead
|
|
2184
|
+
// of a DOM tag, and every other render path routes them to a boundary
|
|
2185
|
+
// handler rather than to createElement (dom.js createDOM, and the same
|
|
2186
|
+
// three tags in the server's renderer). Hydration was the one path with no
|
|
2187
|
+
// branch for them, so a marker tag fell through to the ELEMENT branch below
|
|
2188
|
+
// and went looking for a `<__errorBoundary>` element in the server HTML.
|
|
2189
|
+
// What it found was the first node of the boundary's OWN subtree, which it
|
|
2190
|
+
// warned about and destroyed:
|
|
2191
|
+
//
|
|
2192
|
+
// server: <div id="x"><p>INNER</p></div>
|
|
2193
|
+
// client: <div id="x"><!--eb:start--></div>
|
|
2194
|
+
//
|
|
2195
|
+
// One <ErrorBoundary> anywhere in a server-rendered page blanked everything
|
|
2196
|
+
// under it. The construct whose entire job is to contain a failure was
|
|
2197
|
+
// itself the failure.
|
|
2198
|
+
if (vnode.tag === '__errorBoundary') {
|
|
2199
|
+
const { errorState, fallback, reset, handleError } = vnode.props;
|
|
2200
|
+
return hydrateBoundary(vnode, parent, {
|
|
2201
|
+
startText: 'eb:start',
|
|
2202
|
+
endText: 'eb:end',
|
|
2203
|
+
ctxExtras: { _errorBoundary: handleError },
|
|
2204
|
+
state: errorState,
|
|
2205
|
+
contentFor: (error) => {
|
|
2206
|
+
if (!error) return vnode.children || [];
|
|
2207
|
+
return typeof fallback === 'function' ? fallback({ error, reset }) : fallback;
|
|
2208
|
+
},
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
if (vnode.tag === '__suspense') {
|
|
2213
|
+
const { boundary, fallback, loading } = vnode.props;
|
|
2214
|
+
return hydrateBoundary(vnode, parent, {
|
|
2215
|
+
startText: 'sb:start',
|
|
2216
|
+
endText: 'sb:end',
|
|
2217
|
+
ctxExtras: { _suspenseBoundary: boundary },
|
|
2218
|
+
state: loading,
|
|
2219
|
+
contentFor: (isLoading) => (isLoading ? fallback : (vnode.children || [])),
|
|
2220
|
+
});
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
// <Portal> renders NOTHING on the server, by the same decision that makes
|
|
2224
|
+
// Portal() return null when there is no document: its content belongs to a
|
|
2225
|
+
// container somewhere else on the page, not to this position. So there is
|
|
2226
|
+
// no server markup here to claim and the portal mounts client-side exactly
|
|
2227
|
+
// as it does in a client-only render.
|
|
2228
|
+
//
|
|
2229
|
+
// The element branch did the opposite. It CLAIMED the next node, which is
|
|
2230
|
+
// the server's next real sibling, warned about a mismatch that never
|
|
2231
|
+
// existed, and replaced that sibling with the portal's placeholder comment.
|
|
2232
|
+
// The claimed node was destroyed and everything after it shifted, so a
|
|
2233
|
+
// portal in the middle of a server-rendered list cost every node behind it:
|
|
2234
|
+
// a modal host declared before the page content rebuilt the entire page.
|
|
2235
|
+
if (vnode.tag === '__portal') {
|
|
2236
|
+
const placeholder = createDOM(vnode, parent);
|
|
2237
|
+
return placeholder ? insertAtCursor(parent, placeholder) : null;
|
|
2238
|
+
}
|
|
2239
|
+
|
|
1790
2240
|
// Element — claim existing DOM element
|
|
2241
|
+
//
|
|
2242
|
+
// The comparison is case-INSENSITIVE. `nodeName` is uppercased for HTML
|
|
2243
|
+
// elements but case-preserved for everything else, so an SVG element's
|
|
2244
|
+
// nodeName is 'svg' and could never equal `tag.toUpperCase()`. Every inline
|
|
2245
|
+
// SVG on a server-rendered page therefore failed to match, warned
|
|
2246
|
+
// "expected <svg>, got svg", and was destroyed and rebuilt: with
|
|
2247
|
+
// document.createElement, in the HTML namespace, which does not render as
|
|
2248
|
+
// SVG at all. Icons, logos and charts went blank on hydration.
|
|
1791
2249
|
const existing = claimNode(parent);
|
|
1792
|
-
const expectedTag = vnode.tag.
|
|
2250
|
+
const expectedTag = vnode.tag.toLowerCase();
|
|
1793
2251
|
|
|
1794
|
-
if (existing && existing.nodeType === 1 && existing.nodeName === expectedTag) {
|
|
2252
|
+
if (existing && existing.nodeType === 1 && existing.nodeName.toLowerCase() === expectedTag) {
|
|
1795
2253
|
// Match! Reuse this element. Apply props/bindings.
|
|
1796
2254
|
hydrateElementProps(existing, vnode.props || {});
|
|
1797
2255
|
|
|
@@ -1804,6 +2262,20 @@ function hydrateNode(vnode, parent) {
|
|
|
1804
2262
|
for (const child of vnode.children) {
|
|
1805
2263
|
hydrateNode(child, existing);
|
|
1806
2264
|
}
|
|
2265
|
+
// Only when the client tree actually declares children here.
|
|
2266
|
+
//
|
|
2267
|
+
// An element the client says is EMPTY is not the same claim as "the
|
|
2268
|
+
// server's content is stale". An island is the counter-example that
|
|
2269
|
+
// matters: it renders a bare host element and fills it in later, when
|
|
2270
|
+
// its trigger fires, from the server HTML still sitting inside it.
|
|
2271
|
+
// Trimming on an empty child list threw that content away and the
|
|
2272
|
+
// island rebuilt it from scratch, which is the exact opposite of what
|
|
2273
|
+
// an island is for (a `mode: 'static'` island, which never hydrates at
|
|
2274
|
+
// all, simply lost its content).
|
|
2275
|
+
//
|
|
2276
|
+
// dangerouslySetInnerHTML is excluded above for the same reason: the
|
|
2277
|
+
// cursor never walks that subtree, so nothing in it is ever claimed.
|
|
2278
|
+
if (vnode.children.length > 0) trimUnclaimed(existing);
|
|
1807
2279
|
}
|
|
1808
2280
|
|
|
1809
2281
|
_hydrationCursor = savedCursor;
|
|
@@ -1817,19 +2289,17 @@ function hydrateNode(vnode, parent) {
|
|
|
1817
2289
|
);
|
|
1818
2290
|
}
|
|
1819
2291
|
|
|
1820
|
-
// Create the element from scratch
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
reconcileInsert(newEl, child, null, null);
|
|
1828
|
-
}
|
|
2292
|
+
// Create the element from scratch, through the same path a client-only
|
|
2293
|
+
// render uses. The hand-rolled version here called document.createElement
|
|
2294
|
+
// and setProp with no SVG context, so a rebuilt <svg> landed in the XHTML
|
|
2295
|
+
// namespace and rendered as nothing at all, and its attributes were set as
|
|
2296
|
+
// properties rather than attributes. Falling back to a client render has to
|
|
2297
|
+
// mean the client render, not an approximation of it.
|
|
2298
|
+
const newEl = createDOM(vnode, parent, isSvgParent(parent));
|
|
1829
2299
|
if (existing) {
|
|
1830
2300
|
parent.replaceChild(newEl, existing);
|
|
1831
2301
|
} else {
|
|
1832
|
-
parent
|
|
2302
|
+
insertAtCursor(parent, newEl);
|
|
1833
2303
|
}
|
|
1834
2304
|
return newEl;
|
|
1835
2305
|
}
|
|
@@ -1840,9 +2310,361 @@ function hydrateNode(vnode, parent) {
|
|
|
1840
2310
|
}
|
|
1841
2311
|
|
|
1842
2312
|
// Fallback — create text node
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
2313
|
+
return insertAtCursor(parent, document.createTextNode(String(vnode)));
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
/**
|
|
2317
|
+
* Hand a thrown thenable to the nearest <Suspense> above `ctx`.
|
|
2318
|
+
*
|
|
2319
|
+
* The twin of the private `suspend()` in dom.js: the same walk up the same
|
|
2320
|
+
* `_parentCtx` chain to the same `_suspenseBoundary`. It is written out again
|
|
2321
|
+
* rather than shared because dom.js keeps its copy module-private, and the two
|
|
2322
|
+
* halves it depends on (the chain, and the boundary's onSuspend) are fixed
|
|
2323
|
+
* shapes that createSuspenseBoundary and hydrateBoundary both build.
|
|
2324
|
+
*
|
|
2325
|
+
* Returns false when nothing above can take the suspension, which makes the
|
|
2326
|
+
* thenable an ordinary unhandled error again.
|
|
2327
|
+
*/
|
|
2328
|
+
function suspendDuringHydration(promise, ctx) {
|
|
2329
|
+
for (let c = ctx; c; c = c._parentCtx) {
|
|
2330
|
+
if (c._suspenseBoundary) {
|
|
2331
|
+
c._suspenseBoundary.onSuspend(promise);
|
|
2332
|
+
return true;
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
return false;
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
/**
|
|
2339
|
+
* Evidence that the node the cursor is parked on is NOT the one `vnode` would
|
|
2340
|
+
* have produced on the server.
|
|
2341
|
+
*
|
|
2342
|
+
* A boundary's region has no delimiter in the server's bytes, so "the markup
|
|
2343
|
+
* here belongs to this boundary" can never be PROVEN from the client. It can
|
|
2344
|
+
* sometimes be refuted, and a refutation is all claimServerArm needs: a plain
|
|
2345
|
+
* element or text vnode names exactly the node it wants, so a <p> facing a
|
|
2346
|
+
* <footer> is a boundary reaching past its own region into its next sibling.
|
|
2347
|
+
*
|
|
2348
|
+
* Anything else — a component, a thunk, a nested boundary marker — cannot
|
|
2349
|
+
* answer without being run, and "cannot tell" is deliberately NOT a refutation.
|
|
2350
|
+
* Refusing there would give up the reuse for `fallback={() => <ErrorMessage />}`,
|
|
2351
|
+
* which is the shape most apps actually write.
|
|
2352
|
+
*/
|
|
2353
|
+
function contradictsServerNode(vnode, node) {
|
|
2354
|
+
if (typeof vnode === 'string' || typeof vnode === 'number') return node.nodeType !== 3;
|
|
2355
|
+
if (vnode && vnode._vnode && typeof vnode.tag === 'string') {
|
|
2356
|
+
return node.nodeType !== 1 || node.nodeName.toLowerCase() !== vnode.tag.toLowerCase();
|
|
2357
|
+
}
|
|
2358
|
+
return false;
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
/**
|
|
2362
|
+
* Claim the server's markup for a boundary's FALLBACK, when the server rendered
|
|
2363
|
+
* the fallback too.
|
|
2364
|
+
*
|
|
2365
|
+
* A child that throws during SSR is caught by the server's own boundary branch
|
|
2366
|
+
* (packages/server/src/index.js), so the response carries the fallback and NOT
|
|
2367
|
+
* the children. The same child throws again while hydrating, which flips the
|
|
2368
|
+
* boundary's signal — but by then hydrateBoundary has already walked the happy
|
|
2369
|
+
* arm, and the happy arm does not match a byte of what the server sent. The
|
|
2370
|
+
* fallback markup went unclaimed, the boundary's effect built a second copy of
|
|
2371
|
+
* it, and the first copy was trimmed: the server rendered the fallback and the
|
|
2372
|
+
* client threw it away and rebuilt it.
|
|
2373
|
+
*
|
|
2374
|
+
* What makes this recoverable is that a child which throws before producing
|
|
2375
|
+
* anything claims NOTHING, so the cursor is still parked exactly where the
|
|
2376
|
+
* server's markup for this boundary starts and nothing in the region has been
|
|
2377
|
+
* written over. Then the fallback can hydrate against it like ordinary markup.
|
|
2378
|
+
*
|
|
2379
|
+
* The refusals matter as much as the claim, because the region's extent is not
|
|
2380
|
+
* knowable from the client (see contradictsServerNode):
|
|
2381
|
+
*
|
|
2382
|
+
* - the failed arm produced something first, so the cursor has moved and
|
|
2383
|
+
* whatever it moved over has already been claimed or replaced. A child
|
|
2384
|
+
* ahead of the thrower is the ordinary case here: it claims the server's
|
|
2385
|
+
* fallback node, calls it a mismatch, and destroys it before the boundary
|
|
2386
|
+
* ever learns an error happened. Nothing left to reuse.
|
|
2387
|
+
* - the server left nothing at this position at all. The node at the cursor
|
|
2388
|
+
* then belongs to the boundary's next SIBLING, and claiming it would be the
|
|
2389
|
+
* <Portal> failure again: a boundary eating the footer behind it.
|
|
2390
|
+
* - the node that is there openly disagrees with the fallback's root.
|
|
2391
|
+
*
|
|
2392
|
+
* Every refusal falls back to the boundary's effect rebuilding the region,
|
|
2393
|
+
* which is what this whole path did before and is always correct — a lost
|
|
2394
|
+
* reuse, not a lost node.
|
|
2395
|
+
*
|
|
2396
|
+
* `getContent` is a thunk rather than a value so the refusals above cost
|
|
2397
|
+
* nothing: on a refusal the effect is the one that builds the fallback, and
|
|
2398
|
+
* running a user's `fallback={({ error }) => ...}` twice per catch to throw the
|
|
2399
|
+
* first result away is a side effect this has no business causing.
|
|
2400
|
+
*
|
|
2401
|
+
* Returns true when the region now holds the fallback.
|
|
2402
|
+
*/
|
|
2403
|
+
function claimServerArm(parent, regionStart, getContent) {
|
|
2404
|
+
// No cursor in this parent means nothing here was being claimed from the
|
|
2405
|
+
// server in the first place.
|
|
2406
|
+
if (regionStart < 0 || !_hydrationCursor || _hydrationCursor.parent !== parent) return false;
|
|
2407
|
+
|
|
2408
|
+
// The failed arm has to have produced NOTHING. Any movement of the cursor is
|
|
2409
|
+
// a node this region has already committed to, claimed or created.
|
|
2410
|
+
if (_hydrationCursor.index !== regionStart) return false;
|
|
2411
|
+
|
|
2412
|
+
// Nothing at this position means there is nothing to reuse. Asked first
|
|
2413
|
+
// because it is the only question answerable without building the fallback.
|
|
2414
|
+
const candidate = peekNode(parent);
|
|
2415
|
+
if (!candidate) return false;
|
|
2416
|
+
|
|
2417
|
+
const content = getContent();
|
|
2418
|
+
const vnodes = Array.isArray(content) ? content : [content];
|
|
2419
|
+
const root = vnodes.find((v) => v != null && typeof v !== 'boolean');
|
|
2420
|
+
|
|
2421
|
+
// A fallback that renders nothing wants an empty region, and an empty region
|
|
2422
|
+
// is what it already has. Claimed, with nothing to claim — and `candidate` is
|
|
2423
|
+
// left for whoever it really belongs to.
|
|
2424
|
+
if (root === undefined) return true;
|
|
2425
|
+
|
|
2426
|
+
if (contradictsServerNode(root, candidate)) return false;
|
|
2427
|
+
|
|
2428
|
+
for (const v of vnodes) hydrateNode(v, parent);
|
|
2429
|
+
return true;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
/**
|
|
2433
|
+
* Hydrate an <ErrorBoundary> or a <Suspense>.
|
|
2434
|
+
*
|
|
2435
|
+
* The two are the same machine with a different signal: a marked region whose
|
|
2436
|
+
* contents are the children while the signal is falsy and the fallback once it
|
|
2437
|
+
* is not. The client builds both with createErrorBoundary / createSuspenseBoundary
|
|
2438
|
+
* in dom.js, and this is the hydrating twin of those two functions.
|
|
2439
|
+
*
|
|
2440
|
+
* Three things have to be true when this returns, and each was a separate bug:
|
|
2441
|
+
*
|
|
2442
|
+
* - the server's markup is still on screen. The children hydrate against it
|
|
2443
|
+
* in place; nothing is rebuilt.
|
|
2444
|
+
* - the boundary's context is on the component stack while those children
|
|
2445
|
+
* hydrate. reportError and suspend() both find their boundary by walking
|
|
2446
|
+
* `_parentCtx` up from the component that threw, so a boundary missing from
|
|
2447
|
+
* that chain catches nothing: the error escapes to the console and the page
|
|
2448
|
+
* dies exactly as it would with no boundary at all.
|
|
2449
|
+
* - the region is owned by an effect from here on, bounded by real comment
|
|
2450
|
+
* markers. Without the markers there is no insertion point and no stable
|
|
2451
|
+
* node to hang the disposer on, which is the same pair of failures the
|
|
2452
|
+
* reactive-region branch documents above.
|
|
2453
|
+
*
|
|
2454
|
+
* The first effect run is the subtle one. It must NOT rebuild what hydration
|
|
2455
|
+
* just claimed, or hydrating a boundary would be indistinguishable from
|
|
2456
|
+
* client-rendering it. But it cannot skip unconditionally either: a child that
|
|
2457
|
+
* threw or suspended WHILE hydrating flipped the signal before this effect
|
|
2458
|
+
* existed, and in that case the markup between the markers may be the wrong arm
|
|
2459
|
+
* and has to be replaced.
|
|
2460
|
+
*
|
|
2461
|
+
* "May be", not "is", and that is the whole of claimServerArm below. When a
|
|
2462
|
+
* child throws during the SERVER render the server catches it too and puts the
|
|
2463
|
+
* FALLBACK in the HTML, so the two sides agree on the arm and the fallback is
|
|
2464
|
+
* ordinary server markup that hydration should claim like any other. Hydrating
|
|
2465
|
+
* the happy arm first and then rebuilding on the flipped signal threw that
|
|
2466
|
+
* markup away and built a second copy of it, which is exactly the
|
|
2467
|
+
* destroy-and-rebuild these markers exist to stop.
|
|
2468
|
+
*/
|
|
2469
|
+
function hydrateBoundary(vnode, parent, { startText, endText, ctxExtras, state, contentFor }) {
|
|
2470
|
+
const children = vnode.children || [];
|
|
2471
|
+
const cursorInParent = !!(_hydrationCursor && _hydrationCursor.parent === parent);
|
|
2472
|
+
const startComment = document.createComment(startText);
|
|
2473
|
+
const endComment = document.createComment(endText);
|
|
2474
|
+
|
|
2475
|
+
// Same shape as the contexts the client boundaries build, for the same
|
|
2476
|
+
// reasons: `_parentCtx` keeps useContext resolving through the boundary, and
|
|
2477
|
+
// the marker references let a teardown find the region from the context.
|
|
2478
|
+
const boundaryCtx = {
|
|
2479
|
+
hooks: [],
|
|
2480
|
+
hookIndex: 0,
|
|
2481
|
+
effects: [],
|
|
2482
|
+
cleanups: [],
|
|
2483
|
+
mounted: false,
|
|
2484
|
+
disposed: false,
|
|
2485
|
+
_parentCtx: captureOwner(),
|
|
2486
|
+
_startComment: startComment,
|
|
2487
|
+
_endComment: endComment,
|
|
2488
|
+
...ctxExtras,
|
|
2489
|
+
};
|
|
2490
|
+
|
|
2491
|
+
// Open the region at the slot the cursor points at, before anything is
|
|
2492
|
+
// hydrated into it, so everything the children claim lands inside the pair.
|
|
2493
|
+
// (Anchoring the markers afterwards to whatever the children produced is
|
|
2494
|
+
// wrong for a boundary that produced nothing, and interleaves nested regions
|
|
2495
|
+
// instead of nesting them. See the reactive-region branch.)
|
|
2496
|
+
if (cursorInParent) {
|
|
2497
|
+
parent.insertBefore(startComment, parent.childNodes[_hydrationCursor.index] || null);
|
|
2498
|
+
_hydrationCursor.index++;
|
|
2499
|
+
} else {
|
|
2500
|
+
parent.appendChild(startComment);
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
// Where the region's content begins, in cursor terms. The start marker has
|
|
2504
|
+
// already consumed its slot, so this is the index the server's first node for
|
|
2505
|
+
// this boundary sits at. claimServerArm needs it to tell "the failed arm
|
|
2506
|
+
// touched nothing" from "the failed arm got part way in".
|
|
2507
|
+
const regionStart = cursorInParent ? _hydrationCursor.index : -1;
|
|
2508
|
+
|
|
2509
|
+
const stack = getComponentStack();
|
|
2510
|
+
stack.push(boundaryCtx);
|
|
2511
|
+
try {
|
|
2512
|
+
for (const child of children) {
|
|
2513
|
+
hydrateNode(child, parent);
|
|
2514
|
+
}
|
|
2515
|
+
} finally {
|
|
2516
|
+
stack.pop();
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
// Which arm the boundary is on now that its children have run.
|
|
2520
|
+
//
|
|
2521
|
+
// Read UNTRACKED. This is the hydration walk, not the effect below, and a
|
|
2522
|
+
// hydrate() reached from inside somebody else's effect would otherwise hand
|
|
2523
|
+
// that effect a subscription to this boundary's private error/loading signal:
|
|
2524
|
+
// an unrelated region upstream would re-render every time a boundary caught.
|
|
2525
|
+
const armAfterWalk = untrack(state);
|
|
2526
|
+
|
|
2527
|
+
// Whether the markup between the markers is already the arm `armAfterWalk`
|
|
2528
|
+
// names. True by construction when nothing flipped the signal (the walk just
|
|
2529
|
+
// claimed the children the server rendered), and true again when the fallback
|
|
2530
|
+
// below is claimed in place.
|
|
2531
|
+
let regionHoldsArm = !armAfterWalk;
|
|
2532
|
+
|
|
2533
|
+
if (armAfterWalk) {
|
|
2534
|
+
// Same re-push as the rebuild in the effect, for the same reason: a
|
|
2535
|
+
// fallback that renders a component of its own must see the boundary in its
|
|
2536
|
+
// parent chain, and contentFor is what runs that fallback.
|
|
2537
|
+
stack.push(boundaryCtx);
|
|
2538
|
+
try {
|
|
2539
|
+
regionHoldsArm = claimServerArm(parent, regionStart, () => contentFor(armAfterWalk));
|
|
2540
|
+
} finally {
|
|
2541
|
+
stack.pop();
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
if (cursorInParent) {
|
|
2546
|
+
parent.insertBefore(endComment, parent.childNodes[_hydrationCursor.index] || null);
|
|
2547
|
+
_hydrationCursor.index++;
|
|
2548
|
+
} else {
|
|
2549
|
+
parent.appendChild(endComment);
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
let claimedFromServer = true;
|
|
2553
|
+
// Generation guard, carried over from createSuspenseBoundary in dom.js, where
|
|
2554
|
+
// it exists because a child suspending mid-rebuild flips the state signal from
|
|
2555
|
+
// inside the loop below: if that re-entered the effect, the inner run would
|
|
2556
|
+
// replace the region and the outer run would then append the rest of the arm
|
|
2557
|
+
// it was already committed to, putting both arms on screen at once.
|
|
2558
|
+
//
|
|
2559
|
+
// It is honest to say this is currently UNREACHABLE and kept for parity. An
|
|
2560
|
+
// effect cannot re-enter itself here: reactive.js's notify() only executes
|
|
2561
|
+
// subscribers at notifyDepth 0 and queues them otherwise, so a write made
|
|
2562
|
+
// during an effect's own run is always drained after that run returns. Every
|
|
2563
|
+
// shape tried against it (two- and three-deep lazy waterfalls, and a staged
|
|
2564
|
+
// suspender behind a signal so the effect was auto-promoted to _stable and
|
|
2565
|
+
// therefore running INLINE) came back with a nesting depth of 1.
|
|
2566
|
+
//
|
|
2567
|
+
// Keeping it costs four lines and removes a way for the two boundary
|
|
2568
|
+
// implementations to disagree. The invariant it leans on lives in another
|
|
2569
|
+
// module and is not part of any contract this one can see.
|
|
2570
|
+
let generation = 0;
|
|
2571
|
+
const dispose = effect(() => {
|
|
2572
|
+
const current = state();
|
|
2573
|
+
|
|
2574
|
+
if (claimedFromServer) {
|
|
2575
|
+
claimedFromServer = false;
|
|
2576
|
+
// The region already holds the arm this run would build: either the
|
|
2577
|
+
// children the server rendered and the walk claimed (the normal case), or
|
|
2578
|
+
// the fallback claimed in place by claimServerArm. The markup already
|
|
2579
|
+
// there IS the answer, so leave it alone.
|
|
2580
|
+
if (regionHoldsArm && current === armAfterWalk) return;
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
const host = startComment.parentNode;
|
|
2584
|
+
if (!host) return; // region detached before this run; nothing to update
|
|
2585
|
+
|
|
2586
|
+
const gen = ++generation;
|
|
2587
|
+
|
|
2588
|
+
// Same teardown as the client boundaries: everything between the markers
|
|
2589
|
+
// goes, disposed first so nested effects and component contexts die with
|
|
2590
|
+
// the nodes rather than outliving them.
|
|
2591
|
+
while (startComment.nextSibling && startComment.nextSibling !== endComment) {
|
|
2592
|
+
const old = startComment.nextSibling;
|
|
2593
|
+
disposeTree(old);
|
|
2594
|
+
host.removeChild(old);
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
// Re-push the boundary for the rebuild. This effect re-runs long after the
|
|
2598
|
+
// hydration walk has unwound the stack, and anything built with an empty
|
|
2599
|
+
// stack gets `parentCtx = null`: a fallback that itself contains a
|
|
2600
|
+
// component would sit outside every context it was written inside.
|
|
2601
|
+
stack.push(boundaryCtx);
|
|
2602
|
+
try {
|
|
2603
|
+
const content = contentFor(current);
|
|
2604
|
+
const vnodes = Array.isArray(content) ? content : [content];
|
|
2605
|
+
for (const v of vnodes) {
|
|
2606
|
+
const node = createDOM(v, host);
|
|
2607
|
+
if (gen !== generation) {
|
|
2608
|
+
// A newer run already rebuilt the region. Whatever this node is, it
|
|
2609
|
+
// belongs to a superseded arm: dispose it rather than insert it
|
|
2610
|
+
// alongside the arm that won.
|
|
2611
|
+
if (node) disposeTree(node);
|
|
2612
|
+
break;
|
|
2613
|
+
}
|
|
2614
|
+
// endComment can be gone if that newer run tore the region down.
|
|
2615
|
+
if (!node) continue;
|
|
2616
|
+
if (endComment.parentNode) endComment.parentNode.insertBefore(node, endComment);
|
|
2617
|
+
else disposeTree(node);
|
|
2618
|
+
}
|
|
2619
|
+
} finally {
|
|
2620
|
+
stack.pop();
|
|
2621
|
+
}
|
|
2622
|
+
});
|
|
2623
|
+
|
|
2624
|
+
// Put the cursor back where the END MARKER actually ended up.
|
|
2625
|
+
//
|
|
2626
|
+
// The effect above runs SYNCHRONOUSLY, and when the state was already truthy
|
|
2627
|
+
// it has just removed R nodes from the region and inserted I of its own. The
|
|
2628
|
+
// cursor was fixed at endComment+1 a few lines earlier and knows nothing
|
|
2629
|
+
// about that, so it is off by (R - I) and the rest of the walk pays:
|
|
2630
|
+
//
|
|
2631
|
+
// - drifting forward SKIPS the boundary's next server sibling, which then
|
|
2632
|
+
// warns "got nothing" and is rendered a SECOND time. A page with a
|
|
2633
|
+
// boundary above the footer got two footers.
|
|
2634
|
+
// - drifting backward makes that sibling claim a node it must not, which
|
|
2635
|
+
// before the marker skip list above meant claiming the boundary's own
|
|
2636
|
+
// end marker and replaceChild()ing it away.
|
|
2637
|
+
//
|
|
2638
|
+
// Re-reading the marker's real index is the same re-sync the _mapArray branch
|
|
2639
|
+
// does, and for the same reason: once something has moved nodes behind the
|
|
2640
|
+
// walk's back, the only trustworthy answer to "where is the cursor now" is
|
|
2641
|
+
// where the marker physically is.
|
|
2642
|
+
if (cursorInParent && _hydrationCursor && _hydrationCursor.parent === parent) {
|
|
2643
|
+
const endIndex = Array.prototype.indexOf.call(parent.childNodes, endComment);
|
|
2644
|
+
if (endIndex >= 0) _hydrationCursor.index = endIndex + 1;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
boundaryCtx.effects.push(dispose);
|
|
2648
|
+
// The client registers a boundary context in dom.js's comment->ctx WeakMap;
|
|
2649
|
+
// the hydration disposer registry is the same idea reached from out here, and
|
|
2650
|
+
// disposeTree walks both. Registered on BOTH markers, matching the
|
|
2651
|
+
// reactive-region branch: whichever one a teardown happens to walk, the
|
|
2652
|
+
// boundary dies. disposeComponent latches on ctx.disposed, so being reached
|
|
2653
|
+
// twice is harmless.
|
|
2654
|
+
addHydratedComponent(startComment, boundaryCtx);
|
|
2655
|
+
addHydratedComponent(endComment, boundaryCtx);
|
|
2656
|
+
|
|
2657
|
+
// The START MARKER is the boundary's node, not its current contents.
|
|
2658
|
+
//
|
|
2659
|
+
// This matches the client exactly: createErrorBoundary returns a fragment
|
|
2660
|
+
// whose first node is that same start comment. It also matters for whoever
|
|
2661
|
+
// hydrated us. A component anchors its context to the first node its output
|
|
2662
|
+
// produced, and the contents of a boundary are the one thing that is
|
|
2663
|
+
// guaranteed to be replaced later, so returning them handed the enclosing
|
|
2664
|
+
// component a self-destructing anchor: the boundary catching an error
|
|
2665
|
+
// disposed the very component that wrapped the boundary. The markers outlive
|
|
2666
|
+
// every value the region holds, which is what an anchor has to do.
|
|
2667
|
+
return startComment;
|
|
1846
2668
|
}
|
|
1847
2669
|
|
|
1848
2670
|
/**
|