@tanstack/redact 0.0.21 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/dist/core/attributes.js +2 -1
  2. package/dist/core/attributes.js.map +2 -2
  3. package/dist/core/browser.d.ts +9 -0
  4. package/dist/core/browser.js +35 -0
  5. package/dist/core/browser.js.map +7 -0
  6. package/dist/core/index.d.ts +2 -2
  7. package/dist/core/index.js +3 -3
  8. package/dist/core/internal.d.ts +31 -8
  9. package/dist/core/internal.js +3 -1
  10. package/dist/core/internal.js.map +2 -2
  11. package/dist/core/resource-hints.d.ts +7 -0
  12. package/dist/core/resource-hints.js +19 -0
  13. package/dist/core/resource-hints.js.map +7 -0
  14. package/dist/core/types.js +1 -1
  15. package/dist/dom/_all.d.ts +11 -16
  16. package/dist/dom/_all.js +9 -20
  17. package/dist/dom/_all.js.map +2 -2
  18. package/dist/dom/client.d.ts +4 -4
  19. package/dist/dom/client.js +4 -4
  20. package/dist/dom/commit.d.ts +23 -0
  21. package/dist/dom/commit.js +191 -0
  22. package/dist/dom/commit.js.map +7 -0
  23. package/dist/dom/dispatcher.d.ts +4 -22
  24. package/dist/dom/dispatcher.js +184 -126
  25. package/dist/dom/dispatcher.js.map +3 -3
  26. package/dist/dom/dom.d.ts +2 -0
  27. package/dist/dom/dom.js +72 -52
  28. package/dist/dom/dom.js.map +2 -2
  29. package/dist/dom/error-info.d.ts +2 -0
  30. package/dist/dom/error-info.js +16 -0
  31. package/dist/dom/error-info.js.map +7 -0
  32. package/dist/dom/event-replay.js +1 -1
  33. package/dist/dom/features/activity/full.d.ts +1 -0
  34. package/dist/dom/features/activity/full.js +162 -0
  35. package/dist/dom/features/activity/full.js.map +7 -0
  36. package/dist/dom/features/activity/index.d.ts +1 -0
  37. package/dist/dom/features/activity/index.js +3 -0
  38. package/dist/dom/features/activity/index.js.map +7 -0
  39. package/dist/dom/features/activity/stub.d.ts +1 -0
  40. package/dist/dom/features/activity/stub.js +12 -0
  41. package/dist/dom/features/activity/stub.js.map +7 -0
  42. package/dist/dom/features/class/full.js +87 -27
  43. package/dist/dom/features/class/full.js.map +2 -2
  44. package/dist/dom/features/class/index.d.ts +1 -1
  45. package/dist/dom/features/class/index.js +2 -2
  46. package/dist/dom/features/class/stub.js +3 -3
  47. package/dist/dom/features/context/full.d.ts +4 -1
  48. package/dist/dom/features/context/full.js +69 -9
  49. package/dist/dom/features/context/full.js.map +2 -2
  50. package/dist/dom/features/context/index.d.ts +1 -1
  51. package/dist/dom/features/context/index.js +2 -2
  52. package/dist/dom/features/context/stub.d.ts +4 -1
  53. package/dist/dom/features/context/stub.js +15 -4
  54. package/dist/dom/features/context/stub.js.map +2 -2
  55. package/dist/dom/features/forward-ref/full.js +12 -21
  56. package/dist/dom/features/forward-ref/full.js.map +2 -2
  57. package/dist/dom/features/forward-ref/index.d.ts +1 -1
  58. package/dist/dom/features/forward-ref/index.js +2 -2
  59. package/dist/dom/features/forward-ref/stub.js +4 -4
  60. package/dist/dom/features/fragment-refs/index.d.ts +1 -0
  61. package/dist/dom/features/fragment-refs/index.js +8 -0
  62. package/dist/dom/features/fragment-refs/index.js.map +7 -0
  63. package/dist/dom/features/fragment-refs/stub.d.ts +3 -0
  64. package/dist/dom/features/fragment-refs/stub.js +14 -0
  65. package/dist/dom/features/fragment-refs/stub.js.map +7 -0
  66. package/dist/dom/features/hydration/full.d.ts +11 -6
  67. package/dist/dom/features/hydration/full.js +76 -77
  68. package/dist/dom/features/hydration/full.js.map +2 -2
  69. package/dist/dom/features/hydration/index.d.ts +1 -1
  70. package/dist/dom/features/hydration/index.js +2 -2
  71. package/dist/dom/features/hydration/stub.d.ts +7 -2
  72. package/dist/dom/features/hydration/stub.js +1 -1
  73. package/dist/dom/features/hydration/stub.js.map +1 -1
  74. package/dist/dom/features/index.d.ts +8 -7
  75. package/dist/dom/features/index.js +9 -8
  76. package/dist/dom/features/index.js.map +2 -2
  77. package/dist/dom/features/lazy/full.js +4 -4
  78. package/dist/dom/features/lazy/index.d.ts +1 -1
  79. package/dist/dom/features/lazy/index.js +2 -2
  80. package/dist/dom/features/lazy/stub.js +4 -4
  81. package/dist/dom/features/memo/full.js +8 -6
  82. package/dist/dom/features/memo/full.js.map +2 -2
  83. package/dist/dom/features/memo/index.d.ts +1 -1
  84. package/dist/dom/features/memo/index.js +2 -2
  85. package/dist/dom/features/memo/stub.js +4 -4
  86. package/dist/dom/features/portal/full.js +4 -4
  87. package/dist/dom/features/portal/index.d.ts +1 -1
  88. package/dist/dom/features/portal/index.js +2 -2
  89. package/dist/dom/features/portal/stub.js +4 -4
  90. package/dist/dom/features/suspense/full.js +197 -52
  91. package/dist/dom/features/suspense/full.js.map +2 -2
  92. package/dist/dom/features/suspense/index.d.ts +1 -1
  93. package/dist/dom/features/suspense/index.js +2 -2
  94. package/dist/dom/features/suspense/stub.js +4 -4
  95. package/dist/dom/features/view-transition/full.d.ts +6 -0
  96. package/dist/dom/features/view-transition/full.js +280 -0
  97. package/dist/dom/features/view-transition/full.js.map +7 -0
  98. package/dist/dom/features/view-transition/index.d.ts +1 -0
  99. package/dist/dom/features/view-transition/index.js +3 -0
  100. package/dist/dom/features/view-transition/index.js.map +7 -0
  101. package/dist/dom/features/view-transition/resources.d.ts +11 -0
  102. package/dist/dom/features/view-transition/resources.js +108 -0
  103. package/dist/dom/features/view-transition/resources.js.map +7 -0
  104. package/dist/dom/features/view-transition/stub.d.ts +6 -0
  105. package/dist/dom/features/view-transition/stub.js +22 -0
  106. package/dist/dom/features/view-transition/stub.js.map +7 -0
  107. package/dist/dom/features/view-transition/visual.d.ts +14 -0
  108. package/dist/dom/features/view-transition/visual.js +356 -0
  109. package/dist/dom/features/view-transition/visual.js.map +7 -0
  110. package/dist/dom/fragment-instance.d.ts +40 -0
  111. package/dist/dom/fragment-instance.js +324 -0
  112. package/dist/dom/fragment-instance.js.map +7 -0
  113. package/dist/dom/index.d.ts +11 -11
  114. package/dist/dom/index.js +11 -18
  115. package/dist/dom/index.js.map +3 -3
  116. package/dist/dom/portal.d.ts +2 -2
  117. package/dist/dom/portal.js +2 -2
  118. package/dist/dom/portal.js.map +1 -1
  119. package/dist/dom/reconcile.d.ts +32 -3
  120. package/dist/dom/reconcile.js +689 -221
  121. package/dist/dom/reconcile.js.map +2 -2
  122. package/dist/dom/resource-hints.d.ts +6 -0
  123. package/dist/dom/resource-hints.js +115 -0
  124. package/dist/dom/resource-hints.js.map +7 -0
  125. package/dist/dom/resource-store.d.ts +13 -0
  126. package/dist/dom/resource-store.js +43 -0
  127. package/dist/dom/resource-store.js.map +7 -0
  128. package/dist/dom/resources.d.ts +1 -0
  129. package/dist/dom/resources.js +44 -0
  130. package/dist/dom/resources.js.map +7 -0
  131. package/dist/dom/retained-effects.d.ts +6 -0
  132. package/dist/dom/retained-effects.js +49 -0
  133. package/dist/dom/retained-effects.js.map +7 -0
  134. package/dist/dom/root-internal.d.ts +4 -4
  135. package/dist/dom/root-internal.js +2 -2
  136. package/dist/dom/root-internal.js.map +2 -2
  137. package/dist/dom/root.d.ts +5 -5
  138. package/dist/dom/root.js +11 -11
  139. package/dist/dom/root.js.map +2 -2
  140. package/dist/dom/test-utils.js +1 -1
  141. package/dist/react/activity.d.ts +7 -0
  142. package/dist/react/activity.js +8 -0
  143. package/dist/react/activity.js.map +7 -0
  144. package/dist/react/children.d.ts +1 -1
  145. package/dist/react/children.js +3 -3
  146. package/dist/react/children.js.map +1 -1
  147. package/dist/react/class.d.ts +1 -1
  148. package/dist/react/class.js +3 -3
  149. package/dist/react/class.js.map +1 -1
  150. package/dist/react/compiler-runtime.js +2 -2
  151. package/dist/react/context.d.ts +1 -1
  152. package/dist/react/context.js +3 -3
  153. package/dist/react/context.js.map +1 -1
  154. package/dist/react/element.d.ts +6 -3
  155. package/dist/react/element.js +2 -2
  156. package/dist/react/element.js.map +2 -2
  157. package/dist/react/hooks.d.ts +26 -7
  158. package/dist/react/hooks.js +13 -3
  159. package/dist/react/hooks.js.map +2 -2
  160. package/dist/react/index.d.ts +39 -24
  161. package/dist/react/index.js +39 -19
  162. package/dist/react/index.js.map +3 -3
  163. package/dist/react/jsx-runtime.d.ts +2 -2
  164. package/dist/react/jsx-runtime.js +3 -3
  165. package/dist/react/memo.d.ts +1 -1
  166. package/dist/react/memo.js +1 -1
  167. package/dist/react/portal.js +1 -1
  168. package/dist/react/shared-internals.d.ts +15 -5
  169. package/dist/react/shared-internals.js +2 -3
  170. package/dist/react/shared-internals.js.map +2 -2
  171. package/dist/react/suspense.js +1 -1
  172. package/dist/react/view-transition.d.ts +32 -0
  173. package/dist/react/view-transition.js +13 -0
  174. package/dist/react/view-transition.js.map +7 -0
  175. package/dist/scheduler/index.js +1 -1
  176. package/dist/server/bootstrap-script.d.ts +1 -23
  177. package/dist/server/bootstrap-script.js +7 -6
  178. package/dist/server/bootstrap-script.js.map +2 -2
  179. package/dist/server/dispatcher.d.ts +28 -6
  180. package/dist/server/dispatcher.js +33 -15
  181. package/dist/server/dispatcher.js.map +2 -2
  182. package/dist/server/escape.js +16 -15
  183. package/dist/server/escape.js.map +2 -2
  184. package/dist/server/index.d.ts +6 -6
  185. package/dist/server/index.js +7 -7
  186. package/dist/server/renderToString.d.ts +1 -1
  187. package/dist/server/renderToString.js +12 -8
  188. package/dist/server/renderToString.js.map +2 -2
  189. package/dist/server/resource-hints.d.ts +22 -0
  190. package/dist/server/resource-hints.js +148 -0
  191. package/dist/server/resource-hints.js.map +7 -0
  192. package/dist/server/stream.d.ts +15 -2
  193. package/dist/server/stream.js +155 -63
  194. package/dist/server/stream.js.map +2 -2
  195. package/dist/server/walk.d.ts +15 -2
  196. package/dist/server/walk.js +143 -96
  197. package/dist/server/walk.js.map +2 -2
  198. package/dist/vite/index.d.ts +6 -0
  199. package/dist/vite/index.js +22 -6
  200. package/dist/vite/index.js.map +2 -2
  201. package/package.json +1 -1
  202. package/src/core/attributes.ts +1 -0
  203. package/src/core/browser.ts +33 -0
  204. package/src/core/internal.ts +24 -6
  205. package/src/core/resource-hints.ts +36 -0
  206. package/src/dom/_all.ts +1 -7
  207. package/src/dom/commit.ts +186 -0
  208. package/src/dom/dispatcher.ts +190 -134
  209. package/src/dom/dom.ts +70 -54
  210. package/src/dom/error-info.ts +15 -0
  211. package/src/dom/features/activity/full.ts +166 -0
  212. package/src/dom/features/activity/index.ts +1 -0
  213. package/src/dom/features/activity/stub.ts +14 -0
  214. package/src/dom/features/class/full.ts +72 -28
  215. package/src/dom/features/context/full.ts +66 -4
  216. package/src/dom/features/context/stub.ts +6 -0
  217. package/src/dom/features/forward-ref/full.ts +9 -19
  218. package/src/dom/features/fragment-refs/index.ts +1 -0
  219. package/src/dom/features/fragment-refs/stub.ts +3 -0
  220. package/src/dom/features/hydration/full.ts +84 -85
  221. package/src/dom/features/hydration/stub.ts +1 -1
  222. package/src/dom/features/index.ts +1 -0
  223. package/src/dom/features/memo/full.ts +4 -3
  224. package/src/dom/features/suspense/full.ts +192 -55
  225. package/src/dom/features/view-transition/full.ts +288 -0
  226. package/src/dom/features/view-transition/index.ts +1 -0
  227. package/src/dom/features/view-transition/resources.ts +107 -0
  228. package/src/dom/features/view-transition/stub.ts +7 -0
  229. package/src/dom/features/view-transition/visual.ts +406 -0
  230. package/src/dom/fragment-instance.ts +337 -0
  231. package/src/dom/index.ts +6 -7
  232. package/src/dom/portal.ts +1 -1
  233. package/src/dom/reconcile.ts +695 -242
  234. package/src/dom/resource-hints.ts +115 -0
  235. package/src/dom/resource-store.ts +52 -0
  236. package/src/dom/resources.ts +39 -0
  237. package/src/dom/retained-effects.ts +38 -0
  238. package/src/dom/root-internal.ts +4 -4
  239. package/src/dom/root.ts +12 -14
  240. package/src/react/activity.ts +10 -0
  241. package/src/react/element.ts +6 -2
  242. package/src/react/hooks.ts +12 -3
  243. package/src/react/index.ts +18 -0
  244. package/src/react/shared-internals.ts +3 -3
  245. package/src/react/view-transition.ts +41 -0
  246. package/src/server/bootstrap-script.ts +36 -5
  247. package/src/server/dispatcher.ts +35 -13
  248. package/src/server/escape.ts +15 -12
  249. package/src/server/renderToString.ts +7 -5
  250. package/src/server/resource-hints.ts +165 -0
  251. package/src/server/stream.ts +154 -62
  252. package/src/server/walk.ts +72 -11
  253. package/src/vite/index.ts +30 -13
@@ -2,21 +2,25 @@ import type { ReactNode } from '../core'
2
2
  import {
3
3
  beginSSR,
4
4
  endSSR,
5
- installSSRDispatcher,
6
- uninstallSSRDispatcher,
7
5
  applyContextSnapshot,
6
+ currentSSRFrame,
7
+ type SSRFrame,
8
8
  } from './dispatcher'
9
- import { walk, type SuspendedBoundary } from './walk'
9
+ import { walk, type SuspendedBoundary, type BrowserBailoutCallback } from './walk'
10
+ import { browserError, isBrowserToken, isBrowserError } from '../core/browser'
10
11
  import { BOUNDARY_REVEAL_RUNTIME, revealScript } from './bootstrap-script'
11
- import { escapeScript } from './escape'
12
+ import { escapeAttr, escapeScript } from './escape'
13
+ import { resourceURL } from '../core/resource-hints'
14
+ import type { ResourceScope } from './resource-hints'
12
15
 
13
16
  export interface StreamOptions {
14
17
  identifierPrefix?: string
15
- nonce?: string
18
+ nonce?: string | { script?: string; style?: string }
16
19
  bootstrapScriptContent?: string | ReadonlyArray<string>
17
20
  bootstrapScripts?: ReadonlyArray<string | { src: string; async?: boolean; nonce?: string }>
18
21
  bootstrapModules?: ReadonlyArray<string | { src: string; nonce?: string }>
19
22
  onError?: (error: unknown) => string | void
23
+ onBrowserBailout?: BrowserBailoutCallback
20
24
  signal?: AbortSignal
21
25
  progressiveChunkSize?: number
22
26
  }
@@ -28,6 +32,7 @@ export interface ReadableStreamResult extends ReadableStream<Uint8Array> {
28
32
  export interface PipeableWritable {
29
33
  write(chunk: string): unknown
30
34
  end(): unknown
35
+ destroy?(error?: unknown): unknown
31
36
  }
32
37
 
33
38
  export interface OrchestratorState {
@@ -35,6 +40,28 @@ export interface OrchestratorState {
35
40
  pending: Set<Promise<void>>
36
41
  closed: boolean
37
42
  errored: unknown | null
43
+ aborted: boolean
44
+ reason: unknown
45
+ abort: (reason?: unknown) => void
46
+ cancelled: Promise<void>
47
+ onShellReady?: () => void
48
+ frame?: SSRFrame
49
+ }
50
+
51
+ function createState(): OrchestratorState {
52
+ let wake!: () => void
53
+ const state: OrchestratorState = {
54
+ nextId: 0, pending: new Set(), closed: false, errored: null,
55
+ aborted: false, reason: undefined,
56
+ cancelled: new Promise<void>((resolve) => { wake = resolve }),
57
+ abort(reason) {
58
+ if (state.closed || state.aborted) return
59
+ state.aborted = true
60
+ state.reason = isBrowserToken(reason) ? browserError(reason) : reason ?? new Error('The render was aborted by the server without a reason.')
61
+ wake()
62
+ },
63
+ }
64
+ return state
38
65
  }
39
66
 
40
67
  type Emit = (chunk: string) => void
@@ -45,9 +72,7 @@ export async function streamHtml(
45
72
  options: StreamOptions,
46
73
  state: OrchestratorState,
47
74
  ): Promise<void> {
48
- installSSRDispatcher()
49
- beginSSR(options.identifierPrefix)
50
- const nonce = options.nonce
75
+ const nonce = scriptNonce(options)
51
76
 
52
77
  try {
53
78
  const boundaries: SuspendedBoundary[] = []
@@ -60,6 +85,7 @@ export async function streamHtml(
60
85
  // one encode and one enqueue per shell — measured ~2-4% of total SSR
61
86
  // time on CPU profiles.
62
87
  const shellChunks: string[] = []
88
+ let head = 0
63
89
  const bufferedEmit: Emit = (chunk) => {
64
90
  shellChunks.push(chunk)
65
91
  }
@@ -70,17 +96,27 @@ export async function streamHtml(
70
96
  // partial shell chunks or boundary ids from the aborted attempt.
71
97
  let shellRendered = false
72
98
  let rootRetries = 0
99
+ let resources: SSRFrame['resources']
73
100
  while (!shellRendered) {
74
101
  const chunkCount = shellChunks.length
75
102
  const boundaryCount = boundaries.length
76
103
  const nextId = state.nextId
104
+ head = 0
105
+ const previous = beginSSR(options.identifierPrefix)
106
+ currentSSRFrame().styleNonce = typeof options.nonce === 'object' ? options.nonce?.style : undefined
107
+ if (resources) currentSSRFrame().resources = resources
108
+ let suspended: Promise<unknown> | undefined
77
109
  try {
110
+ if (state.aborted) throw state.reason
78
111
  walk(children, {
79
112
  emit: bufferedEmit,
80
113
  onSuspend: (b) => boundaries.push(b),
81
114
  nextBoundaryId: () => state.nextId++,
115
+ onBrowserBailout: options.onBrowserBailout,
116
+ onHead: () => { head = shellChunks.length },
82
117
  })
83
118
  shellRendered = true
119
+ state.frame = currentSSRFrame()
84
120
  } catch (err) {
85
121
  shellChunks.length = chunkCount
86
122
  boundaries.length = boundaryCount
@@ -89,10 +125,18 @@ export async function streamHtml(
89
125
  if (++rootRetries > 50) {
90
126
  throw new Error('renderToReadableStream exceeded 50 root suspension retries.')
91
127
  }
92
- await err
128
+ suspended = err
129
+ } finally {
130
+ resources = currentSSRFrame().resources
131
+ endSSR(previous)
93
132
  }
133
+ if (suspended) await Promise.race([suspended, state.cancelled])
94
134
  }
95
135
 
136
+ const hints = state.frame?.resources?.drain()
137
+ if (hints) shellChunks.splice(head, 0, hints)
138
+ if (state.frame) state.frame.shellFlushed = true
139
+
96
140
  // 2. Inject runtime + bootstrap scripts (once, after shell). Skip the
97
141
  // reveal/event-replay runtime when nothing needs it — no suspensions to
98
142
  // reveal and no bootstrap scripts to guard against early user input. That
@@ -104,7 +148,7 @@ export async function streamHtml(
104
148
  (options.bootstrapScripts?.length ?? 0) > 0 ||
105
149
  (options.bootstrapModules?.length ?? 0) > 0
106
150
  if (boundaries.length > 0 || hasBootstrap) {
107
- shellChunks.push(`<script${nonce ? ` nonce="${nonce}"` : ''}>${BOUNDARY_REVEAL_RUNTIME}</script>`)
151
+ shellChunks.push(`<script${nonce ? ` nonce="${escapeAttr(nonce)}"` : ''}>${BOUNDARY_REVEAL_RUNTIME}</script>`)
108
152
  for (const content of bootstrapScriptContentToArray(options.bootstrapScriptContent)) {
109
153
  shellChunks.push(inlineBootstrapTag(content, nonce))
110
154
  }
@@ -117,17 +161,20 @@ export async function streamHtml(
117
161
  }
118
162
 
119
163
  if (shellChunks.length) emit(normalizeDocumentShell(shellChunks.join('')))
164
+ state.onShellReady?.()
120
165
 
121
166
  // 3. Stream suspended boundaries as they resolve
122
167
  for (const b of boundaries) streamBoundary(b, emit, options, state)
123
168
  await drain(state)
124
169
  } catch (err) {
170
+ if (isBrowserError(err)) {
171
+ err = new Error('The server render could not complete because client rendering was requested outside a Suspense boundary.',
172
+ Object.hasOwn(err, 'cause') ? { cause: err.cause } : undefined)
173
+ }
125
174
  state.errored = err
126
175
  if (options.onError) options.onError(err)
127
176
  throw err
128
177
  } finally {
129
- endSSR()
130
- uninstallSSRDispatcher()
131
178
  state.closed = true
132
179
  }
133
180
  }
@@ -138,39 +185,59 @@ function streamBoundary(
138
185
  options: StreamOptions,
139
186
  state: OrchestratorState,
140
187
  ): void {
188
+ const nonce = scriptNonce(options)
141
189
  const task = (async () => {
142
190
  try {
143
- await b.thenable
144
- } catch (err) {
145
- if (options.onError) options.onError(err)
146
- }
191
+ await Promise.race([b.thenable, state.cancelled])
192
+ } catch {}
147
193
  if (state.closed) return
148
194
 
195
+ const defer = (error: unknown) => {
196
+ if (isBrowserError(error)) options.onBrowserBailout?.(error, { componentStack: (error as any).componentStack || b.componentStack })
197
+ else options.onError?.(error)
198
+ emit(`<script${nonce ? ` nonce="${escapeAttr(nonce)}"` : ''}>$RB(${b.id}${isBrowserError(error) ? '' : ',1'})</script>`)
199
+ }
200
+ if (state.aborted) { defer(state.reason); return }
201
+
149
202
  // Re-render the boundary's children into a string, restoring the
150
203
  // provider stack from when the boundary first suspended.
151
204
  const parts: string[] = []
152
205
  const sub: SuspendedBoundary[] = []
206
+ const resourceScope: ResourceScope = {}
207
+ let styles: string[][] | undefined
208
+ const previous = beginSSR(options.identifierPrefix, state.frame)
209
+ state.frame?.resources?.takeStylesheets()
153
210
  const restore = applyContextSnapshot(b.contextSnapshot)
154
211
  try {
155
212
  walk(b.children, {
156
213
  emit: (s) => parts.push(s),
157
214
  onSuspend: (n) => sub.push(n),
158
215
  nextBoundaryId: () => state.nextId++,
216
+ onBrowserBailout: options.onBrowserBailout,
217
+ componentStack: b.componentStack,
218
+ resourceScope,
159
219
  })
220
+ state.frame?.resources?.mergeScope(resourceScope)
160
221
  } catch (err) {
161
- if (options.onError) options.onError(err)
162
- restore()
222
+ if (isThenable(err)) {
223
+ streamBoundary({ ...b, thenable: err }, emit, options, state)
224
+ } else defer(err)
163
225
  return
226
+ } finally {
227
+ restore()
228
+ endSSR(previous)
229
+ const hints = state.frame?.resources?.drain()
230
+ styles = state.frame?.resources?.takeStylesheets()
231
+ if (hints) emit(hints)
164
232
  }
165
- restore()
166
233
 
167
- emit(`<div hidden id="S:${b.id}">${parts.join('')}</div>${revealScript(b.id, options.nonce)}`)
234
+ emit(`<div hidden id="S:${b.id}">${parts.join('')}</div>${revealScript(b.id, nonce, styles)}`)
168
235
 
169
236
  // Recurse: any nested suspensions inside the now-revealed content
170
237
  for (const s of sub) streamBoundary(s, emit, options, state)
171
238
  })()
172
239
  state.pending.add(task)
173
- task.finally(() => state.pending.delete(task))
240
+ task.then(() => state.pending.delete(task), () => state.pending.delete(task))
174
241
  }
175
242
 
176
243
  async function drain(state: OrchestratorState): Promise<void> {
@@ -183,6 +250,10 @@ function isThenable(value: unknown): value is Promise<unknown> {
183
250
  return !!value && typeof (value as { then?: unknown }).then === 'function'
184
251
  }
185
252
 
253
+ function scriptNonce(options: StreamOptions): string | undefined {
254
+ return typeof options.nonce === 'string' ? options.nonce : options.nonce?.script
255
+ }
256
+
186
257
  function bootstrapScriptContentToArray(
187
258
  content: StreamOptions['bootstrapScriptContent'],
188
259
  ): string[] {
@@ -191,7 +262,7 @@ function bootstrapScriptContentToArray(
191
262
  }
192
263
 
193
264
  function inlineBootstrapTag(content: string, defaultNonce: string | undefined): string {
194
- const nAttr = defaultNonce ? ` nonce="${defaultNonce}"` : ''
265
+ const nAttr = defaultNonce ? ` nonce="${escapeAttr(defaultNonce)}"` : ''
195
266
  return `<script${nAttr}>${escapeScript(content)}</script>`
196
267
  }
197
268
 
@@ -237,9 +308,9 @@ function bootstrapTag(
237
308
  kind: 'script' | 'module',
238
309
  defaultNonce: string | undefined,
239
310
  ): string {
240
- const src = typeof entry === 'string' ? entry : entry.src
311
+ const src = escapeAttr(resourceURL(typeof entry === 'string' ? entry : entry.src))
241
312
  const nonce = typeof entry === 'string' ? defaultNonce : entry.nonce ?? defaultNonce
242
- const nAttr = nonce ? ` nonce="${nonce}"` : ''
313
+ const nAttr = nonce ? ` nonce="${escapeAttr(nonce)}"` : ''
243
314
  if (kind === 'module') return `<script type="module"${nAttr} src="${src}"></script>`
244
315
  return `<script async${nAttr} src="${src}"></script>`
245
316
  }
@@ -252,13 +323,15 @@ export function renderToReadableStream(
252
323
  children: ReactNode,
253
324
  options: StreamOptions = {},
254
325
  ): Promise<ReadableStreamResult> {
255
- const state: OrchestratorState = {
256
- nextId: 0,
257
- pending: new Set(),
258
- closed: false,
259
- errored: null,
260
- }
326
+ const state = createState()
261
327
  const encoder = new TextEncoder()
328
+ let abortListener: (() => void) | undefined
329
+ const detachAbortListener = () => {
330
+ if (abortListener) {
331
+ options.signal?.removeEventListener('abort', abortListener)
332
+ abortListener = undefined
333
+ }
334
+ }
262
335
 
263
336
  let allReadyResolve!: () => void
264
337
  let allReadyReject!: (e: unknown) => void
@@ -266,6 +339,15 @@ export function renderToReadableStream(
266
339
  allReadyResolve = r
267
340
  allReadyReject = rej
268
341
  })
342
+ // Shell failures reject the outer promise before callers receive allReady.
343
+ allReady.catch(() => {})
344
+ let shellResolve!: (stream: ReadableStreamResult) => void
345
+ let shellReject!: (error: unknown) => void
346
+ const shell = new Promise<ReadableStreamResult>((resolve, reject) => {
347
+ shellResolve = resolve
348
+ shellReject = reject
349
+ })
350
+ state.onShellReady = () => queueMicrotask(() => shellResolve(Object.assign(stream, { allReady })))
269
351
 
270
352
  const stream = new ReadableStream<Uint8Array>({
271
353
  start(controller) {
@@ -275,31 +357,41 @@ export function renderToReadableStream(
275
357
  } catch {}
276
358
  }
277
359
 
360
+ if (options.signal) {
361
+ abortListener = () => {
362
+ detachAbortListener()
363
+ state.abort(options.signal!.reason)
364
+ }
365
+ options.signal.addEventListener('abort', abortListener)
366
+ if (options.signal.aborted) abortListener()
367
+ }
368
+
278
369
  streamHtml(children, emit, options, state).then(
279
370
  () => {
371
+ detachAbortListener()
280
372
  try {
281
373
  controller.close()
282
374
  } catch {}
283
375
  allReadyResolve()
284
376
  },
285
377
  (err) => {
378
+ detachAbortListener()
286
379
  try {
287
380
  controller.error(err)
288
381
  } catch {}
289
382
  allReadyReject(err)
383
+ shellReject(err)
290
384
  },
291
385
  )
292
386
 
293
- options.signal?.addEventListener('abort', () => {
294
- state.closed = true
295
- try {
296
- controller.close()
297
- } catch {}
298
- })
387
+ },
388
+ cancel(reason) {
389
+ state.abort(reason)
390
+ detachAbortListener()
299
391
  },
300
392
  })
301
393
 
302
- return Promise.resolve(Object.assign(stream, { allReady }))
394
+ return shell
303
395
  }
304
396
 
305
397
  // ---------------------------------------------------------------------------
@@ -321,18 +413,20 @@ export function renderToPipeableStream(
321
413
  children: ReactNode,
322
414
  options: PipeableOptions = {},
323
415
  ): PipeableHandle {
324
- const state: OrchestratorState = {
325
- nextId: 0,
326
- pending: new Set(),
327
- closed: false,
328
- errored: null,
329
- }
416
+ const state = createState()
330
417
 
331
418
  const buffers: string[] = []
332
419
  let dest: PipeableWritable | null = null
333
420
  let shellReady = false
421
+ let shellFailed = false
334
422
  let finished = false
335
- let aborted = false
423
+ let failed = false
424
+ let failure: unknown
425
+
426
+ const finishDestination = (target: PipeableWritable) => {
427
+ if (failed && target.destroy) target.destroy(failure)
428
+ else target.end()
429
+ }
336
430
 
337
431
  const flushTo = (w: PipeableWritable) => {
338
432
  if (!buffers.length) return
@@ -341,11 +435,17 @@ export function renderToPipeableStream(
341
435
  }
342
436
 
343
437
  const emit: Emit = (chunk) => {
344
- if (aborted || finished) return
438
+ if (finished) return
345
439
  if (dest) dest.write(chunk)
346
440
  else buffers.push(chunk)
347
441
  }
348
442
 
443
+ state.onShellReady = () => queueMicrotask(() => {
444
+ if (shellFailed) return
445
+ shellReady = true
446
+ options.onShellReady?.()
447
+ })
448
+
349
449
  // Kick off rendering
350
450
  streamHtml(children, emit, options, state).then(
351
451
  () => {
@@ -354,35 +454,27 @@ export function renderToPipeableStream(
354
454
  options.onAllReady?.()
355
455
  },
356
456
  (err) => {
457
+ finished = true
458
+ failed = true
459
+ failure = err
357
460
  if (!shellReady) {
461
+ shellFailed = true
358
462
  options.onShellError?.(err)
359
463
  } else {
360
- options.onError?.(err)
361
- if (dest) dest.end()
464
+ if (dest) finishDestination(dest)
362
465
  }
363
466
  },
364
467
  )
365
468
 
366
- // We call onShellReady once the first synchronous emit has landed.
367
- // streamHtml above runs the shell synchronously before the first await in drain(),
368
- // so we can schedule onShellReady right after the first microtask.
369
- queueMicrotask(() => {
370
- if (aborted || finished) return
371
- shellReady = true
372
- options.onShellReady?.()
373
- })
374
-
375
469
  return {
376
470
  pipe<T extends PipeableWritable>(target: T): T {
377
471
  dest = target
378
- flushTo(target)
379
- if (finished) target.end()
472
+ if (!failed) flushTo(target)
473
+ if (finished) finishDestination(target)
380
474
  return target
381
475
  },
382
- abort(_reason?: unknown) {
383
- aborted = true
384
- state.closed = true
385
- if (dest) dest.end()
476
+ abort(reason?: unknown) {
477
+ state.abort(reason)
386
478
  },
387
479
  }
388
480
  }
@@ -15,6 +15,8 @@ import {
15
15
  REACT_STRICT_MODE_TYPE,
16
16
  REACT_PROFILER_TYPE,
17
17
  REACT_PORTAL_TYPE,
18
+ REACT_ACTIVITY_TYPE,
19
+ REACT_VIEW_TRANSITION_TYPE,
18
20
  } from '../react'
19
21
  import {
20
22
  attrToHtml,
@@ -27,8 +29,19 @@ import {
27
29
  pushContext,
28
30
  popContext,
29
31
  snapshotContexts,
32
+ recordHostResource,
33
+ currentSSRFrame,
30
34
  type ContextSnapshot,
31
35
  } from './dispatcher'
36
+ import { isBrowserError } from '../core/browser'
37
+ import { resourceKind } from '../core/resource-hints'
38
+ import type { ResourceScope } from './resource-hints'
39
+
40
+ export interface ServerErrorInfo {
41
+ componentStack: string
42
+ }
43
+
44
+ export type BrowserBailoutCallback = (error: Error, errorInfo: ServerErrorInfo) => void
32
45
 
33
46
  export interface SuspendedBoundary {
34
47
  id: number
@@ -36,15 +49,21 @@ export interface SuspendedBoundary {
36
49
  children: ReactNode
37
50
  thenable: Promise<any>
38
51
  contextSnapshot: ContextSnapshot
52
+ componentStack: string
39
53
  }
40
54
 
41
55
  export interface WalkOptions {
42
56
  emit: (chunk: string) => void
57
+ onHead?: () => void
43
58
  onSuspend?: ((boundary: SuspendedBoundary) => void) | undefined
59
+ onBrowserBailout?: BrowserBailoutCallback | undefined
60
+ componentStack?: string | undefined
61
+ thrownInfo?: { componentStack?: string | undefined }
44
62
  nextBoundaryId: () => number
45
63
  bootstrapped?: boolean | undefined
46
64
  isBoundaryResolution?: boolean | undefined
47
65
  isSvg?: boolean | undefined
66
+ resourceScope?: ResourceScope | undefined
48
67
  /**
49
68
  * Tracks whether the most recent emission within the *current text flow*
50
69
  * ended with a text node. When the next emission is also text, we emit a
@@ -160,6 +179,15 @@ function walkElement(el: ReactElement, opts: WalkOptions): void {
160
179
  return
161
180
  }
162
181
 
182
+ if (type === REACT_ACTIVITY_TYPE) {
183
+ if (props.mode !== 'hidden') walkNode(props.children, opts)
184
+ return
185
+ }
186
+ if (type === REACT_VIEW_TRANSITION_TYPE) {
187
+ walkNode(props.children, opts)
188
+ return
189
+ }
190
+
163
191
  if (typeof type === 'string') {
164
192
  walkHost(type, props, opts)
165
193
  return
@@ -196,8 +224,7 @@ function walkElement(el: ReactElement, opts: WalkOptions): void {
196
224
  const render = (type as any).render
197
225
  const ref = (props as any).ref ?? null
198
226
  const { ref: _omit, ...rest } = props as any
199
- const rendered = render(rest, ref)
200
- walkNode(rendered, opts)
227
+ walkComponent(render, rest, opts, ref)
201
228
  return
202
229
  }
203
230
 
@@ -234,6 +261,7 @@ function walkHost(
234
261
  opts: WalkOptions,
235
262
  ): void {
236
263
  const isSvg = opts.isSvg || tag === 'svg'
264
+ if (!isSvg && resourceKind(tag, props) && recordHostResource(tag, props, opts.resourceScope)) return
237
265
  // <textarea value="..."> serializes its value as a TEXT CHILD, not an
238
266
  // attribute. `defaultValue` is the fallback when `value` is absent. This
239
267
  // matches React and the HTML spec — `<textarea value="x">` is not valid
@@ -270,6 +298,7 @@ function walkHost(
270
298
  pushSelectContext(val)
271
299
  }
272
300
  const isOption = tag === 'option'
301
+ try {
273
302
 
274
303
  // Prepend the HTML5 doctype to the stream when rendering an <html> root.
275
304
  // Without it the browser parses the document in quirks mode, which breaks
@@ -312,6 +341,7 @@ function walkHost(
312
341
  return
313
342
  }
314
343
  opts.emit('>')
344
+ if (tag === 'head') opts.onHead?.()
315
345
  // Opening a host element starts a fresh text flow context for its children.
316
346
  // Children's text separator tracking is independent of the outer context.
317
347
  const parentTextState = opts.textState
@@ -322,7 +352,9 @@ function walkHost(
322
352
  }
323
353
 
324
354
  if (tag === 'html' && !hasHeadChild(props.children)) {
325
- opts.emit('<head></head>')
355
+ opts.emit('<head>')
356
+ opts.onHead?.()
357
+ opts.emit('</head>')
326
358
  }
327
359
 
328
360
  const dangerouslyHtml = props.dangerouslySetInnerHTML?.__html
@@ -358,9 +390,11 @@ function walkHost(
358
390
  walkNode(props.children, childOpts)
359
391
  }
360
392
  opts.emit(`</${tag}>`)
361
- if (isSelect) popSelectContext()
362
393
  // Host element closing resets outer flow — next sibling text starts fresh.
363
394
  if (parentTextState) parentTextState.lastWasText = false
395
+ } finally {
396
+ if (isSelect) popSelectContext()
397
+ }
364
398
  }
365
399
 
366
400
  function hasHeadChild(children: unknown): boolean {
@@ -393,7 +427,12 @@ function walkComponent(
393
427
  fn: Function,
394
428
  props: Record<string, any>,
395
429
  opts: WalkOptions,
430
+ ref?: unknown,
396
431
  ): void {
432
+ if (opts.onBrowserBailout) {
433
+ opts = { ...opts, componentStack: `\n at ${(fn as any).displayName || fn.name || 'Anonymous'}${opts.componentStack || ''}` }
434
+ }
435
+ try {
397
436
  if ((fn as any).prototype?.isReactComponent) {
398
437
  const ctxType = (fn as any).contextType
399
438
  const ctxValue = ctxType ? ctxType._currentValue : undefined
@@ -407,8 +446,15 @@ function walkComponent(
407
446
  walkNode(instance.render(), opts)
408
447
  return
409
448
  }
410
- const rendered = (fn as any)(props)
449
+ const rendered = (fn as any)(props, ref)
411
450
  walkNode(rendered, opts)
451
+ } catch (error) {
452
+ if (opts.thrownInfo && !opts.thrownInfo.componentStack) opts.thrownInfo.componentStack = opts.componentStack
453
+ if (isBrowserError(error) && !(error as any).componentStack) {
454
+ Object.defineProperty(error, 'componentStack', { value: opts.componentStack })
455
+ }
456
+ throw error
457
+ }
412
458
  }
413
459
 
414
460
  function walkSuspense(
@@ -423,26 +469,37 @@ function walkSuspense(
423
469
  // Try to render the children synchronously. If a thenable is thrown,
424
470
  // record the boundary and emit the fallback.
425
471
  const childParts: string[] = []
472
+ const nested: SuspendedBoundary[] = []
473
+ const thrownInfo: { componentStack?: string | undefined } = {}
426
474
  const childEmit = (s: string) => childParts.push(s)
475
+ const resourceScope: ResourceScope | undefined = currentSSRFrame().shellFlushed ? {} : undefined
427
476
  try {
428
477
  walkNode(props.children, {
478
+ ...opts,
479
+ resourceScope,
480
+ thrownInfo,
429
481
  emit: childEmit,
430
- onSuspend: opts.onSuspend,
482
+ onSuspend: opts.onSuspend ? (boundary) => nested.push(boundary) : undefined,
431
483
  nextBoundaryId: opts.nextBoundaryId,
432
484
  })
433
485
  } catch (thenable: any) {
434
- if (isThenable(thenable)) {
486
+ nested.length = 0
487
+ if (isThenable(thenable) || isBrowserError(thenable)) {
435
488
  const fallbackParts: string[] = []
436
- try {
437
489
  walkNode(props.fallback, {
490
+ ...opts,
438
491
  emit: (s) => fallbackParts.push(s),
439
- onSuspend: opts.onSuspend,
492
+ onSuspend: opts.onSuspend ? (boundary) => nested.push(boundary) : undefined,
440
493
  nextBoundaryId: opts.nextBoundaryId,
441
494
  })
442
- } catch {
443
- // Fallback suspending is unsupported; emit nothing
495
+ if (isBrowserError(thenable)) {
496
+ opts.onBrowserBailout?.(thenable, { componentStack: (thenable as any).componentStack || opts.componentStack || '' })
497
+ opts.emit(`<!--$!${id}-->${fallbackParts.join('')}<!--/$-->`)
498
+ for (const boundary of nested) opts.onSuspend?.(boundary)
499
+ return
444
500
  }
445
501
  emitBoundary(opts, id, fallbackParts.join(''))
502
+ for (const boundary of nested) opts.onSuspend?.(boundary)
446
503
 
447
504
  if (opts.onSuspend) {
448
505
  opts.onSuspend({
@@ -451,6 +508,7 @@ function walkSuspense(
451
508
  children: props.children,
452
509
  thenable,
453
510
  contextSnapshot,
511
+ componentStack: thrownInfo.componentStack || opts.componentStack || '',
454
512
  })
455
513
  }
456
514
  return
@@ -458,6 +516,8 @@ function walkSuspense(
458
516
  throw thenable
459
517
  }
460
518
 
519
+ if (resourceScope) currentSSRFrame().resources?.mergeScope(resourceScope, opts.resourceScope)
520
+
461
521
  // Children rendered fully — emit them wrapped in resolved-boundary markers
462
522
  // (`<!--$N-->` / `<!--/$-->`). Without markers, the client hydrator has no
463
523
  // way to know this subtree is inside a Suspense, so if the client version
@@ -468,6 +528,7 @@ function walkSuspense(
468
528
  opts.emit(`<!--$${id}-->`)
469
529
  opts.emit(childParts.join(''))
470
530
  opts.emit(`<!--/$-->`)
531
+ for (const boundary of nested) opts.onSuspend?.(boundary)
471
532
  }
472
533
 
473
534
  function emitBoundary(opts: WalkOptions, id: number, fallbackHTML: string): void {