@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.
- 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.js +12 -0
- 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/features/hydration/full.ts +25 -8
- package/src/dom/index.ts +17 -0
- package/src/dom/reconcile.ts +15 -1
- 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
|
@@ -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 {
|
|
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',
|