@tanstack/redact 0.0.21 → 0.1.1

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 (254) 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 +72 -15
  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 +17 -8
  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 +7 -2
  152. package/dist/react/context.js +3 -11
  153. package/dist/react/context.js.map +3 -3
  154. package/dist/react/element.d.ts +6 -3
  155. package/dist/react/element.js +7 -11
  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 +150 -110
  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 +70 -13
  216. package/src/dom/features/context/stub.ts +10 -5
  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/context.ts +4 -11
  242. package/src/react/element.ts +11 -12
  243. package/src/react/hooks.ts +12 -3
  244. package/src/react/index.ts +18 -0
  245. package/src/react/shared-internals.ts +3 -3
  246. package/src/react/view-transition.ts +41 -0
  247. package/src/server/bootstrap-script.ts +36 -5
  248. package/src/server/dispatcher.ts +35 -13
  249. package/src/server/escape.ts +15 -12
  250. package/src/server/renderToString.ts +7 -5
  251. package/src/server/resource-hints.ts +165 -0
  252. package/src/server/stream.ts +154 -62
  253. package/src/server/walk.ts +79 -26
  254. 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
  }