@tanstack/redact 0.0.9 → 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.
- package/dist/dom/dispatcher.js +4 -0
- package/dist/dom/dispatcher.js.map +2 -2
- package/dist/dom/features/hydration/full.js +19 -8
- package/dist/dom/features/hydration/full.js.map +2 -2
- package/dist/dom/index.d.ts +30 -0
- package/dist/dom/index.js +26 -0
- package/dist/dom/index.js.map +2 -2
- package/dist/dom/reconcile.d.ts +1 -4
- package/dist/dom/reconcile.js +41 -35
- package/dist/dom/reconcile.js.map +2 -2
- package/dist/dom/root.js +79 -3
- package/dist/dom/root.js.map +2 -2
- package/dist/server/bootstrap-script.d.ts +4 -1
- package/dist/server/bootstrap-script.js.map +2 -2
- package/dist/server/stream.d.ts +1 -0
- package/dist/server/stream.js +72 -7
- package/dist/server/stream.js.map +2 -2
- package/dist/server/walk.js +21 -0
- package/dist/server/walk.js.map +2 -2
- package/dist/vite/index.js +2 -0
- package/dist/vite/index.js.map +2 -2
- package/package.json +1 -1
- package/src/dom/dispatcher.ts +10 -0
- package/src/dom/features/hydration/full.ts +25 -8
- package/src/dom/index.ts +17 -0
- package/src/dom/reconcile.ts +54 -54
- package/src/dom/root.ts +99 -3
- package/src/server/bootstrap-script.ts +4 -1
- package/src/server/stream.ts +88 -7
- package/src/server/walk.ts +30 -0
- package/src/vite/index.ts +2 -0
package/src/dom/reconcile.ts
CHANGED
|
@@ -209,20 +209,15 @@ function rerenderFiber(fiber: Fiber, root: FiberRoot): void {
|
|
|
209
209
|
// Element → children normalization
|
|
210
210
|
// ---------------------------------------------------------------------------
|
|
211
211
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
// `[object Object]` when createTextNode stringified the element. React
|
|
222
|
-
// elements always carry `$$typeof`; our text wrapper never does — so the
|
|
223
|
-
// presence of `$$typeof` is the invariant we rely on.
|
|
224
|
-
function isTextChild(child: Exclude<NormalizedChild, null>): child is TextChild {
|
|
225
|
-
return (child as any).$$typeof === undefined
|
|
212
|
+
// Text children pass through as raw strings — no wrapper. The previous
|
|
213
|
+
// `{_text: string}` shape allocated tens of thousands of objects per
|
|
214
|
+
// stable-list re-render and dominated minor-GC pressure. `typeof === 'string'`
|
|
215
|
+
// is also robust to RSC renderable proxies (which have `has` traps that
|
|
216
|
+
// would fool a `'_text' in child` predicate but can't fool `typeof`).
|
|
217
|
+
type NormalizedChild = ReactElement | string | null
|
|
218
|
+
|
|
219
|
+
function isTextChild(child: Exclude<NormalizedChild, null>): child is string {
|
|
220
|
+
return typeof child === 'string'
|
|
226
221
|
}
|
|
227
222
|
|
|
228
223
|
export function childrenToArray(children: ReactNode): NormalizedChild[] {
|
|
@@ -233,11 +228,15 @@ export function childrenToArray(children: ReactNode): NormalizedChild[] {
|
|
|
233
228
|
|
|
234
229
|
function pushChildren(node: ReactNode, out: NormalizedChild[]): void {
|
|
235
230
|
if (node == null || typeof node === 'boolean') return
|
|
236
|
-
if (typeof node === 'string'
|
|
231
|
+
if (typeof node === 'string') {
|
|
237
232
|
// Empty strings render no text node (matches React + the `<!-- -->`
|
|
238
233
|
// separator elision on the SSR side so server/client agree).
|
|
239
234
|
if (node === '') return
|
|
240
|
-
out.push(
|
|
235
|
+
out.push(node)
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
if (typeof node === 'number') {
|
|
239
|
+
out.push('' + node)
|
|
241
240
|
return
|
|
242
241
|
}
|
|
243
242
|
if (Array.isArray(node)) {
|
|
@@ -298,7 +297,7 @@ function fiberFromChild(child: NormalizedChild, parent: Fiber): Fiber {
|
|
|
298
297
|
if (!child) return createFiber(FiberTag.Fragment, null, null)
|
|
299
298
|
if (isTextChild(child)) {
|
|
300
299
|
const f = createFiber(FiberTag.Text, null, null)
|
|
301
|
-
f.pendingProps = child
|
|
300
|
+
f.pendingProps = child
|
|
302
301
|
f.parent = parent
|
|
303
302
|
return f
|
|
304
303
|
}
|
|
@@ -344,44 +343,31 @@ export function reconcileChildren(
|
|
|
344
343
|
domParent: Node,
|
|
345
344
|
anchor: Node | null,
|
|
346
345
|
): void {
|
|
347
|
-
// Fast path: unkeyed positional steady-state.
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
// pendingProps / type / ref in place. The render pass below is unchanged.
|
|
353
|
-
// Falls back to the slow path on the first divergence.
|
|
346
|
+
// Fast path: unkeyed positional steady-state. Walk the existing sibling
|
|
347
|
+
// chain and newChildren in lockstep, validating AND committing in one pass.
|
|
348
|
+
// On any divergence we fall back to the slow path, which rebuilds the
|
|
349
|
+
// sibling chain anyway — partial pendingProps writes are idempotent.
|
|
350
|
+
// Skips the Map / Set / existing-array allocation entirely.
|
|
354
351
|
if (!currentRoot?.hydrating) {
|
|
355
352
|
let f: Fiber | null = parent.child
|
|
356
|
-
let i = 0
|
|
357
353
|
let ok = true
|
|
358
|
-
for (; i < newChildren.length; i++) {
|
|
354
|
+
for (let i = 0; i < newChildren.length; i++) {
|
|
359
355
|
const child = newChildren[i]
|
|
360
|
-
if (child == null) { ok = false; break }
|
|
361
|
-
if (
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
356
|
+
if (child == null || !f || f.key != null) { ok = false; break }
|
|
357
|
+
if (typeof child === 'string') {
|
|
358
|
+
if (f.tag !== FiberTag.Text) { ok = false; break }
|
|
359
|
+
f.pendingProps = child
|
|
360
|
+
} else {
|
|
361
|
+
if ((child as ReactElement).key != null) { ok = false; break }
|
|
362
|
+
if (f.type !== (child as ReactElement).type) { ok = false; break }
|
|
363
|
+
f.pendingProps = (child as ReactElement).props
|
|
364
|
+
f.ref = (child as any).ref ?? null
|
|
365
|
+
}
|
|
365
366
|
f = f.sibling
|
|
366
367
|
}
|
|
367
368
|
if (ok && f === null) {
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
for (let j = 0; j < newChildren.length; j++) {
|
|
371
|
-
const child = newChildren[j]!
|
|
372
|
-
if (isTextChild(child)) {
|
|
373
|
-
g!.pendingProps = child._text
|
|
374
|
-
} else {
|
|
375
|
-
// type is already === child.type by sameType; assignment is a
|
|
376
|
-
// no-op write but keeps the slow-path semantic that we accept
|
|
377
|
-
// "type identity preserved" matches.
|
|
378
|
-
g!.pendingProps = (child as ReactElement).props
|
|
379
|
-
g!.ref = (child as any).ref ?? null
|
|
380
|
-
}
|
|
381
|
-
g = g!.sibling
|
|
382
|
-
}
|
|
383
|
-
// Pass 2: render forward with per-child anchors. This is the same loop
|
|
384
|
-
// as the slow path's pass 2.
|
|
369
|
+
// Pass 2: render forward with per-child anchors. Identical to the slow
|
|
370
|
+
// path's pass 2.
|
|
385
371
|
for (let r: Fiber | null = parent.child; r; r = r.sibling) {
|
|
386
372
|
let a = anchor
|
|
387
373
|
for (let s: Fiber | null = r.sibling; s; s = s.sibling) {
|
|
@@ -481,7 +467,7 @@ export function reconcileChildren(
|
|
|
481
467
|
claimed.add(match)
|
|
482
468
|
fiber = match
|
|
483
469
|
if (isTextChild(child!)) {
|
|
484
|
-
fiber.pendingProps = child
|
|
470
|
+
fiber.pendingProps = child
|
|
485
471
|
} else {
|
|
486
472
|
fiber.type = (child as ReactElement).type
|
|
487
473
|
fiber.pendingProps = (child as ReactElement).props
|
|
@@ -725,9 +711,7 @@ export function renderFiber(fiber: Fiber, domParent: Node, anchor: Node | null):
|
|
|
725
711
|
|
|
726
712
|
function renderText(fiber: Fiber, domParent: Node, anchor: Node | null): void {
|
|
727
713
|
const text = fiber.pendingProps as string
|
|
728
|
-
//
|
|
729
|
-
// skip the Text.data getter (a native call) and the memoizedProps write.
|
|
730
|
-
// Static literal-string children hit this on every re-render after mount.
|
|
714
|
+
// Identity-unchanged fast path: skip the native Text.data write entirely.
|
|
731
715
|
if (fiber.dom && fiber.memoizedProps === text) return
|
|
732
716
|
if (!fiber.dom) {
|
|
733
717
|
const hydrated = currentRoot?.hydrating ? adoptTextDom(fiber, fiber.parent!, text) : false
|
|
@@ -735,7 +719,9 @@ function renderText(fiber: Fiber, domParent: Node, anchor: Node | null): void {
|
|
|
735
719
|
fiber.dom = document.createTextNode(text)
|
|
736
720
|
insertInto(domParent, fiber.dom, anchor)
|
|
737
721
|
}
|
|
738
|
-
} else
|
|
722
|
+
} else {
|
|
723
|
+
// Past the fast path, and adoptTextDom already realigned `.data` on
|
|
724
|
+
// hydration — `.data !== text` here is guaranteed, so write directly.
|
|
739
725
|
;(fiber.dom as Text).data = text
|
|
740
726
|
}
|
|
741
727
|
fiber.memoizedProps = text
|
|
@@ -1088,6 +1074,12 @@ export function unmountAllChildren(parent: Fiber, domParent: Node): void {
|
|
|
1088
1074
|
// ---------------------------------------------------------------------------
|
|
1089
1075
|
|
|
1090
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
|
+
|
|
1091
1083
|
// Anchor may have been removed or moved since it was computed (mutations
|
|
1092
1084
|
// from unmount, boundary reveal, user code, HMR). If it's no longer a child
|
|
1093
1085
|
// of `parent`, fall back to append — trying to insertBefore a non-child
|
|
@@ -1099,6 +1091,15 @@ function insertInto(parent: Node, node: Node, anchor: Node | null): void {
|
|
|
1099
1091
|
}
|
|
1100
1092
|
}
|
|
1101
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
|
+
|
|
1102
1103
|
function getHostParent(fiber: Fiber): Node {
|
|
1103
1104
|
let p = fiber.parent
|
|
1104
1105
|
while (p) {
|
|
@@ -1274,4 +1275,3 @@ function isEventProp(name: string): boolean {
|
|
|
1274
1275
|
name.charCodeAt(2) >= 65 /* 'A'-ish: any uppercase start (onClick, onChange, …) */
|
|
1275
1276
|
)
|
|
1276
1277
|
}
|
|
1277
|
-
|
package/src/dom/root.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
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
|
|
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).
|
package/src/server/stream.ts
CHANGED
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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',
|
package/src/server/walk.ts
CHANGED
|
@@ -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',
|