@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.
Files changed (90) hide show
  1. package/dist/core/internal.d.ts +23 -39
  2. package/dist/core/internal.js +7 -23
  3. package/dist/core/internal.js.map +3 -3
  4. package/dist/dom/dispatcher.d.ts +3 -1
  5. package/dist/dom/dispatcher.js +104 -97
  6. package/dist/dom/dispatcher.js.map +2 -2
  7. package/dist/dom/dom.js +1 -1
  8. package/dist/dom/dom.js.map +2 -2
  9. package/dist/dom/event-replay.js +7 -29
  10. package/dist/dom/event-replay.js.map +2 -2
  11. package/dist/dom/features/class/full.js +16 -16
  12. package/dist/dom/features/class/full.js.map +2 -2
  13. package/dist/dom/features/class/stub.js +7 -7
  14. package/dist/dom/features/class/stub.js.map +2 -2
  15. package/dist/dom/features/context/full.js +7 -7
  16. package/dist/dom/features/context/full.js.map +2 -2
  17. package/dist/dom/features/context/stub.js +3 -3
  18. package/dist/dom/features/context/stub.js.map +2 -2
  19. package/dist/dom/features/forward-ref/full.js +11 -11
  20. package/dist/dom/features/forward-ref/full.js.map +2 -2
  21. package/dist/dom/features/hydration/full.d.ts +26 -14
  22. package/dist/dom/features/hydration/full.js +417 -92
  23. package/dist/dom/features/hydration/full.js.map +2 -2
  24. package/dist/dom/features/hydration/stub.d.ts +13 -12
  25. package/dist/dom/features/hydration/stub.js +23 -8
  26. package/dist/dom/features/hydration/stub.js.map +2 -2
  27. package/dist/dom/features/lazy/full.js +3 -43
  28. package/dist/dom/features/lazy/full.js.map +2 -2
  29. package/dist/dom/features/lazy/stub.js +1 -1
  30. package/dist/dom/features/lazy/stub.js.map +2 -2
  31. package/dist/dom/features/memo/full.js +3 -3
  32. package/dist/dom/features/memo/full.js.map +2 -2
  33. package/dist/dom/features/memo/stub.js +1 -1
  34. package/dist/dom/features/memo/stub.js.map +2 -2
  35. package/dist/dom/features/portal/full.js +2 -2
  36. package/dist/dom/features/portal/full.js.map +2 -2
  37. package/dist/dom/features/suspense/full.js +151 -111
  38. package/dist/dom/features/suspense/full.js.map +2 -2
  39. package/dist/dom/reconcile.d.ts +3 -1
  40. package/dist/dom/reconcile.js +141 -129
  41. package/dist/dom/reconcile.js.map +2 -2
  42. package/dist/dom/root-internal.d.ts +9 -0
  43. package/dist/dom/root-internal.js +31 -0
  44. package/dist/dom/root-internal.js.map +7 -0
  45. package/dist/dom/root.d.ts +1 -1
  46. package/dist/dom/root.js +4 -136
  47. package/dist/dom/root.js.map +2 -2
  48. package/dist/dom/test-utils.js +1 -1
  49. package/dist/dom/test-utils.js.map +2 -2
  50. package/dist/react/children.js +4 -1
  51. package/dist/react/children.js.map +2 -2
  52. package/dist/react/class.js +8 -2
  53. package/dist/react/class.js.map +2 -2
  54. package/dist/react/context.js +4 -1
  55. package/dist/react/context.js.map +2 -2
  56. package/dist/react/index.js +1 -1
  57. package/dist/react/index.js.map +2 -2
  58. package/dist/react/shared-internals.d.ts +4 -7
  59. package/dist/react/shared-internals.js +4 -7
  60. package/dist/react/shared-internals.js.map +2 -2
  61. package/dist/server/stream.d.ts +5 -1
  62. package/dist/server/stream.js.map +2 -2
  63. package/package.json +1 -1
  64. package/src/core/internal.ts +30 -50
  65. package/src/dom/dispatcher.ts +114 -105
  66. package/src/dom/dom.ts +1 -1
  67. package/src/dom/event-replay.ts +14 -30
  68. package/src/dom/features/class/full.ts +16 -16
  69. package/src/dom/features/class/stub.ts +7 -7
  70. package/src/dom/features/context/full.ts +7 -7
  71. package/src/dom/features/context/stub.ts +3 -3
  72. package/src/dom/features/forward-ref/full.ts +11 -11
  73. package/src/dom/features/hydration/full.ts +519 -104
  74. package/src/dom/features/hydration/stub.ts +28 -14
  75. package/src/dom/features/lazy/full.ts +3 -56
  76. package/src/dom/features/lazy/stub.ts +1 -1
  77. package/src/dom/features/memo/full.ts +3 -3
  78. package/src/dom/features/memo/stub.ts +1 -1
  79. package/src/dom/features/portal/full.ts +2 -2
  80. package/src/dom/features/suspense/full.ts +176 -131
  81. package/src/dom/reconcile.ts +164 -170
  82. package/src/dom/root-internal.ts +40 -0
  83. package/src/dom/root.ts +5 -165
  84. package/src/dom/test-utils.ts +1 -1
  85. package/src/react/children.ts +4 -1
  86. package/src/react/class.ts +8 -2
  87. package/src/react/context.ts +4 -1
  88. package/src/react/index.ts +1 -1
  89. package/src/react/shared-internals.ts +8 -14
  90. package/src/server/stream.ts +9 -4
@@ -1,12 +1,19 @@
1
- import { FiberTag, type Fiber, type FiberRoot } from '../../../core'
2
- import { setProp } from '../../dom'
3
- import { findRoot } from '../../reconcile'
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 } from '../../event-replay'
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.__redactScrollGuardInstalled) return
25
- w.__redactScrollGuardInstalled = true
26
- const guardStartedAt = performance.now()
31
+ if (w._r) return
32
+ const time = Date.now
33
+ const guardStartedAt = w._r = time()
27
34
  let lastUserScrollAt = 0
28
- let programmatic = 0
29
- w.__redactScrollLog = []
30
- window.addEventListener(
35
+ let programmatic = false
36
+ w.addEventListener(
31
37
  'scroll',
32
38
  () => {
33
- if (programmatic === 0) {
34
- lastUserScrollAt = performance.now()
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 = window.scrollTo.bind(window)
41
- window.scrollTo = function (this: any, ...args: any[]) {
42
- const now = performance.now()
43
- const inGuardWindow = now - guardStartedAt < GUARD_WINDOW_MS
44
- const userScrolledRecently = lastUserScrollAt > 0 && now - lastUserScrollAt < 1500
45
- if (inGuardWindow && userScrolledRecently) {
46
- w.__redactScrollLog.push({
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
- w.__redactScrollLog.push({
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(this, args)
56
+ return (origScrollTo as any).apply(w, args)
66
57
  } finally {
67
58
  queueMicrotask(() => {
68
- programmatic = Math.max(0, programmatic - 1)
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
- next: ChildNode | null
83
- parent: Node
84
- endBefore: ChildNode | null
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.parent = parent
87
- this.next = start ?? parent.firstChild
88
- this.endBefore = endBefore
77
+ this.p = parent
78
+ this.n = start ?? parent.firstChild
79
+ this.e = endBefore
89
80
  }
90
- takeHostNode(): ChildNode | null {
91
- while (this.next && this.next !== this.endBefore) {
92
- const n = this.next
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.next = n.nextSibling
87
+ this.n = n.nextSibling
97
88
  continue
98
89
  }
99
- this.next = n.nextSibling
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
- takeMatchingHeadElement(tag: string, props: Record<string, any>): ChildNode | null {
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.parent.firstChild
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
- remaining(): ChildNode[] {
129
- const out: ChildNode[] = []
130
- let n = this.next
131
- while (n && n !== this.endBefore) {
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 out
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.length === 0) return true
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 keys.some((k) => props[k] != null || el.hasAttribute(k))
263
+ return matched
172
264
  }
173
265
 
174
266
  export function beginHydration(root: FiberRoot): void {
175
- root.hydrating = true
176
- hydrationCursors.set(root.current, new HydrationCursor(root.container))
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.hydrating = false
181
- hydrationCursors.delete(root.current)
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 interface BoundaryInfo {
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.next
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] === '$?' ? 'pending' : 'resolved'
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.next = startMark.nextSibling
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 { kind, id, startMark, endMark }
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.next = node.nextSibling
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 = getDocumentHeadParent(cursor.parent, tag)
252
- const parentEl = cursor.parent as Element
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).takeMatchingHeadElement(
353
+ candidate = new HydrationCursor(documentHeadParent).head(
264
354
  tag,
265
- fiber.pendingProps ?? {},
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.takeMatchingHeadElement(tag, fiber.pendingProps ?? {})
362
+ candidate = cursor.head(tag, fiber.pp ?? {})
273
363
  } else {
274
- candidate = cursor.takeHostNode()
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.pendingProps ?? {}
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] === 'function') {
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.takeHostNode()
321
- if (!candidate) return false
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
- ;(candidate as Text).data = text
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
- throw new Error('No host parent found')
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): void {
348
- // For v1: log and exit hydration for this subtree. The normal reconciler
349
- // will create a fresh DOM node below.
350
- const root = findRoot(fiber)
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
- // Remove stale DOM if still there
361
- if (actualNode && actualNode.parentNode) actualNode.parentNode.removeChild(actualNode)
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
  }