@tanstack/redact 0.0.10 → 0.0.12

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.
@@ -1,4 +1,4 @@
1
- import { FiberTag, type Fiber } from '../../../core'
1
+ import { FiberTag, createFiber, type Fiber } from '../../../core'
2
2
  import { REACT_SUSPENSE_TYPE } from '../../../react'
3
3
  import {
4
4
  registerRenderer,
@@ -6,8 +6,10 @@ import {
6
6
  installCapability,
7
7
  reconcileChildren,
8
8
  childrenToArray,
9
+ renderFiber,
9
10
  scheduleUpdate,
10
11
  unmountAllChildren,
12
+ unmountFiber,
11
13
  findRoot,
12
14
  runEffects,
13
15
  getCurrentRoot,
@@ -36,9 +38,40 @@ function realHandleSuspended(fiber: Fiber, thenable: Promise<any>): void {
36
38
  )
37
39
  }
38
40
 
41
+ // React's Suspense semantics: when a re-render of an already-committed
42
+ // boundary suspends, the previously-committed children are kept in the DOM
43
+ // (hidden) so their scroll position, focus, selection, native form state,
44
+ // and component state survive across the suspension. The fallback is mounted
45
+ // alongside the hidden primary until the pending promise resolves.
46
+ //
47
+ // We track the hidden subtree DOM in `state.hiddenDoms` (root host nodes +
48
+ // their original `display` so we can restore it) and the fallback as a
49
+ // detached Fragment fiber in `state.fallbackFiber` (deliberately kept OUT of
50
+ // `fiber.child` so reconciles against `props.children` don't trip on it).
51
+ // First-mount suspensions have no committed DOM worth preserving, so they
52
+ // keep the original unmount-and-render-fallback behavior.
53
+ interface SuspenseState {
54
+ suspended: boolean
55
+ pending: Promise<any> | null
56
+ hydrated?: boolean
57
+ boundaryId?: number
58
+ startMark?: Comment
59
+ endMark?: Comment
60
+ realChildren?: any
61
+ _awaitingLazyHydration?: boolean
62
+ // Re-suspend preservation:
63
+ hiddenDoms: Array<[HTMLElement, string]> | null
64
+ fallbackFiber: Fiber | null
65
+ }
66
+
39
67
  function renderSuspense(fiber: Fiber, domParent: Node, anchor: Node | null): void {
40
68
  const props = fiber.pendingProps ?? {}
41
- const state = (fiber.memoizedState ??= { suspended: false, pending: null as Promise<any> | null })
69
+ const state = (fiber.memoizedState ??= {
70
+ suspended: false,
71
+ pending: null as Promise<any> | null,
72
+ hiddenDoms: null,
73
+ fallbackFiber: null,
74
+ }) as SuspenseState
42
75
 
43
76
  // Streaming hydration: if the next DOM node is a server-emitted boundary
44
77
  // marker, route through the boundary-aware hydration path.
@@ -58,55 +91,125 @@ function renderSuspense(fiber: Fiber, domParent: Node, anchor: Node | null): voi
58
91
  // yet. Until the Lazy's resume fires, skip our own tryChildren pass so
59
92
  // an unrelated re-render can't accidentally flip us into the suspended
60
93
  // path and mount a duplicate fallback on top of the SSR content.
61
- if ((state as any)._awaitingLazyHydration) {
94
+ if (state._awaitingLazyHydration) {
62
95
  fiber.memoizedProps = props
63
96
  return
64
97
  }
65
98
 
66
- const tryChildren = () => {
67
- reconcileChildren(fiber, childrenToArray(props.children), domParent, anchor)
99
+ // We were already in the suspended-with-preserved-primary state. Don't
100
+ // re-attempt primary children (would re-throw and churn the tree). Just
101
+ // refresh the fallback in case its JSX changed, and wait for the pending
102
+ // promise to fire scheduleUpdate.
103
+ if (state.suspended && state.pending && state.fallbackFiber) {
104
+ state.fallbackFiber.pendingProps = { children: props.fallback }
105
+ renderFiber(state.fallbackFiber, domParent, anchor)
106
+ fiber.memoizedProps = props
107
+ return
68
108
  }
69
109
 
70
- if (state.suspended && state.pending) {
71
- // Render fallback while waiting; pending promise will reschedule
110
+ // Initial-mount suspended path: no committed primary to preserve. Old
111
+ // behavior render fallback into fiber.child directly.
112
+ if (state.suspended && state.pending && !state.fallbackFiber) {
72
113
  reconcileChildren(fiber, childrenToArray(props.fallback), domParent, anchor)
73
114
  fiber.memoizedProps = props
74
115
  return
75
116
  }
76
117
 
77
- // Attempt children suspension is handled by the pushed handler below
118
+ // Snapshot whether we have an existing committed primary tree before
119
+ // attempting the new render. If the new attempt suspends and we did have a
120
+ // committed primary, we keep it (hidden) rather than destroying it.
121
+ const hadCommittedPrimary = fiber.memoizedProps !== undefined && fiber.child !== null
122
+
78
123
  const savedHandler = suspendHandlerStack[suspendHandlerStack.length - 1]
124
+ let suspendedThisRender = false
125
+ let suspendedThenable: Promise<any> | null = null
79
126
  suspendHandlerStack.push((thenable) => {
80
- state.suspended = true
81
- state.pending = thenable
82
- thenable.then(
83
- () => {
84
- state.suspended = false
85
- state.pending = null
86
- scheduleUpdate(fiber)
87
- },
88
- () => {
89
- state.suspended = false
90
- state.pending = null
91
- scheduleUpdate(fiber)
92
- },
93
- )
127
+ suspendedThisRender = true
128
+ suspendedThenable = thenable
94
129
  })
95
130
  try {
96
- tryChildren()
131
+ reconcileChildren(fiber, childrenToArray(props.children), domParent, anchor)
97
132
  } finally {
98
133
  suspendHandlerStack.pop()
99
134
  void savedHandler
100
135
  }
101
136
 
102
- if (state.suspended) {
103
- // Replace children with fallback
104
- unmountAllChildren(fiber, domParent)
105
- reconcileChildren(fiber, childrenToArray(props.fallback), domParent, anchor)
137
+ if (suspendedThisRender && suspendedThenable) {
138
+ state.suspended = true
139
+ state.pending = suspendedThenable
140
+ const onSettle = () => {
141
+ state.suspended = false
142
+ state.pending = null
143
+ scheduleUpdate(fiber)
144
+ }
145
+ suspendedThenable.then(onSettle, onSettle)
146
+
147
+ if (hadCommittedPrimary && fiber.child) {
148
+ // Hide the primary subtree's root host doms so the fallback is the only
149
+ // thing visible, but the underlying nodes (and their scroll/state/focus)
150
+ // survive. Save original `display` for the resume path.
151
+ const hostDoms: Node[] = []
152
+ let c: Fiber | null = fiber.child
153
+ while (c) {
154
+ collectRootHostDoms(c, hostDoms)
155
+ c = c.sibling
156
+ }
157
+ const hidden: Array<[HTMLElement, string]> = []
158
+ for (const d of hostDoms) {
159
+ if (d.nodeType === 1) {
160
+ const el = d as HTMLElement
161
+ hidden.push([el, el.style.display])
162
+ el.style.display = 'none'
163
+ }
164
+ }
165
+ state.hiddenDoms = hidden
166
+
167
+ // Mount fallback in a detached Fragment fiber. Kept off `fiber.child`
168
+ // so reconciles of primary don't see it as a stale match candidate.
169
+ if (!state.fallbackFiber) {
170
+ state.fallbackFiber = createFiber(FiberTag.Fragment, null, null)
171
+ state.fallbackFiber.parent = fiber
172
+ }
173
+ state.fallbackFiber.pendingProps = { children: props.fallback }
174
+ renderFiber(state.fallbackFiber, domParent, anchor)
175
+ } else {
176
+ // First-mount suspension — nothing to preserve.
177
+ unmountAllChildren(fiber, domParent)
178
+ reconcileChildren(fiber, childrenToArray(props.fallback), domParent, anchor)
179
+ }
180
+ } else {
181
+ // Render succeeded. Clean up any preserved-suspend state from a prior
182
+ // suspension cycle: unhide primary, unmount the orphan fallback fiber.
183
+ if (state.hiddenDoms) {
184
+ for (const [el, origDisplay] of state.hiddenDoms) {
185
+ el.style.display = origDisplay
186
+ }
187
+ state.hiddenDoms = null
188
+ }
189
+ if (state.fallbackFiber) {
190
+ unmountFiber(state.fallbackFiber, domParent)
191
+ state.fallbackFiber = null
192
+ }
106
193
  }
107
194
  fiber.memoizedProps = props
108
195
  }
109
196
 
197
+ // Walk a fiber subtree collecting host/text DOM nodes that sit at the root
198
+ // of the subtree (do not descend through their children — display:none on
199
+ // the root hides the whole element). Used by the hide-on-suspend path.
200
+ function collectRootHostDoms(fiber: Fiber, out: Node[]): void {
201
+ if (fiber.tag === FiberTag.Host || fiber.tag === FiberTag.Text) {
202
+ if (fiber.dom) out.push(fiber.dom)
203
+ return
204
+ }
205
+ if (fiber.tag === FiberTag.Portal) return
206
+ let c = fiber.child
207
+ while (c) {
208
+ collectRootHostDoms(c, out)
209
+ c = c.sibling
210
+ }
211
+ }
212
+
110
213
  function hydrateSuspenseBoundary(
111
214
  fiber: Fiber,
112
215
  props: any,
package/src/dom/index.ts CHANGED
@@ -14,6 +14,22 @@ export function preinit(_href: string, _opts?: any): void {}
14
14
  export function preloadModule(_href: string, _opts?: any): void {}
15
15
  export function preinitModule(_href: string, _opts?: any): void {}
16
16
 
17
+ export const __DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE = {
18
+ d: {
19
+ f() {},
20
+ r() {},
21
+ D() {},
22
+ C() {},
23
+ L() {},
24
+ m() {},
25
+ X() {},
26
+ S() {},
27
+ M() {},
28
+ },
29
+ p: 0,
30
+ findDOMNode: null,
31
+ }
32
+
17
33
  export const version = '19.2.3'
18
34
 
19
35
  // Required by React's default export consumers
@@ -29,5 +45,6 @@ export default {
29
45
  preinit,
30
46
  preloadModule,
31
47
  preinitModule,
48
+ __DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,
32
49
  version: '19.2.3',
33
50
  }
@@ -1014,7 +1014,7 @@ export function isThenable(x: any): x is Promise<any> {
1014
1014
  // Unmount
1015
1015
  // ---------------------------------------------------------------------------
1016
1016
 
1017
- function unmountFiber(fiber: Fiber, domParent: Node): void {
1017
+ export function unmountFiber(fiber: Fiber, domParent: Node): void {
1018
1018
  fiber.unmounted = true
1019
1019
  // Recurse first
1020
1020
  let c = fiber.child
@@ -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',