@tanstack/redact 0.0.14 → 0.0.16
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/core/internal.d.ts +23 -39
- package/dist/core/internal.js +7 -23
- package/dist/core/internal.js.map +3 -3
- package/dist/dom/dispatcher.d.ts +3 -1
- package/dist/dom/dispatcher.js +104 -97
- package/dist/dom/dispatcher.js.map +2 -2
- package/dist/dom/dom.js +1 -1
- package/dist/dom/dom.js.map +2 -2
- package/dist/dom/event-replay.js +7 -29
- package/dist/dom/event-replay.js.map +2 -2
- package/dist/dom/features/class/full.js +16 -16
- package/dist/dom/features/class/full.js.map +2 -2
- package/dist/dom/features/class/stub.js +7 -7
- package/dist/dom/features/class/stub.js.map +2 -2
- package/dist/dom/features/context/full.js +7 -7
- package/dist/dom/features/context/full.js.map +2 -2
- package/dist/dom/features/context/stub.js +3 -3
- package/dist/dom/features/context/stub.js.map +2 -2
- package/dist/dom/features/forward-ref/full.js +11 -11
- package/dist/dom/features/forward-ref/full.js.map +2 -2
- package/dist/dom/features/hydration/full.d.ts +26 -14
- package/dist/dom/features/hydration/full.js +417 -92
- package/dist/dom/features/hydration/full.js.map +2 -2
- package/dist/dom/features/hydration/stub.d.ts +13 -12
- package/dist/dom/features/hydration/stub.js +23 -8
- package/dist/dom/features/hydration/stub.js.map +2 -2
- package/dist/dom/features/lazy/full.js +3 -43
- package/dist/dom/features/lazy/full.js.map +2 -2
- package/dist/dom/features/lazy/stub.js +1 -1
- package/dist/dom/features/lazy/stub.js.map +2 -2
- package/dist/dom/features/memo/full.js +3 -3
- package/dist/dom/features/memo/full.js.map +2 -2
- package/dist/dom/features/memo/stub.js +1 -1
- package/dist/dom/features/memo/stub.js.map +2 -2
- package/dist/dom/features/portal/full.js +2 -2
- package/dist/dom/features/portal/full.js.map +2 -2
- package/dist/dom/features/suspense/full.js +151 -111
- package/dist/dom/features/suspense/full.js.map +2 -2
- package/dist/dom/reconcile.d.ts +3 -1
- package/dist/dom/reconcile.js +141 -129
- package/dist/dom/reconcile.js.map +2 -2
- package/dist/dom/root-internal.d.ts +9 -0
- package/dist/dom/root-internal.js +31 -0
- package/dist/dom/root-internal.js.map +7 -0
- package/dist/dom/root.d.ts +1 -1
- package/dist/dom/root.js +4 -136
- package/dist/dom/root.js.map +2 -2
- package/dist/dom/test-utils.js +1 -1
- package/dist/dom/test-utils.js.map +2 -2
- package/dist/react/children.js +4 -1
- package/dist/react/children.js.map +2 -2
- package/dist/react/class.js +8 -2
- package/dist/react/class.js.map +2 -2
- package/dist/react/context.js +4 -1
- package/dist/react/context.js.map +2 -2
- package/dist/react/index.js +1 -1
- package/dist/react/index.js.map +2 -2
- package/dist/react/shared-internals.d.ts +4 -7
- package/dist/react/shared-internals.js +4 -7
- package/dist/react/shared-internals.js.map +2 -2
- package/dist/server/stream.d.ts +5 -1
- package/dist/server/stream.js.map +2 -2
- package/package.json +1 -1
- package/src/core/internal.ts +30 -50
- package/src/dom/dispatcher.ts +114 -105
- package/src/dom/dom.ts +1 -1
- package/src/dom/event-replay.ts +14 -30
- package/src/dom/features/class/full.ts +16 -16
- package/src/dom/features/class/stub.ts +7 -7
- package/src/dom/features/context/full.ts +7 -7
- package/src/dom/features/context/stub.ts +3 -3
- package/src/dom/features/forward-ref/full.ts +11 -11
- package/src/dom/features/hydration/full.ts +519 -104
- package/src/dom/features/hydration/stub.ts +28 -14
- package/src/dom/features/lazy/full.ts +3 -56
- package/src/dom/features/lazy/stub.ts +1 -1
- package/src/dom/features/memo/full.ts +3 -3
- package/src/dom/features/memo/stub.ts +1 -1
- package/src/dom/features/portal/full.ts +2 -2
- package/src/dom/features/suspense/full.ts +176 -131
- package/src/dom/reconcile.ts +164 -170
- package/src/dom/root-internal.ts +40 -0
- package/src/dom/root.ts +5 -165
- package/src/dom/test-utils.ts +1 -1
- package/src/react/children.ts +4 -1
- package/src/react/class.ts +8 -2
- package/src/react/context.ts +4 -1
- package/src/react/index.ts +1 -1
- package/src/react/shared-internals.ts +8 -14
- package/src/server/stream.ts +9 -4
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
FiberTag,
|
|
3
|
+
REACT_ELEMENT_TYPE,
|
|
4
|
+
type Fiber,
|
|
5
|
+
type FiberRoot,
|
|
6
|
+
type ReactElement,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from '../../../core'
|
|
9
|
+
import { createHostNode, setProp } from '../../dom'
|
|
10
|
+
import { drainReplayQueue } from '../../event-replay'
|
|
11
|
+
import { discardPendingWork, findRoot, flushSyncWork, renderRoot } from '../../reconcile'
|
|
12
|
+
import { attachRootFiber, createFiberRoot } from '../../root-internal'
|
|
4
13
|
|
|
5
14
|
// Re-export from event-replay so all hydration concerns live behind one
|
|
6
15
|
// feature boundary — the plugin's stub swap strips drainReplayQueue too.
|
|
7
|
-
export { drainReplayQueue }
|
|
8
|
-
|
|
9
|
-
const GUARD_WINDOW_MS = 3000
|
|
16
|
+
export { drainReplayQueue }
|
|
10
17
|
|
|
11
18
|
/**
|
|
12
19
|
* Preserve the user's scroll position across hydration. If the user scrolled
|
|
@@ -21,51 +28,35 @@ const GUARD_WINDOW_MS = 3000
|
|
|
21
28
|
export function installHydrationScrollGuard(): void {
|
|
22
29
|
if (typeof window === 'undefined') return
|
|
23
30
|
const w = window as any
|
|
24
|
-
if (w.
|
|
25
|
-
|
|
26
|
-
const guardStartedAt =
|
|
31
|
+
if (w._r) return
|
|
32
|
+
const time = Date.now
|
|
33
|
+
const guardStartedAt = w._r = time()
|
|
27
34
|
let lastUserScrollAt = 0
|
|
28
|
-
let programmatic =
|
|
29
|
-
w.
|
|
30
|
-
window.addEventListener(
|
|
35
|
+
let programmatic = false
|
|
36
|
+
w.addEventListener(
|
|
31
37
|
'scroll',
|
|
32
38
|
() => {
|
|
33
|
-
if (programmatic
|
|
34
|
-
lastUserScrollAt =
|
|
35
|
-
w.__redactScrollLog.push({ t: Math.round(lastUserScrollAt), ev: 'user-scroll', y: window.scrollY })
|
|
39
|
+
if (!programmatic) {
|
|
40
|
+
lastUserScrollAt = time()
|
|
36
41
|
}
|
|
37
42
|
},
|
|
38
43
|
{ capture: true, passive: true },
|
|
39
44
|
)
|
|
40
|
-
const origScrollTo =
|
|
41
|
-
|
|
42
|
-
const now =
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
t: Math.round(now),
|
|
48
|
-
ev: 'suppressed',
|
|
49
|
-
args: JSON.stringify(args).slice(0, 80),
|
|
50
|
-
tSinceHydrate: Math.round(now - guardStartedAt),
|
|
51
|
-
tSinceUserScroll: Math.round(now - lastUserScrollAt),
|
|
52
|
-
})
|
|
45
|
+
const origScrollTo = w.scrollTo
|
|
46
|
+
w.scrollTo = function (...args: any[]) {
|
|
47
|
+
const now = time()
|
|
48
|
+
if (
|
|
49
|
+
now - guardStartedAt < 3000 &&
|
|
50
|
+
now - lastUserScrollAt < 1500
|
|
51
|
+
) {
|
|
53
52
|
return
|
|
54
53
|
}
|
|
55
|
-
|
|
56
|
-
t: Math.round(now),
|
|
57
|
-
ev: 'allowed',
|
|
58
|
-
args: JSON.stringify(args).slice(0, 80),
|
|
59
|
-
tSinceHydrate: Math.round(now - guardStartedAt),
|
|
60
|
-
inGuard: inGuardWindow,
|
|
61
|
-
userScrolled: userScrolledRecently,
|
|
62
|
-
})
|
|
63
|
-
programmatic++
|
|
54
|
+
programmatic = true
|
|
64
55
|
try {
|
|
65
|
-
return (origScrollTo as any).apply(
|
|
56
|
+
return (origScrollTo as any).apply(w, args)
|
|
66
57
|
} finally {
|
|
67
58
|
queueMicrotask(() => {
|
|
68
|
-
programmatic =
|
|
59
|
+
programmatic = false
|
|
69
60
|
})
|
|
70
61
|
}
|
|
71
62
|
}
|
|
@@ -79,24 +70,24 @@ export function installHydrationScrollGuard(): void {
|
|
|
79
70
|
* so we only adopt DOM up to the closing `/$` marker for that boundary.
|
|
80
71
|
*/
|
|
81
72
|
export class HydrationCursor {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
73
|
+
n: ChildNode | null
|
|
74
|
+
p: Node
|
|
75
|
+
e: ChildNode | null
|
|
85
76
|
constructor(parent: Node, start: ChildNode | null = null, endBefore: ChildNode | null = null) {
|
|
86
|
-
this.
|
|
87
|
-
this.
|
|
88
|
-
this.
|
|
77
|
+
this.p = parent
|
|
78
|
+
this.n = start ?? parent.firstChild
|
|
79
|
+
this.e = endBefore
|
|
89
80
|
}
|
|
90
|
-
|
|
91
|
-
while (this.
|
|
92
|
-
const n = this.
|
|
81
|
+
take(): ChildNode | null {
|
|
82
|
+
while (this.n && this.n !== this.e) {
|
|
83
|
+
const n = this.n
|
|
93
84
|
// Skip anything that isn't an element (1) or text (3):
|
|
94
85
|
// comments (8), doctype (10), processing instructions (7), cdata (4).
|
|
95
86
|
if (n.nodeType !== 1 && n.nodeType !== 3) {
|
|
96
|
-
this.
|
|
87
|
+
this.n = n.nextSibling
|
|
97
88
|
continue
|
|
98
89
|
}
|
|
99
|
-
this.
|
|
90
|
+
this.n = n.nextSibling
|
|
100
91
|
return n
|
|
101
92
|
}
|
|
102
93
|
return null
|
|
@@ -108,10 +99,10 @@ export class HydrationCursor {
|
|
|
108
99
|
* name/property for meta, src for script). Non-matching nodes stay in
|
|
109
100
|
* place so the SSR'd stylesheet/script order is preserved.
|
|
110
101
|
*/
|
|
111
|
-
|
|
102
|
+
head(tag: string, props: Record<string, any>): ChildNode | null {
|
|
112
103
|
const target = tag.toLowerCase()
|
|
113
|
-
const keyAttrs = HEAD_KEY_ATTRS[target]
|
|
114
|
-
let scan = this.
|
|
104
|
+
const keyAttrs = HEAD_KEY_ATTRS[target]
|
|
105
|
+
let scan = this.p.firstChild
|
|
115
106
|
while (scan) {
|
|
116
107
|
if (
|
|
117
108
|
scan.nodeType === 1 &&
|
|
@@ -125,26 +116,125 @@ export class HydrationCursor {
|
|
|
125
116
|
}
|
|
126
117
|
return null
|
|
127
118
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
out.push(n)
|
|
119
|
+
has(): boolean {
|
|
120
|
+
let n = this.n
|
|
121
|
+
while (n && n !== this.e) {
|
|
122
|
+
if (n.nodeType === 1 || n.nodeType === 3) return true
|
|
133
123
|
n = n.nextSibling
|
|
134
124
|
}
|
|
135
|
-
return
|
|
125
|
+
return false
|
|
136
126
|
}
|
|
137
127
|
}
|
|
138
128
|
|
|
139
129
|
const hydrationCursors = new WeakMap<Fiber, HydrationCursor>()
|
|
130
|
+
const PROD_HYDRATION_ERROR = 'Hydration mismatch.'
|
|
131
|
+
|
|
132
|
+
export interface HydrationBailoutError extends Error {
|
|
133
|
+
f: Fiber | null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function isHydrationBailout(error: unknown): error is HydrationBailoutError {
|
|
137
|
+
return !!error && (error as any).f !== undefined
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function abortHydration(cause: unknown, fiber: Fiber | null = null): never {
|
|
141
|
+
const error = (cause instanceof Error ? cause : new Error(PROD_HYDRATION_ERROR)) as HydrationBailoutError
|
|
142
|
+
;(error as any).f = fiber
|
|
143
|
+
throw error
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface HydrateRootOptions {
|
|
147
|
+
identifierPrefix?: string
|
|
148
|
+
onRecoverableError?: (error: unknown) => void
|
|
149
|
+
onCaughtError?: (error: unknown) => void
|
|
150
|
+
onUncaughtError?: (error: unknown) => void
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface HydratedRoot {
|
|
154
|
+
render(children: ReactNode): void
|
|
155
|
+
unmount(): void
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function hydrateRootImpl(
|
|
159
|
+
container: Element | Document,
|
|
160
|
+
initialChildren: ReactNode,
|
|
161
|
+
options: HydrateRootOptions,
|
|
162
|
+
): HydratedRoot {
|
|
163
|
+
const target = container as any as Element | Document
|
|
164
|
+
const isDocument = (container as Node).nodeType === 9
|
|
165
|
+
const body = isDocument ? (target as Document).body : null
|
|
166
|
+
const root = createFiberRoot(target, options)
|
|
167
|
+
|
|
168
|
+
installHydrationScrollGuard()
|
|
169
|
+
|
|
170
|
+
const normalizedInitialChildren =
|
|
171
|
+
isDocument ? normalizeDocumentChildren(initialChildren) : initialChildren
|
|
172
|
+
let documentBodyFallback = false
|
|
173
|
+
|
|
174
|
+
let hydrationError: unknown = null
|
|
175
|
+
beginHydration(root)
|
|
176
|
+
try {
|
|
177
|
+
flushSyncWork(() => {
|
|
178
|
+
renderRoot(root, normalizedInitialChildren)
|
|
179
|
+
})
|
|
180
|
+
} catch (e) {
|
|
181
|
+
hydrationError = e
|
|
182
|
+
}
|
|
183
|
+
endHydration(root)
|
|
184
|
+
|
|
185
|
+
if (hydrationError) {
|
|
186
|
+
if (!isHydrationBailout(hydrationError)) {
|
|
187
|
+
throw hydrationError
|
|
188
|
+
}
|
|
189
|
+
let recoveryContainer: Element | Document = target
|
|
190
|
+
let recoveryChildren = normalizedInitialChildren
|
|
191
|
+
const hostRecovery = getRecoverableHostChildren(hydrationError)
|
|
192
|
+
if (hostRecovery) {
|
|
193
|
+
recoveryContainer = hostRecovery[0]
|
|
194
|
+
recoveryChildren = hostRecovery[1]
|
|
195
|
+
} else if (body) {
|
|
196
|
+
const bodyChildren = getRecoverableDocumentBodyChildren(hydrationError)
|
|
197
|
+
if (bodyChildren != null) {
|
|
198
|
+
documentBodyFallback = true
|
|
199
|
+
recoveryContainer = body
|
|
200
|
+
recoveryChildren = bodyChildren
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
resetAfterHydrationFailure(root, recoveryContainer)
|
|
204
|
+
try {
|
|
205
|
+
flushSyncWork(() => {
|
|
206
|
+
renderRoot(root, recoveryChildren)
|
|
207
|
+
})
|
|
208
|
+
} catch (clientError) {
|
|
209
|
+
resetAfterHydrationFailure(root, recoveryContainer)
|
|
210
|
+
throw clientError
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
drainReplayQueue()
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
render(children) {
|
|
217
|
+
flushSyncWork(() => {
|
|
218
|
+
const normalized = isDocument ? normalizeDocumentChildren(children) : children
|
|
219
|
+
renderRoot(
|
|
220
|
+
root,
|
|
221
|
+
documentBodyFallback ? getStaticDocumentBodyChildren(normalized) ?? normalized : normalized,
|
|
222
|
+
)
|
|
223
|
+
})
|
|
224
|
+
},
|
|
225
|
+
unmount() {
|
|
226
|
+
flushSyncWork(() => {
|
|
227
|
+
renderRoot(root, null)
|
|
228
|
+
})
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
}
|
|
140
232
|
|
|
141
233
|
// Head elements that we match against server DOM by attribute signature.
|
|
142
234
|
const HEAD_KEY_ATTRS: Record<string, ReadonlyArray<string>> = {
|
|
143
235
|
link: ['rel', 'href', 'sizes', 'type'],
|
|
144
236
|
meta: ['name', 'property', 'charset', 'http-equiv'],
|
|
145
237
|
script: ['src', 'type'],
|
|
146
|
-
style: [],
|
|
147
|
-
title: [],
|
|
148
238
|
}
|
|
149
239
|
|
|
150
240
|
const DOCUMENT_HEAD_TAGS = new Set(['base', 'link', 'meta', 'script', 'style', 'title'])
|
|
@@ -155,30 +245,32 @@ const CLAIMED = new WeakSet<Node>()
|
|
|
155
245
|
function headAttrsMatch(
|
|
156
246
|
el: Element,
|
|
157
247
|
props: Record<string, any>,
|
|
158
|
-
keys: ReadonlyArray<string
|
|
248
|
+
keys: ReadonlyArray<string> | undefined,
|
|
159
249
|
): boolean {
|
|
160
250
|
if (CLAIMED.has(el)) return false
|
|
161
|
-
if (keys
|
|
251
|
+
if (!keys) return true
|
|
252
|
+
let matched = false
|
|
162
253
|
for (const k of keys) {
|
|
163
254
|
const propVal = props[k] ?? (k === 'http-equiv' ? props.httpEquiv : undefined)
|
|
164
255
|
const elVal = el.getAttribute(k)
|
|
165
256
|
// If neither defines it, skip this key; if one defines it, they must match.
|
|
166
257
|
if (propVal == null && elVal == null) continue
|
|
258
|
+
matched = true
|
|
167
259
|
if (propVal == null || elVal == null) continue // tolerate missing on either side
|
|
168
260
|
if (String(propVal) !== elVal) return false
|
|
169
261
|
}
|
|
170
262
|
// At least one matching signal must be present.
|
|
171
|
-
return
|
|
263
|
+
return matched
|
|
172
264
|
}
|
|
173
265
|
|
|
174
266
|
export function beginHydration(root: FiberRoot): void {
|
|
175
|
-
root.
|
|
176
|
-
hydrationCursors.set(root.
|
|
267
|
+
root.h = true
|
|
268
|
+
hydrationCursors.set(root.r, new HydrationCursor(root.c))
|
|
177
269
|
}
|
|
178
270
|
|
|
179
271
|
export function endHydration(root: FiberRoot): void {
|
|
180
|
-
root.
|
|
181
|
-
hydrationCursors.delete(root.
|
|
272
|
+
root.h = false
|
|
273
|
+
hydrationCursors.delete(root.r)
|
|
182
274
|
}
|
|
183
275
|
|
|
184
276
|
/**
|
|
@@ -186,26 +278,21 @@ export function endHydration(root: FiberRoot): void {
|
|
|
186
278
|
* marker emitted by the server. Returns info + advances the cursor past the
|
|
187
279
|
* marker pair (start comment + fallback/real content + end comment).
|
|
188
280
|
*/
|
|
189
|
-
export
|
|
190
|
-
kind: 'pending' | 'resolved'
|
|
191
|
-
id: number
|
|
192
|
-
startMark: Comment
|
|
193
|
-
endMark: Comment
|
|
194
|
-
}
|
|
281
|
+
export type BoundaryInfo = [0 | 1, number, Comment, Comment]
|
|
195
282
|
|
|
196
283
|
export function tryConsumeBoundary(parent: Fiber): BoundaryInfo | null {
|
|
197
284
|
const cursor = hydrationCursors.get(findHostParent(parent))
|
|
198
285
|
if (!cursor) return null
|
|
199
|
-
const peek = cursor.
|
|
286
|
+
const peek = cursor.n
|
|
200
287
|
if (!peek || peek.nodeType !== 8) return null
|
|
201
288
|
const data = (peek as Comment).data
|
|
202
289
|
const m = /^(\$\??)(\d+)$/.exec(data)
|
|
203
290
|
if (!m) return null
|
|
204
|
-
const kind = m[1] === '$?' ?
|
|
291
|
+
const kind = m[1] === '$?' ? 1 : 0
|
|
205
292
|
const id = Number(m[2])
|
|
206
293
|
const startMark = peek as Comment
|
|
207
294
|
// Advance past the start comment
|
|
208
|
-
cursor.
|
|
295
|
+
cursor.n = startMark.nextSibling
|
|
209
296
|
// Locate end comment: closest <!--/$-->
|
|
210
297
|
let endMark: Comment | null = null
|
|
211
298
|
let scan = startMark.nextSibling
|
|
@@ -217,13 +304,13 @@ export function tryConsumeBoundary(parent: Fiber): BoundaryInfo | null {
|
|
|
217
304
|
scan = scan.nextSibling
|
|
218
305
|
}
|
|
219
306
|
if (!endMark) return null
|
|
220
|
-
return
|
|
307
|
+
return [kind, id, startMark, endMark]
|
|
221
308
|
}
|
|
222
309
|
|
|
223
310
|
export function advanceCursorPast(parent: Fiber, node: Node): void {
|
|
224
311
|
const cursor = hydrationCursors.get(findHostParent(parent))
|
|
225
312
|
if (!cursor) return
|
|
226
|
-
cursor.
|
|
313
|
+
cursor.n = node.nextSibling
|
|
227
314
|
}
|
|
228
315
|
|
|
229
316
|
export function getHydrationCursor(hostFiber: Fiber): HydrationCursor | undefined {
|
|
@@ -248,8 +335,11 @@ export function adoptHostDom(fiber: Fiber, parent: Fiber): boolean {
|
|
|
248
335
|
if (!cursor) return false
|
|
249
336
|
|
|
250
337
|
const tag = (fiber.type as string).toLowerCase()
|
|
251
|
-
const documentHeadParent =
|
|
252
|
-
|
|
338
|
+
const documentHeadParent =
|
|
339
|
+
cursor.p.nodeType === 9 && DOCUMENT_HEAD_TAGS.has(tag)
|
|
340
|
+
? (cursor.p as Document).head
|
|
341
|
+
: null
|
|
342
|
+
const parentEl = cursor.p as Element
|
|
253
343
|
const parentTag =
|
|
254
344
|
parentEl.nodeType === 1 ? (parentEl as Element).tagName.toLowerCase() : ''
|
|
255
345
|
const isHeadish = parentTag === 'head' || parentTag === 'html' || !!documentHeadParent
|
|
@@ -260,18 +350,18 @@ export function adoptHostDom(fiber: Fiber, parent: Fiber): boolean {
|
|
|
260
350
|
// document.head. Redact does not have that projection yet, so when a
|
|
261
351
|
// document-root hydration pass sees a top-level head element, adopt it
|
|
262
352
|
// from <head> rather than trying to append it beside <html>.
|
|
263
|
-
candidate = new HydrationCursor(documentHeadParent).
|
|
353
|
+
candidate = new HydrationCursor(documentHeadParent).head(
|
|
264
354
|
tag,
|
|
265
|
-
fiber.
|
|
355
|
+
fiber.pp ?? {},
|
|
266
356
|
)
|
|
267
357
|
} else if (isHeadish) {
|
|
268
358
|
// Head/html children are position-insensitive — server may emit them in
|
|
269
359
|
// a different order than the React tree (React 19 head hoisting, etc.).
|
|
270
360
|
// Scan forward without removing non-matching nodes; match on attribute
|
|
271
361
|
// signature so we don't adopt the wrong <link> and clobber its props.
|
|
272
|
-
candidate = cursor.
|
|
362
|
+
candidate = cursor.head(tag, fiber.pp ?? {})
|
|
273
363
|
} else {
|
|
274
|
-
candidate = cursor.
|
|
364
|
+
candidate = cursor.take()
|
|
275
365
|
}
|
|
276
366
|
|
|
277
367
|
if (!candidate) {
|
|
@@ -292,14 +382,15 @@ export function adoptHostDom(fiber: Fiber, parent: Fiber): boolean {
|
|
|
292
382
|
}
|
|
293
383
|
fiber.dom = candidate
|
|
294
384
|
// Apply props (attach events, sync IDL props). Don't re-set existing attrs.
|
|
295
|
-
const props = fiber.
|
|
385
|
+
const props = fiber.pp ?? {}
|
|
296
386
|
const isSvg =
|
|
297
387
|
tag === 'svg' ||
|
|
298
388
|
((candidate as Element).namespaceURI === 'http://www.w3.org/2000/svg' &&
|
|
299
389
|
tag !== 'foreignobject')
|
|
390
|
+
validateHydrationProps(fiber, candidate as Element, props, tag, isSvg)
|
|
300
391
|
for (const k in props) {
|
|
301
392
|
if (k === 'children') continue
|
|
302
|
-
if (k[0] === 'o' && k[1] === 'n' && typeof props[k]
|
|
393
|
+
if (k[0] === 'o' && k[1] === 'n' && typeof props[k] == 'function') {
|
|
303
394
|
setProp(candidate as Element, k, props[k], undefined, isSvg)
|
|
304
395
|
}
|
|
305
396
|
// Non-event props: trust the server HTML, skip
|
|
@@ -309,19 +400,20 @@ export function adoptHostDom(fiber: Fiber, parent: Fiber): boolean {
|
|
|
309
400
|
return true
|
|
310
401
|
}
|
|
311
402
|
|
|
312
|
-
function getDocumentHeadParent(parent: Node, tag: string): HTMLHeadElement | null {
|
|
313
|
-
if (parent.nodeType !== 9 || !DOCUMENT_HEAD_TAGS.has(tag)) return null
|
|
314
|
-
return (parent as Document).head
|
|
315
|
-
}
|
|
316
|
-
|
|
317
403
|
export function adoptTextDom(fiber: Fiber, parent: Fiber, text: string): boolean {
|
|
318
404
|
const cursor = hydrationCursors.get(findHostParent(parent))
|
|
319
405
|
if (!cursor) return false
|
|
320
|
-
const candidate = cursor.
|
|
321
|
-
if (!candidate)
|
|
406
|
+
const candidate = cursor.take()
|
|
407
|
+
if (!candidate) onMismatch(fiber, null)
|
|
322
408
|
if (candidate.nodeType === 3) {
|
|
323
409
|
if ((candidate as Text).data !== text) {
|
|
324
|
-
|
|
410
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
411
|
+
failHydration(
|
|
412
|
+
fiber,
|
|
413
|
+
new Error(`Hydration text mismatch: expected "${text}" but found "${(candidate as Text).data}".`),
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
failHydration(fiber)
|
|
325
417
|
}
|
|
326
418
|
fiber.dom = candidate
|
|
327
419
|
return true
|
|
@@ -341,15 +433,16 @@ export function findHostParent(fiber: Fiber): Fiber {
|
|
|
341
433
|
}
|
|
342
434
|
f = f.parent
|
|
343
435
|
}
|
|
344
|
-
|
|
436
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
437
|
+
throw new Error('No host parent found')
|
|
438
|
+
}
|
|
439
|
+
throw new Error()
|
|
345
440
|
}
|
|
346
441
|
|
|
347
|
-
function onMismatch(fiber: Fiber, actualNode: ChildNode | null):
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (root?.onRecoverableError) {
|
|
352
|
-
root.onRecoverableError(
|
|
442
|
+
function onMismatch(fiber: Fiber, actualNode: ChildNode | null): never {
|
|
443
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
444
|
+
failHydration(
|
|
445
|
+
fiber,
|
|
353
446
|
new Error(
|
|
354
447
|
`Hydration mismatch: expected <${(fiber.type as string) ?? 'text'}> but found ${
|
|
355
448
|
actualNode ? (actualNode.nodeType === 1 ? (actualNode as Element).tagName : 'text') : 'nothing'
|
|
@@ -357,6 +450,328 @@ function onMismatch(fiber: Fiber, actualNode: ChildNode | null): void {
|
|
|
357
450
|
),
|
|
358
451
|
)
|
|
359
452
|
}
|
|
360
|
-
|
|
361
|
-
|
|
453
|
+
failHydration(fiber)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function failHydration(fiber: Fiber, error: Error = new Error(PROD_HYDRATION_ERROR)): never {
|
|
457
|
+
const root = findRoot(fiber)
|
|
458
|
+
if (root?.re) {
|
|
459
|
+
root.re(error)
|
|
460
|
+
}
|
|
461
|
+
abortHydration(error, findHostRecoveryParent(fiber) ?? fiber)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function findHostRecoveryParent(fiber: Fiber): Fiber | null {
|
|
465
|
+
if (fiber.tag === FiberTag.Text) {
|
|
466
|
+
let directHost = fiber.parent
|
|
467
|
+
while (directHost && (directHost.tag !== FiberTag.Host || !directHost.dom)) {
|
|
468
|
+
directHost = directHost.parent
|
|
469
|
+
}
|
|
470
|
+
if (!directHost) return null
|
|
471
|
+
let hasEvent
|
|
472
|
+
const props = directHost.pp ?? directHost.mp
|
|
473
|
+
if (props) {
|
|
474
|
+
for (const k in props) {
|
|
475
|
+
if (k[0] === 'o' && k[1] === 'n' && typeof props[k] == 'function') {
|
|
476
|
+
hasEvent = true
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return findNearestSafeHostAboveComposite(
|
|
481
|
+
hasEvent
|
|
482
|
+
? directHost.parent
|
|
483
|
+
: directHost.parent?.tag === FiberTag.Host
|
|
484
|
+
? directHost.parent
|
|
485
|
+
: directHost,
|
|
486
|
+
)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return findNearestSafeHostAboveComposite(fiber.parent)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function findNearestSafeHostAboveComposite(fiber: Fiber | null): Fiber | null {
|
|
493
|
+
let host: Fiber | null = null
|
|
494
|
+
let f = fiber
|
|
495
|
+
while (f) {
|
|
496
|
+
if (f.tag === FiberTag.Host && f.dom) {
|
|
497
|
+
if (!isSafeHostRecoveryElement(f)) return null
|
|
498
|
+
const parentTag = f.parent?.tag as number
|
|
499
|
+
if (!host || (parentTag > FiberTag.Text && parentTag < FiberTag.Suspense)) {
|
|
500
|
+
host = f
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
f = f.parent
|
|
504
|
+
}
|
|
505
|
+
return host
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function isSafeHostRecoveryElement(fiber: Fiber): boolean {
|
|
509
|
+
const tag = fiber.type.toLowerCase()
|
|
510
|
+
return tag !== 'html' && tag !== 'head' && tag !== 'body'
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function resetAfterHydrationFailure(
|
|
514
|
+
root: FiberRoot,
|
|
515
|
+
container: Element | Document,
|
|
516
|
+
): void {
|
|
517
|
+
discardPendingWork(root)
|
|
518
|
+
clearHydrationContainer(container)
|
|
519
|
+
attachRootFiber(root, container)
|
|
520
|
+
root.h = false
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function clearHydrationContainer(container: Element | Document): void {
|
|
524
|
+
if (container.nodeType === 9) {
|
|
525
|
+
let node = container.firstChild
|
|
526
|
+
while (node) {
|
|
527
|
+
const next = node.nextSibling
|
|
528
|
+
if (node.nodeType !== 10 /* DOCUMENT_TYPE_NODE */) {
|
|
529
|
+
container.removeChild(node)
|
|
530
|
+
}
|
|
531
|
+
node = next
|
|
532
|
+
}
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
;(container as Element).textContent = ''
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function getRecoverableHostChildren(
|
|
539
|
+
error: HydrationBailoutError,
|
|
540
|
+
): [Element, ReactNode] | null {
|
|
541
|
+
const host = error.f
|
|
542
|
+
if (
|
|
543
|
+
host?.tag !== FiberTag.Host ||
|
|
544
|
+
!host.dom ||
|
|
545
|
+
!findNearestSafeHostAboveComposite(host.parent)
|
|
546
|
+
) {
|
|
547
|
+
return null
|
|
548
|
+
}
|
|
549
|
+
return [host.dom as Element, (host.pp ?? host.mp)?.children ?? null]
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function getRecoverableDocumentBodyChildren(error: HydrationBailoutError): ReactNode | null {
|
|
553
|
+
const bodyFiber = findBodyAncestor(error.f)
|
|
554
|
+
if (!bodyFiber) return null
|
|
555
|
+
return (bodyFiber.pp ?? bodyFiber.mp)?.children ?? null
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function findBodyAncestor(fiber: Fiber | null): Fiber | null {
|
|
559
|
+
let f = fiber
|
|
560
|
+
while (f) {
|
|
561
|
+
if (f.tag === FiberTag.Host && f.type === 'body') {
|
|
562
|
+
return f === fiber ? null : f
|
|
563
|
+
}
|
|
564
|
+
f = f.parent
|
|
565
|
+
}
|
|
566
|
+
return null
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function getStaticDocumentBodyChildren(children: ReactNode): ReactNode | null {
|
|
570
|
+
const list = toChildArray(children)
|
|
571
|
+
const html = list.find((child) => isHostElement(child, 'html')) as ReactElement | undefined
|
|
572
|
+
if (!html) return null
|
|
573
|
+
const htmlChildren = toChildArray(html.props?.children)
|
|
574
|
+
const body = htmlChildren.find((child) => isHostElement(child, 'body')) as ReactElement | undefined
|
|
575
|
+
return body ? body.props?.children ?? null : null
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function normalizeDocumentChildren(children: ReactNode): ReactNode {
|
|
579
|
+
const list = toChildArray(children)
|
|
580
|
+
const htmlIndex = list.findIndex((child) => isHostElement(child, 'html'))
|
|
581
|
+
if (htmlIndex === -1) return children
|
|
582
|
+
|
|
583
|
+
const headNodes = list.filter(isHeadElement)
|
|
584
|
+
if (headNodes.length === 0) return children
|
|
585
|
+
|
|
586
|
+
const htmlElement = list[htmlIndex] as ReactElement
|
|
587
|
+
const normalizedHtml = hoistIntoHtmlHead(htmlElement, headNodes)
|
|
588
|
+
return list
|
|
589
|
+
.filter((child, index) => index === htmlIndex || !isHeadElement(child))
|
|
590
|
+
.map((child) => (child === htmlElement ? normalizedHtml : child))
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function hoistIntoHtmlHead(htmlElement: ReactElement, headNodes: ReactNode[]): ReactElement {
|
|
594
|
+
const htmlChildren = toChildArray(htmlElement.props?.children)
|
|
595
|
+
const headIndex = htmlChildren.findIndex((child) => isHostElement(child, 'head'))
|
|
596
|
+
let nextChildren: ReactNode[]
|
|
597
|
+
|
|
598
|
+
if (headIndex === -1) {
|
|
599
|
+
nextChildren = [
|
|
600
|
+
createHostElement('head', { children: headNodes }),
|
|
601
|
+
...htmlChildren,
|
|
602
|
+
]
|
|
603
|
+
} else {
|
|
604
|
+
const headElement = htmlChildren[headIndex] as ReactElement
|
|
605
|
+
const existingHeadChildren = toChildArray(headElement.props?.children)
|
|
606
|
+
const nextHead = {
|
|
607
|
+
...headElement,
|
|
608
|
+
props: {
|
|
609
|
+
...headElement.props,
|
|
610
|
+
children: [...headNodes, ...existingHeadChildren],
|
|
611
|
+
},
|
|
612
|
+
}
|
|
613
|
+
nextChildren = htmlChildren.map((child, index) => (index === headIndex ? nextHead : child))
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
...htmlElement,
|
|
618
|
+
props: {
|
|
619
|
+
...htmlElement.props,
|
|
620
|
+
children: nextChildren,
|
|
621
|
+
},
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function toChildArray(children: unknown): ReactNode[] {
|
|
626
|
+
if (children == null || typeof children === 'boolean') return []
|
|
627
|
+
if (Array.isArray(children)) return children as ReactNode[]
|
|
628
|
+
if (isReactElement(children)) return [children]
|
|
629
|
+
if (typeof children !== 'string' && isIterable(children)) return Array.from(children) as ReactNode[]
|
|
630
|
+
return [children as ReactNode]
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function isHeadElement(value: ReactNode): boolean {
|
|
634
|
+
return isReactElement(value) && typeof value.type === 'string' && DOCUMENT_HEAD_TAGS.has(value.type)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function isHostElement(value: ReactNode, tag: string): boolean {
|
|
638
|
+
return isReactElement(value) && value.type === tag
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function isReactElement(value: unknown): value is ReactElement {
|
|
642
|
+
return !!value && typeof value === 'object' && (value as ReactElement).$$typeof === REACT_ELEMENT_TYPE
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function isIterable(value: unknown): value is Iterable<ReactNode> {
|
|
646
|
+
return !!value && typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] == 'function'
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function createHostElement(type: string, props: Record<string, unknown>): ReactElement {
|
|
650
|
+
return {
|
|
651
|
+
$$typeof: REACT_ELEMENT_TYPE,
|
|
652
|
+
type,
|
|
653
|
+
key: null,
|
|
654
|
+
ref: null,
|
|
655
|
+
props,
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function validateHydrationProps(
|
|
660
|
+
fiber: Fiber,
|
|
661
|
+
el: Element,
|
|
662
|
+
props: Record<string, any>,
|
|
663
|
+
tag: string,
|
|
664
|
+
isSvg: boolean,
|
|
665
|
+
): void {
|
|
666
|
+
if (props.suppressHydrationWarning) return
|
|
667
|
+
|
|
668
|
+
let expected: Element | undefined
|
|
669
|
+
|
|
670
|
+
for (const k in props) {
|
|
671
|
+
const value = props[k]
|
|
672
|
+
if (
|
|
673
|
+
k === 'children' ||
|
|
674
|
+
k === 'key' ||
|
|
675
|
+
k === 'ref' ||
|
|
676
|
+
k === 'suppressHydrationWarning' ||
|
|
677
|
+
k === 'suppressContentEditableWarning' ||
|
|
678
|
+
(k[0] === 'o' && k[1] === 'n' && typeof value == 'function')
|
|
679
|
+
) continue
|
|
680
|
+
|
|
681
|
+
if (k === 'dangerouslySetInnerHTML') {
|
|
682
|
+
const probe = document.createElement('div')
|
|
683
|
+
probe.innerHTML = value?.__html ?? ''
|
|
684
|
+
if ((el as HTMLElement).innerHTML !== probe.innerHTML) {
|
|
685
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
686
|
+
failHydration(
|
|
687
|
+
fiber,
|
|
688
|
+
new Error(`Hydration HTML mismatch inside <${tag}>.`),
|
|
689
|
+
)
|
|
690
|
+
}
|
|
691
|
+
failHydration(fiber)
|
|
692
|
+
}
|
|
693
|
+
continue
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (k === 'value' || k === 'defaultValue') {
|
|
697
|
+
if (tag === 'select') continue
|
|
698
|
+
if (tag === 'input' || tag === 'textarea') {
|
|
699
|
+
if (k === 'value' || props.value == null) {
|
|
700
|
+
if (value != null && (el as HTMLInputElement).value !== '' + value) {
|
|
701
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
702
|
+
failHydration(fiber, new Error(`Hydration ${tag} value mismatch on <${tag}>.`))
|
|
703
|
+
}
|
|
704
|
+
failHydration(fiber)
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
continue
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (tag === 'input' && (k === 'checked' || k === 'defaultChecked')) {
|
|
712
|
+
if (k === 'checked' || props.checked == null) {
|
|
713
|
+
if (value != null && (el as HTMLInputElement).checked !== !!value) {
|
|
714
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
715
|
+
failHydration(fiber, new Error(`Hydration checked mismatch on <input>.`))
|
|
716
|
+
}
|
|
717
|
+
failHydration(fiber)
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
continue
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (tag === 'option' && k === 'selected') {
|
|
724
|
+
if (value != null && (el as HTMLOptionElement).selected !== !!value) {
|
|
725
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
726
|
+
failHydration(fiber, new Error(`Hydration selected mismatch on <option>.`))
|
|
727
|
+
}
|
|
728
|
+
failHydration(fiber)
|
|
729
|
+
}
|
|
730
|
+
continue
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (k === 'style') {
|
|
734
|
+
expected ??= createHostNode(tag, isSvg)
|
|
735
|
+
setProp(expected, k, value, undefined, isSvg)
|
|
736
|
+
if ((el as HTMLElement).style.cssText !== (expected as HTMLElement).style.cssText) {
|
|
737
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
738
|
+
failHydration(
|
|
739
|
+
fiber,
|
|
740
|
+
new Error(`Hydration style mismatch on <${tag}>.`),
|
|
741
|
+
)
|
|
742
|
+
}
|
|
743
|
+
failHydration(fiber)
|
|
744
|
+
}
|
|
745
|
+
continue
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const stringifiedBoolean = k.startsWith('aria-') || k.startsWith('data-')
|
|
749
|
+
const attr = k === 'className' ? 'class' : k === 'htmlFor' ? 'for' : stringifiedBoolean ? k : k.toLowerCase()
|
|
750
|
+
|
|
751
|
+
let expectedValue: string | null
|
|
752
|
+
if (value == null || (value === false && !stringifiedBoolean)) {
|
|
753
|
+
expectedValue = null
|
|
754
|
+
} else {
|
|
755
|
+
expected ??= createHostNode(tag, isSvg)
|
|
756
|
+
setProp(expected, k, value, undefined, isSvg)
|
|
757
|
+
expectedValue = expected.getAttribute(attr)
|
|
758
|
+
}
|
|
759
|
+
const actualValue = el.getAttribute(attr)
|
|
760
|
+
if (expectedValue !== actualValue) {
|
|
761
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
762
|
+
failHydration(
|
|
763
|
+
fiber,
|
|
764
|
+
new Error(
|
|
765
|
+
`Hydration attribute mismatch on <${tag}> for "${attr}": ` +
|
|
766
|
+
`expected ${formatHydrationValue(expectedValue)} but found ${formatHydrationValue(actualValue)}.`,
|
|
767
|
+
),
|
|
768
|
+
)
|
|
769
|
+
}
|
|
770
|
+
failHydration(fiber)
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function formatHydrationValue(value: string | null): string {
|
|
776
|
+
return value == null ? 'nothing' : JSON.stringify(value)
|
|
362
777
|
}
|