@tanstack/redact 0.0.10 → 0.0.11

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.
@@ -1074,6 +1074,12 @@ export function unmountAllChildren(parent: Fiber, domParent: Node): void {
1074
1074
  // ---------------------------------------------------------------------------
1075
1075
 
1076
1076
  function insertInto(parent: Node, node: Node, anchor: Node | null): void {
1077
+ const projectedHeadParent = getDocumentHeadInsertionParent(parent, node)
1078
+ if (projectedHeadParent) {
1079
+ projectedHeadParent.appendChild(node)
1080
+ return
1081
+ }
1082
+
1077
1083
  // Anchor may have been removed or moved since it was computed (mutations
1078
1084
  // from unmount, boundary reveal, user code, HMR). If it's no longer a child
1079
1085
  // of `parent`, fall back to append — trying to insertBefore a non-child
@@ -1085,6 +1091,15 @@ function insertInto(parent: Node, node: Node, anchor: Node | null): void {
1085
1091
  }
1086
1092
  }
1087
1093
 
1094
+ const DOCUMENT_HEAD_TAGS = new Set(['base', 'link', 'meta', 'script', 'style', 'title'])
1095
+
1096
+ function getDocumentHeadInsertionParent(parent: Node, node: Node): HTMLHeadElement | null {
1097
+ if (parent.nodeType !== 9 || node.nodeType !== 1) return null
1098
+ const tag = (node as Element).tagName.toLowerCase()
1099
+ if (!DOCUMENT_HEAD_TAGS.has(tag)) return null
1100
+ return (parent as Document).head
1101
+ }
1102
+
1088
1103
  function getHostParent(fiber: Fiber): Node {
1089
1104
  let p = fiber.parent
1090
1105
  while (p) {
@@ -1260,4 +1275,3 @@ function isEventProp(name: string): boolean {
1260
1275
  name.charCodeAt(2) >= 65 /* 'A'-ish: any uppercase start (onClick, onChange, …) */
1261
1276
  )
1262
1277
  }
1263
-
package/src/dom/root.ts CHANGED
@@ -1,4 +1,11 @@
1
- import { FiberTag, createFiber, type FiberRoot, type ReactNode } from '../core'
1
+ import {
2
+ FiberTag,
3
+ REACT_ELEMENT_TYPE,
4
+ createFiber,
5
+ type FiberRoot,
6
+ type ReactElement,
7
+ type ReactNode,
8
+ } from '../core'
2
9
  import { renderRoot, flushSyncWork, batchedUpdates } from './reconcile'
3
10
  import {
4
11
  beginHydration,
@@ -92,8 +99,10 @@ export function hydrateRoot(
92
99
 
93
100
  beginHydration(root)
94
101
  try {
102
+ const normalizedInitialChildren =
103
+ isDocumentContainer(container) ? normalizeDocumentChildren(initialChildren) : initialChildren
95
104
  flushSyncWork(() => {
96
- renderRoot(root, initialChildren)
105
+ renderRoot(root, normalizedInitialChildren)
97
106
  })
98
107
  } finally {
99
108
  endHydration(root)
@@ -103,7 +112,7 @@ export function hydrateRoot(
103
112
  return {
104
113
  render(children) {
105
114
  flushSyncWork(() => {
106
- renderRoot(root, children)
115
+ renderRoot(root, isDocumentContainer(container) ? normalizeDocumentChildren(children) : children)
107
116
  })
108
117
  },
109
118
  unmount() {
@@ -114,4 +123,91 @@ export function hydrateRoot(
114
123
  }
115
124
  }
116
125
 
126
+ const HEAD_TAGS = new Set(['base', 'link', 'meta', 'script', 'style', 'title'])
127
+
128
+ function isDocumentContainer(container: unknown): container is Document {
129
+ return !!container && (container as Node).nodeType === 9
130
+ }
131
+
132
+ function normalizeDocumentChildren(children: ReactNode): ReactNode {
133
+ const list = toChildArray(children)
134
+ const htmlIndex = list.findIndex((child) => isHostElement(child, 'html'))
135
+ if (htmlIndex === -1) return children
136
+
137
+ const headNodes = list.filter(isHeadElement)
138
+ if (headNodes.length === 0) return children
139
+
140
+ const htmlElement = list[htmlIndex] as ReactElement
141
+ const normalizedHtml = hoistIntoHtmlHead(htmlElement, headNodes)
142
+ return list
143
+ .filter((child, index) => index === htmlIndex || !isHeadElement(child))
144
+ .map((child) => (child === htmlElement ? normalizedHtml : child))
145
+ }
146
+
147
+ function hoistIntoHtmlHead(htmlElement: ReactElement, headNodes: ReactNode[]): ReactElement {
148
+ const htmlChildren = toChildArray(htmlElement.props?.children)
149
+ const headIndex = htmlChildren.findIndex((child) => isHostElement(child, 'head'))
150
+ let nextChildren: ReactNode[]
151
+
152
+ if (headIndex === -1) {
153
+ nextChildren = [
154
+ createHostElement('head', { children: headNodes }),
155
+ ...htmlChildren,
156
+ ]
157
+ } else {
158
+ const headElement = htmlChildren[headIndex] as ReactElement
159
+ const existingHeadChildren = toChildArray(headElement.props?.children)
160
+ const nextHead = {
161
+ ...headElement,
162
+ props: {
163
+ ...headElement.props,
164
+ children: [...headNodes, ...existingHeadChildren],
165
+ },
166
+ }
167
+ nextChildren = htmlChildren.map((child, index) => (index === headIndex ? nextHead : child))
168
+ }
169
+
170
+ return {
171
+ ...htmlElement,
172
+ props: {
173
+ ...htmlElement.props,
174
+ children: nextChildren,
175
+ },
176
+ }
177
+ }
178
+
179
+ function toChildArray(children: unknown): ReactNode[] {
180
+ if (children == null || typeof children === 'boolean') return []
181
+ if (Array.isArray(children)) return children as ReactNode[]
182
+ if (isReactElement(children)) return [children]
183
+ if (typeof children !== 'string' && isIterable(children)) return Array.from(children) as ReactNode[]
184
+ return [children as ReactNode]
185
+ }
186
+
187
+ function isHeadElement(value: ReactNode): boolean {
188
+ return isReactElement(value) && typeof value.type === 'string' && HEAD_TAGS.has(value.type)
189
+ }
190
+
191
+ function isHostElement(value: ReactNode, tag: string): boolean {
192
+ return isReactElement(value) && value.type === tag
193
+ }
194
+
195
+ function isReactElement(value: unknown): value is ReactElement {
196
+ return !!value && typeof value === 'object' && (value as ReactElement).$$typeof === REACT_ELEMENT_TYPE
197
+ }
198
+
199
+ function isIterable(value: unknown): value is Iterable<ReactNode> {
200
+ return !!value && typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function'
201
+ }
202
+
203
+ function createHostElement(type: string, props: Record<string, unknown>): ReactElement {
204
+ return {
205
+ $$typeof: REACT_ELEMENT_TYPE,
206
+ type,
207
+ key: null,
208
+ ref: null,
209
+ props,
210
+ }
211
+ }
212
+
117
213
  export { flushSyncWork as flushSync, batchedUpdates }
@@ -6,9 +6,12 @@
6
6
  * main bundle can replay them against un-hydrated subtrees ($RE_q buffer).
7
7
  *
8
8
  * Wire format:
9
- * Fallback: <!--$?ID--><div hidden id="B:ID">fallback</div><!--/$-->
9
+ * Fallback: <!--$?ID--><div id="B:ID">fallback</div><!--/$-->
10
10
  * Resolved: emits <div hidden id="S:ID">real</div><script>$RC(ID)</script>
11
11
  * which splices real into place and rewrites the comment to <!--$ID-->.
12
+ * The fallback div is visible (it's the user-visible loading
13
+ * state); only the resolved-content staging div is `hidden`
14
+ * before $RC moves its children inline.
12
15
  *
13
16
  * Client hydration calls $RH(ID, cb) to register a callback invoked once the
14
17
  * boundary has been revealed (or immediately, if it was already revealed).
@@ -8,10 +8,12 @@ import {
8
8
  } from './dispatcher'
9
9
  import { walk, type SuspendedBoundary } from './walk'
10
10
  import { BOUNDARY_REVEAL_RUNTIME, revealScript } from './bootstrap-script'
11
+ import { escapeScript } from './escape'
11
12
 
12
13
  export interface StreamOptions {
13
14
  identifierPrefix?: string
14
15
  nonce?: string
16
+ bootstrapScriptContent?: string | ReadonlyArray<string>
15
17
  bootstrapScripts?: ReadonlyArray<string | { src: string; async?: boolean; nonce?: string }>
16
18
  bootstrapModules?: ReadonlyArray<string | { src: string; nonce?: string }>
17
19
  onError?: (error: unknown) => string | void
@@ -57,12 +59,34 @@ export async function streamHtml(
57
59
  shellChunks.push(chunk)
58
60
  }
59
61
 
60
- // 1. Render the shell
61
- walk(children, {
62
- emit: bufferedEmit,
63
- onSuspend: (b) => boundaries.push(b),
64
- nextBoundaryId: () => state.nextId++,
65
- })
62
+ // 1. Render the shell. A root-level `use(promise)` has no Suspense
63
+ // boundary to capture it, but App Router SSR commonly suspends at this
64
+ // level while resolving the RSC stream. Wait and retry without leaking
65
+ // partial shell chunks or boundary ids from the aborted attempt.
66
+ let shellRendered = false
67
+ let rootRetries = 0
68
+ while (!shellRendered) {
69
+ const chunkCount = shellChunks.length
70
+ const boundaryCount = boundaries.length
71
+ const nextId = state.nextId
72
+ try {
73
+ walk(children, {
74
+ emit: bufferedEmit,
75
+ onSuspend: (b) => boundaries.push(b),
76
+ nextBoundaryId: () => state.nextId++,
77
+ })
78
+ shellRendered = true
79
+ } catch (err) {
80
+ shellChunks.length = chunkCount
81
+ boundaries.length = boundaryCount
82
+ state.nextId = nextId
83
+ if (!isThenable(err)) throw err
84
+ if (++rootRetries > 50) {
85
+ throw new Error('renderToReadableStream exceeded 50 root suspension retries.')
86
+ }
87
+ await err
88
+ }
89
+ }
66
90
 
67
91
  // 2. Inject runtime + bootstrap scripts (once, after shell). Skip the
68
92
  // reveal/event-replay runtime when nothing needs it — no suspensions to
@@ -71,10 +95,14 @@ export async function streamHtml(
71
95
  // matches React's behavior where `renderToReadableStream` of a static
72
96
  // tree emits only the markup.
73
97
  const hasBootstrap =
98
+ bootstrapScriptContentToArray(options.bootstrapScriptContent).length > 0 ||
74
99
  (options.bootstrapScripts?.length ?? 0) > 0 ||
75
100
  (options.bootstrapModules?.length ?? 0) > 0
76
101
  if (boundaries.length > 0 || hasBootstrap) {
77
102
  shellChunks.push(`<script${nonce ? ` nonce="${nonce}"` : ''}>${BOUNDARY_REVEAL_RUNTIME}</script>`)
103
+ for (const content of bootstrapScriptContentToArray(options.bootstrapScriptContent)) {
104
+ shellChunks.push(inlineBootstrapTag(content, nonce))
105
+ }
78
106
  for (const s of options.bootstrapScripts ?? []) {
79
107
  shellChunks.push(bootstrapTag(s, 'script', nonce))
80
108
  }
@@ -83,7 +111,7 @@ export async function streamHtml(
83
111
  }
84
112
  }
85
113
 
86
- if (shellChunks.length) emit(shellChunks.join(''))
114
+ if (shellChunks.length) emit(normalizeDocumentShell(shellChunks.join('')))
87
115
 
88
116
  // 3. Stream suspended boundaries as they resolve
89
117
  for (const b of boundaries) streamBoundary(b, emit, options, state)
@@ -146,6 +174,59 @@ async function drain(state: OrchestratorState): Promise<void> {
146
174
  }
147
175
  }
148
176
 
177
+ function isThenable(value: unknown): value is Promise<unknown> {
178
+ return !!value && typeof (value as { then?: unknown }).then === 'function'
179
+ }
180
+
181
+ function bootstrapScriptContentToArray(
182
+ content: StreamOptions['bootstrapScriptContent'],
183
+ ): string[] {
184
+ if (content === undefined) return []
185
+ return typeof content === 'string' ? [content] : [...content]
186
+ }
187
+
188
+ function inlineBootstrapTag(content: string, defaultNonce: string | undefined): string {
189
+ const nAttr = defaultNonce ? ` nonce="${defaultNonce}"` : ''
190
+ return `<script${nAttr}>${escapeScript(content)}</script>`
191
+ }
192
+
193
+ function normalizeDocumentShell(html: string): string {
194
+ const doctypeIndex = html.indexOf('<!DOCTYPE html><html')
195
+ if (doctypeIndex <= 0) return html
196
+
197
+ const headPrefix = html.slice(0, doctypeIndex)
198
+ if (!isHeadPrefix(headPrefix)) return html
199
+
200
+ const documentHtml = html.slice(doctypeIndex)
201
+ const headOpen = documentHtml.match(/<head(?:\s[^>]*)?>/)
202
+ if (!headOpen || headOpen.index === undefined) return html
203
+
204
+ const insertAt = headOpen.index + headOpen[0].length
205
+ return documentHtml.slice(0, insertAt) + headPrefix + documentHtml.slice(insertAt)
206
+ }
207
+
208
+ function isHeadPrefix(value: string): boolean {
209
+ if (!value) return false
210
+ return stripLeadingHeadTags(value).trim() === ''
211
+ }
212
+
213
+ function stripLeadingHeadTags(value: string): string {
214
+ let rest = value
215
+ let changed = true
216
+ while (changed) {
217
+ changed = false
218
+ const next = rest.replace(
219
+ /^\s*(?:<meta\b[^>]*>|<link\b[^>]*>|<base\b[^>]*>|<title\b[^>]*>[\s\S]*?<\/title>|<style\b[^>]*>[\s\S]*?<\/style>|<script\b[^>]*>[\s\S]*?<\/script>)/i,
220
+ '',
221
+ )
222
+ if (next !== rest) {
223
+ rest = next
224
+ changed = true
225
+ }
226
+ }
227
+ return rest
228
+ }
229
+
149
230
  function bootstrapTag(
150
231
  entry: string | { src: string; async?: boolean; nonce?: string },
151
232
  kind: 'script' | 'module',
@@ -315,6 +315,10 @@ function walkHost(
315
315
  const parentTextState = opts.textState
316
316
  const childOpts: WalkOptions = { ...opts, textState: { lastWasText: false } }
317
317
 
318
+ if (tag === 'html' && !hasHeadChild(props.children)) {
319
+ opts.emit('<head></head>')
320
+ }
321
+
318
322
  const dangerouslyHtml = props.dangerouslySetInnerHTML?.__html
319
323
 
320
324
  if (isTextarea && textareaValue != null) {
@@ -353,6 +357,32 @@ function walkHost(
353
357
  if (parentTextState) parentTextState.lastWasText = false
354
358
  }
355
359
 
360
+ function hasHeadChild(children: unknown): boolean {
361
+ if (children == null || typeof children === 'boolean') return false
362
+ if (Array.isArray(children)) return children.some(hasHeadChild)
363
+ if (typeof children !== 'string' && isIterable(children)) {
364
+ for (const child of children as Iterable<unknown>) {
365
+ if (hasHeadChild(child)) return true
366
+ }
367
+ return false
368
+ }
369
+ return isElementOfType(children, 'head')
370
+ }
371
+
372
+ function isElementOfType(value: unknown, type: string): value is ReactElement {
373
+ const marker = (value as ReactElement | null)?.$$typeof as unknown
374
+ return (
375
+ !!value &&
376
+ typeof value === 'object' &&
377
+ (marker === REACT_ELEMENT_TYPE || marker === REACT_LEGACY_ELEMENT_TYPE) &&
378
+ (value as ReactElement).type === type
379
+ )
380
+ }
381
+
382
+ function isIterable(value: unknown): value is Iterable<unknown> {
383
+ return !!value && typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function'
384
+ }
385
+
356
386
  function walkComponent(
357
387
  fn: Function,
358
388
  props: Record<string, any>,
package/src/vite/index.ts CHANGED
@@ -166,6 +166,8 @@ const ALIASES: Record<string, string> = {
166
166
  'react/jsx-dev-runtime': '@tanstack/redact/jsx-dev-runtime',
167
167
  'react/compiler-runtime': '@tanstack/redact/compiler-runtime',
168
168
  'react-dom/client': '@tanstack/redact/dom-client',
169
+ 'react-dom/server.edge': '@tanstack/redact/server',
170
+ 'react-dom/static.edge': '@tanstack/redact/server',
169
171
  'react-dom/server': '@tanstack/redact/server',
170
172
  'react-dom/test-utils': '@tanstack/redact/dom-test-utils',
171
173
  'react-dom': '@tanstack/redact/dom',