@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.
- package/dist/dom/features/hydration/full.js +19 -8
- package/dist/dom/features/hydration/full.js.map +2 -2
- package/dist/dom/features/suspense/full.js +80 -24
- package/dist/dom/features/suspense/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 -0
- package/dist/dom/reconcile.js +13 -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/features/suspense/full.ts +130 -27
- package/src/dom/index.ts +17 -0
- package/src/dom/reconcile.ts +16 -2
- 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
|
@@ -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 ??= {
|
|
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 (
|
|
94
|
+
if (state._awaitingLazyHydration) {
|
|
62
95
|
fiber.memoizedProps = props
|
|
63
96
|
return
|
|
64
97
|
}
|
|
65
98
|
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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 (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
}
|
package/src/dom/reconcile.ts
CHANGED
|
@@ -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 {
|
|
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',
|