@nifrajs/web-react 4.0.2 → 5.1.0

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.
Files changed (65) hide show
  1. package/dist/auth.d.ts.map +1 -1
  2. package/dist/auth.js +39 -18
  3. package/dist/auth.js.map +1 -1
  4. package/dist/island.d.ts.map +1 -1
  5. package/dist/island.js +4 -1
  6. package/dist/island.js.map +1 -1
  7. package/dist/react-dom-server.js +1 -1
  8. package/dist/route-props.d.ts.map +1 -1
  9. package/dist/route-props.js +1 -0
  10. package/dist/route-props.js.map +1 -1
  11. package/dist/server-components/client.d.ts +3 -0
  12. package/dist/server-components/client.d.ts.map +1 -0
  13. package/dist/server-components/client.js +167 -0
  14. package/dist/server-components/client.js.map +1 -0
  15. package/dist/server-components/flight.d.ts +4 -0
  16. package/dist/server-components/flight.d.ts.map +1 -0
  17. package/dist/server-components/flight.js +87 -0
  18. package/dist/server-components/flight.js.map +1 -0
  19. package/dist/server-components/guard.d.ts +8 -0
  20. package/dist/server-components/guard.d.ts.map +1 -0
  21. package/dist/server-components/guard.js +139 -0
  22. package/dist/server-components/guard.js.map +1 -0
  23. package/dist/server-components/jsx-dev-runtime.d.ts +8 -0
  24. package/dist/server-components/jsx-dev-runtime.d.ts.map +1 -0
  25. package/dist/server-components/jsx-dev-runtime.js +12 -0
  26. package/dist/server-components/jsx-dev-runtime.js.map +1 -0
  27. package/dist/server-components/jsx-runtime.d.ts +5 -0
  28. package/dist/server-components/jsx-runtime.d.ts.map +1 -0
  29. package/dist/server-components/jsx-runtime.js +14 -0
  30. package/dist/server-components/jsx-runtime.js.map +1 -0
  31. package/dist/server-components/nested.d.ts +11 -0
  32. package/dist/server-components/nested.d.ts.map +1 -0
  33. package/dist/server-components/nested.js +12 -0
  34. package/dist/server-components/nested.js.map +1 -0
  35. package/dist/server-components/nested.props.d.ts +5 -0
  36. package/dist/server-components/nested.props.d.ts.map +1 -0
  37. package/dist/server-components/nested.props.js +3 -0
  38. package/dist/server-components/nested.props.js.map +1 -0
  39. package/dist/server-components/outlet.d.ts +19 -0
  40. package/dist/server-components/outlet.d.ts.map +1 -0
  41. package/dist/server-components/outlet.js +76 -0
  42. package/dist/server-components/outlet.js.map +1 -0
  43. package/dist/server-components/rsc.d.ts +4 -0
  44. package/dist/server-components/rsc.d.ts.map +1 -0
  45. package/dist/server-components/rsc.js +33 -0
  46. package/dist/server-components/rsc.js.map +1 -0
  47. package/dist/server-components/server.d.ts +4 -0
  48. package/dist/server-components/server.d.ts.map +1 -0
  49. package/dist/server-components/server.js +34 -0
  50. package/dist/server-components/server.js.map +1 -0
  51. package/package.json +63 -9
  52. package/src/auth.ts +34 -18
  53. package/src/island.ts +4 -1
  54. package/src/react-dom-server.ts +1 -1
  55. package/src/route-props.ts +1 -0
  56. package/src/server-components/client.ts +176 -0
  57. package/src/server-components/flight.ts +87 -0
  58. package/src/server-components/guard.ts +199 -0
  59. package/src/server-components/jsx-dev-runtime.ts +27 -0
  60. package/src/server-components/jsx-runtime.ts +19 -0
  61. package/src/server-components/nested.props.ts +2 -0
  62. package/src/server-components/nested.ts +18 -0
  63. package/src/server-components/outlet.ts +109 -0
  64. package/src/server-components/rsc.ts +39 -0
  65. package/src/server-components/server.ts +52 -0
package/src/auth.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  useContext,
19
19
  useEffect,
20
20
  useMemo,
21
+ useRef,
21
22
  useState,
22
23
  } from "react"
23
24
 
@@ -55,7 +56,18 @@ export function AuthSessionProvider(props: AuthSessionProviderProps): ReactNode
55
56
  const [seed, setSeed] = useState(props.initialSession)
56
57
  const [status, setStatus] = useState<AuthStatus>(seededStatus(props.initialSession))
57
58
  const [session, setSession] = useState<Session | null>(props.initialSession ?? null)
59
+ // A read applies only while it holds the latest generation; a newer read, a sign-out, a new client
60
+ // or seed, and unmount each take one. The record is never replaced, so a sign-out that settles
61
+ // after a client or seed change still clears the session the server ended.
62
+ const reads = useRef({ generation: 0, signingOut: 0, live: true, client })
63
+ if (reads.current.client !== client) {
64
+ // A new client keeps what is shown (a parent may build one per render); only answers to the
65
+ // previous client's reads are dropped.
66
+ reads.current.client = client
67
+ reads.current.generation++
68
+ }
58
69
  if (seed !== props.initialSession) {
70
+ reads.current.generation++
59
71
  // A render-phase update: React re-runs this component before any child sees the old session.
60
72
  setSeed(props.initialSession)
61
73
  setStatus(seededStatus(props.initialSession))
@@ -63,40 +75,35 @@ export function AuthSessionProvider(props: AuthSessionProviderProps): ReactNode
63
75
  }
64
76
  const refresh = useMemo(() => {
65
77
  return async (): Promise<void> => {
78
+ const state = reads.current
79
+ // A read made while signing out could answer with the session being ended.
80
+ if (state.signingOut > 0 || state.client !== client) return
81
+ const generation = ++state.generation
66
82
  try {
67
83
  const next = await client.getSession()
84
+ if (!state.live || state.generation !== generation) return
68
85
  setSession(next)
69
86
  setStatus(next === null ? "unauthenticated" : "authenticated")
70
87
  } catch {
71
88
  // A failed session read must fail closed as anonymous; leaving "loading" can strand the
72
89
  // subtree in an auth-pending state and make protected UI appear available by omission.
90
+ if (!state.live || state.generation !== generation) return
73
91
  setSession(null)
74
92
  setStatus("unauthenticated")
75
93
  }
76
94
  }
77
95
  }, [client])
78
96
  useEffect(() => {
79
- let live = true
97
+ const state = reads.current
98
+ state.live = true
80
99
  if (props.initialSession === undefined) {
81
- void client.getSession().then(
82
- (next) => {
83
- if (!live) return
84
- setSession(next)
85
- setStatus(next === null ? "unauthenticated" : "authenticated")
86
- },
87
- () => {
88
- // Treat an unavailable session endpoint as anonymous rather than keeping auth state
89
- // pending indefinitely or exposing stale authenticated UI.
90
- if (!live) return
91
- setSession(null)
92
- setStatus("unauthenticated")
93
- },
94
- )
100
+ void refresh()
95
101
  }
96
102
  return () => {
97
- live = false
103
+ state.live = false
104
+ state.generation++
98
105
  }
99
- }, [client, props.initialSession])
106
+ }, [refresh, props.initialSession])
100
107
  const value = useMemo<AuthSession>(
101
108
  () => ({
102
109
  status,
@@ -104,7 +111,16 @@ export function AuthSessionProvider(props: AuthSessionProviderProps): ReactNode
104
111
  refresh,
105
112
  signIn: (providerId, options) => client.signIn(providerId, options),
106
113
  signOut: async (options) => {
107
- await client.signOut(options)
114
+ const state = reads.current
115
+ state.signingOut++
116
+ state.generation++
117
+ try {
118
+ await client.signOut(options)
119
+ } finally {
120
+ state.signingOut--
121
+ state.generation++
122
+ }
123
+ if (!state.live) return
108
124
  // The server ended the session: the subtree must not go on showing it, whether a redirect is
109
125
  // pending or `redirect: false` keeps the page.
110
126
  setSession(null)
package/src/island.ts CHANGED
@@ -30,7 +30,10 @@ export function Island({ id, props, strategy = "load", children }: IslandProps):
30
30
  "nifra-island",
31
31
  {
32
32
  "data-id": id,
33
- "data-strategy": strategy,
33
+ // The runtime reads a media strategy as two attributes; React drops an object-valued one.
34
+ ...(typeof strategy === "string"
35
+ ? { "data-strategy": strategy }
36
+ : { "data-strategy": "media", "data-media": strategy.media }),
34
37
  // Omit `data-props` entirely when there are no props (keeps markup minimal + parse a no-op).
35
38
  ...(props === undefined ? {} : { "data-props": JSON.stringify(props) }),
36
39
  },
@@ -124,7 +124,7 @@ export function assertSingleReactCore(
124
124
  rendererReact = realpath(resolve("react", dirnamePortable(reactDomServerPath)))
125
125
  // The `react` the app's route components import - the core they call hooks on.
126
126
  componentsReact = realpath(resolve("react", appRoot()))
127
- // @nifra-gate-reviewed: not a security gate. If either `react` cannot be resolved we cannot prove
127
+ // nifra-expect NF-S001: not a security gate. If either `react` cannot be resolved we cannot prove
128
128
  // a duplicate exists, so we skip the assertion rather than block a valid render.
129
129
  } catch {
130
130
  return
@@ -17,6 +17,7 @@ export function routeProps(
17
17
  const rawSearch = q === -1 ? "" : state.path.slice(q)
18
18
  return {
19
19
  data: state.data,
20
+ ...(state.serverComponents === undefined ? {} : { serverComponents: state.serverComponents }),
20
21
  // Each layout's own loader data. Without it a layout renders `data: null` on the client while the
21
22
  // server rendered it with its data - a hydration mismatch on the first paint.
22
23
  ...(state.layoutData !== undefined ? { layoutData: state.layoutData } : {}),
@@ -0,0 +1,176 @@
1
+ import type {
2
+ ComponentClientAdapter,
3
+ ComponentClientRoot,
4
+ ComponentRecord,
5
+ CurrentComponent,
6
+ } from "@nifrajs/web/server-components/client"
7
+ import { bytesStream, decodeComponentBytes } from "@nifrajs/web/server-components/stream"
8
+ import { createFromReadableStream } from "@vitejs/plugin-rsc/browser"
9
+ import { createElement, type ReactNode, useLayoutEffect, useSyncExternalStore } from "react"
10
+ import { flushSync } from "react-dom"
11
+ import { createRoot, hydrateRoot } from "react-dom/client"
12
+
13
+ // Flight's framework-facing lazy ABI is pinned with React/Flight in the compatibility gate.
14
+ async function prepareTree(
15
+ value: unknown,
16
+ current: CurrentComponent,
17
+ seen = new Set<object>(),
18
+ depth = 0,
19
+ ): Promise<void> {
20
+ if (!current()) throw new Error("component_cancelled")
21
+ if (depth > 64 || seen.size > 16384) throw new Error("component_limit")
22
+ if (typeof value !== "object" || value === null || seen.has(value)) return
23
+ seen.add(value)
24
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Only this adapter produces its prepared tree and native root; component records and public props are validated before native preparation.
25
+ const node = value as Record<string, unknown>
26
+ if (node.$$typeof === Symbol.for("react.lazy")) {
27
+ const init = node._init
28
+ if (typeof init !== "function") throw new Error("component_protocol")
29
+ while (true) {
30
+ try {
31
+ await prepareTree(init(node._payload), current, seen, depth + 1)
32
+ break
33
+ } catch (pending) {
34
+ if (
35
+ typeof pending !== "object" ||
36
+ pending === null ||
37
+ !("then" in pending) ||
38
+ typeof pending.then !== "function"
39
+ )
40
+ throw pending
41
+ await pending
42
+ if (!current()) throw new Error("component_cancelled")
43
+ }
44
+ }
45
+ } else if ("then" in node && typeof node.then === "function") {
46
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Only this adapter produces its prepared tree and native root; component records and public props are validated before native preparation.
47
+ await prepareTree(await (value as Promise<unknown>), current, seen, depth + 1)
48
+ } else if (Array.isArray(value)) {
49
+ await Promise.all(value.map((child) => prepareTree(child, current, seen, depth + 1)))
50
+ } else if (node.$$typeof === Symbol.for("react.transitional.element")) {
51
+ await prepareTree(node.type, current, seen, depth + 1)
52
+ await prepareTree(node.props, current, seen, depth + 1)
53
+ } else {
54
+ await Promise.all(
55
+ Object.values(node).map((child) => prepareTree(child, current, seen, depth + 1)),
56
+ )
57
+ }
58
+ }
59
+
60
+ class FlightRoot implements ComponentClientRoot {
61
+ readonly #host: Element
62
+ readonly #root: ReturnType<typeof createRoot>
63
+ readonly #listeners = new Set<() => void>()
64
+ #snapshot: ReactNode
65
+ #current: CurrentComponent
66
+ #committed: ReactNode = null
67
+ #disposed = false
68
+ #ready: () => void
69
+ readonly ready: Promise<void>
70
+ constructor(
71
+ host: Element,
72
+ tree: ReactNode,
73
+ record: ComponentRecord,
74
+ current: CurrentComponent,
75
+ fresh = false,
76
+ ) {
77
+ this.#host = host
78
+ this.#snapshot = tree
79
+ this.#current = current
80
+ let ready!: () => void
81
+ this.ready = new Promise<void>((resolve) => {
82
+ ready = resolve
83
+ })
84
+ this.#ready = ready
85
+ const store = this
86
+ function CurrentTree() {
87
+ const value = useSyncExternalStore(
88
+ (listener) => {
89
+ store.#listeners.add(listener)
90
+ return () => store.#listeners.delete(listener)
91
+ },
92
+ () => store.#snapshot,
93
+ () => tree,
94
+ )
95
+ const display = store.#current() ? value : store.#committed
96
+ useLayoutEffect(() => {
97
+ store.#committed = display
98
+ store.#ready()
99
+ })
100
+ return display
101
+ }
102
+ const options = {
103
+ identifierPrefix: `${record.instance}-`,
104
+ onRecoverableError() {
105
+ if (current()) throw new Error("component_failed")
106
+ },
107
+ onUncaughtError() {
108
+ store.#ready()
109
+ store.dispose()
110
+ throw new Error("component_failed")
111
+ },
112
+ }
113
+ if (fresh) {
114
+ this.#root = createRoot(host, options)
115
+ this.#root.render(createElement(CurrentTree))
116
+ } else this.#root = hydrateRoot(host, createElement(CurrentTree), options)
117
+ }
118
+ publish(tree: ReactNode, current: CurrentComponent): void {
119
+ if (this.#disposed || !current()) return
120
+ flushSync(() => {
121
+ if (!current()) return
122
+ this.#current = current
123
+ this.#snapshot = tree
124
+ for (const listener of this.#listeners) listener()
125
+ })
126
+ }
127
+ update(): never {
128
+ throw new Error("component_contract")
129
+ }
130
+ dispose(): void {
131
+ if (this.#disposed) return
132
+ this.#disposed = true
133
+ this.#root.unmount()
134
+ this.#listeners.clear()
135
+ this.#host.replaceChildren()
136
+ this.#ready()
137
+ }
138
+ }
139
+ export function reactComponentClient(): ComponentClientAdapter {
140
+ return {
141
+ name: "react",
142
+ async prepare(record, current) {
143
+ if (
144
+ typeof record.payload !== "object" ||
145
+ record.payload === null ||
146
+ Array.isArray(record.payload) ||
147
+ !Object.hasOwn(record.payload, "flight")
148
+ )
149
+ throw new Error("component_protocol")
150
+ const tree = await createFromReadableStream<ReactNode>(
151
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Only this adapter produces its prepared tree and native root; component records and public props are validated before native preparation.
152
+ bytesStream(decodeComponentBytes((record.payload as Record<string, unknown>).flight)),
153
+ )
154
+ await prepareTree(tree, current)
155
+ if (!current()) throw new Error("component_cancelled")
156
+ return tree
157
+ },
158
+ async hydrate(host, record, tree, current) {
159
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Only this adapter produces its prepared tree and native root; component records and public props are validated before native preparation.
160
+ const root = new FlightRoot(host, tree as ReactNode, record, current)
161
+ await root.ready
162
+ return root
163
+ },
164
+ async create(host, record, tree, current) {
165
+ if (!current()) throw new Error("component_cancelled")
166
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Only this adapter produces its prepared tree and native root; component records and public props are validated before native preparation.
167
+ const root = new FlightRoot(host, tree as ReactNode, record, current, true)
168
+ await root.ready
169
+ return root
170
+ },
171
+ update(root, _record, tree, current) {
172
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Only this adapter produces its prepared tree and native root; component records and public props are validated before native preparation.
173
+ ;(root as FlightRoot).publish(tree as ReactNode, current)
174
+ },
175
+ }
176
+ }
@@ -0,0 +1,87 @@
1
+ import { readComponentBytes } from "@nifrajs/web/server-components/stream"
2
+
3
+ const encoder = new TextEncoder()
4
+ const decoder = new TextDecoder("utf-8", { fatal: true })
5
+ function publicModel(value: unknown, depth = 0): unknown {
6
+ if (depth > 128) throw new Error("component_limit")
7
+ if (typeof value === "string" && value.startsWith("$F")) throw new Error("component_contract")
8
+ if (Array.isArray(value))
9
+ return (
10
+ value[0] === "$"
11
+ ? [...value.slice(0, 4), null, null, value[6] === 1 || value[6] === 2 ? value[6] : 0]
12
+ : value
13
+ ).map((item) => publicModel(item, depth + 1))
14
+ if (typeof value === "object" && value !== null)
15
+ return Object.fromEntries(
16
+ Object.entries(value).map(([key, item]) => [key, publicModel(item, depth + 1)]),
17
+ )
18
+ return value
19
+ }
20
+
21
+ /** React 19.2.8 Flight framing: remove development owners/stacks/console records before transport. */
22
+ export function publicFlight(bytes: Uint8Array): Uint8Array {
23
+ const rows: Uint8Array[] = []
24
+ let offset = 0
25
+ while (offset < bytes.length) {
26
+ const start = offset
27
+ while (offset < bytes.length && bytes[offset] !== 58) offset++
28
+ const id = decoder.decode(bytes.subarray(start, offset))
29
+ if (!/^[0-9a-f]*$/.test(id) || offset === bytes.length) throw new Error("component_protocol")
30
+ offset++
31
+ if (bytes[offset] === 84) {
32
+ const lengthStart = ++offset
33
+ while (offset < bytes.length && bytes[offset] !== 44) offset++
34
+ const encodedLength = decoder.decode(bytes.subarray(lengthStart, offset))
35
+ if (!/^[0-9a-f]+$/.test(encodedLength)) throw new Error("component_protocol")
36
+ const length = parseInt(encodedLength, 16)
37
+ offset++
38
+ if (offset + length > bytes.length) throw new Error("component_protocol")
39
+ offset += length
40
+ rows.push(bytes.slice(start, offset))
41
+ continue
42
+ }
43
+ const end = bytes.indexOf(10, offset)
44
+ if (end === -1) throw new Error("component_protocol")
45
+ const row = decoder.decode(bytes.subarray(offset, end))
46
+ offset = end + 1
47
+ if (/^[DNW]/.test(row)) continue
48
+ if (row.startsWith("E")) {
49
+ rows.push(
50
+ encoder.encode(
51
+ id +
52
+ ':E{"digest":"component_failed","message":"component_failed","name":"Error","stack":[],"env":"Server","owner":null}\n',
53
+ ),
54
+ )
55
+ continue
56
+ }
57
+ if (/^[[{"0-9tnf-]/.test(row)) {
58
+ let model: unknown
59
+ try {
60
+ model = JSON.parse(row)
61
+ } catch {
62
+ throw new Error("component_protocol")
63
+ }
64
+ rows.push(encoder.encode(`${id}:${JSON.stringify(publicModel(model))}\n`))
65
+ } else rows.push(bytes.slice(start, offset))
66
+ }
67
+ const size = rows.reduce((size, row) => size + row.length, 0)
68
+ const output = new Uint8Array(size)
69
+ let at = 0
70
+ for (const row of rows) {
71
+ output.set(row, at)
72
+ at += row.length
73
+ }
74
+ return output
75
+ }
76
+ export async function publicFlightStream(
77
+ stream: ReadableStream<Uint8Array>,
78
+ signal: AbortSignal,
79
+ ): Promise<ReadableStream<Uint8Array>> {
80
+ const bytes = publicFlight(await readComponentBytes(stream, signal))
81
+ return new ReadableStream({
82
+ start(controller) {
83
+ controller.enqueue(bytes)
84
+ controller.close()
85
+ },
86
+ })
87
+ }
@@ -0,0 +1,199 @@
1
+ import type { StandardSchemaV1 } from "@nifrajs/core/schema"
2
+ import { guardComponentProps } from "@nifrajs/web/server-components/props"
3
+ import {
4
+ type ComponentType,
5
+ cloneElement,
6
+ createElement,
7
+ isValidElement,
8
+ type ReactElement,
9
+ type ReactNode,
10
+ } from "react"
11
+
12
+ interface ClientContract {
13
+ readonly reference: ComponentType<Record<string, unknown>>
14
+ readonly schema: StandardSchemaV1
15
+ readonly slots: readonly string[]
16
+ }
17
+ const contracts = new WeakMap<object, ClientContract>()
18
+ const wrappers = new WeakMap<object, ClientContract>()
19
+ const encoder = new TextEncoder()
20
+
21
+ function failure(): Error {
22
+ const error = new Error("component_failed")
23
+ error.stack = ""
24
+ return error
25
+ }
26
+
27
+ function slot(value: unknown, depth = 0): ReactNode {
28
+ if (depth > 16) throw failure()
29
+ if (
30
+ value === undefined ||
31
+ value === null ||
32
+ typeof value === "boolean" ||
33
+ typeof value === "string" ||
34
+ (typeof value === "number" && Number.isFinite(value)) ||
35
+ isValidElement(value)
36
+ )
37
+ return value
38
+ if (Array.isArray(value) && value.length <= 128)
39
+ return value.map((item: unknown) => slot(item, depth + 1))
40
+ throw failure()
41
+ }
42
+
43
+ function publicProps(contract: ClientContract, raw: unknown): Promise<Record<string, unknown>> {
44
+ return (async () => {
45
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw failure()
46
+ const dto: Record<string, unknown> = Object.create(null)
47
+ const slots: Record<string, ReactNode> = Object.create(null)
48
+ for (const [name, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(raw))) {
49
+ if (!Object.hasOwn(descriptor, "value")) throw failure()
50
+ if (contract.slots.includes(name)) slots[name] = slot(descriptor.value)
51
+ else if (name !== "children" && name !== "ref" && name !== "key") dto[name] = descriptor.value
52
+ }
53
+ const projected = await guardComponentProps(contract.schema, dto)
54
+ if (typeof projected !== "object" || projected === null || Array.isArray(projected))
55
+ throw failure()
56
+ return { ...projected, ...slots }
57
+ })().catch(() => {
58
+ throw failure()
59
+ })
60
+ }
61
+
62
+ /** Called by generated facades. The actual native reference stays private to this module. */
63
+ export function guardedClientReference(
64
+ reference: unknown,
65
+ schema: StandardSchemaV1,
66
+ slots: readonly string[] = [],
67
+ ): ComponentType<Record<string, unknown>> {
68
+ const Facade = (): never => {
69
+ throw failure()
70
+ }
71
+ contracts.set(Facade, {
72
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Registered native references and projected props retain the pinned React ABI; key and prop vocabulary are checked before serialization.
73
+ reference: reference as ComponentType<Record<string, unknown>>,
74
+ schema,
75
+ slots,
76
+ })
77
+ return Facade
78
+ }
79
+
80
+ export function guardedClientElement(
81
+ type: unknown,
82
+ raw: unknown,
83
+ key?: unknown,
84
+ ): ReactNode | undefined {
85
+ if ((typeof type !== "object" && typeof type !== "function") || type === null) return undefined
86
+ const contract = contracts.get(type)
87
+ if (contract === undefined) return undefined
88
+ if (
89
+ key !== undefined &&
90
+ key !== null &&
91
+ !(
92
+ (typeof key === "string" && encoder.encode(key).length <= 128) ||
93
+ (typeof key === "number" && Number.isSafeInteger(key))
94
+ )
95
+ )
96
+ throw failure()
97
+ const props = publicProps(contract, raw ?? {})
98
+ const publicKey = typeof key === "string" || typeof key === "number" ? key : null
99
+ // Only a promise of projected props reaches React's element/debug metadata. Raw props stay in the guard closure.
100
+ const Guarded = async ({
101
+ value,
102
+ }: {
103
+ value: Promise<Record<string, unknown>>
104
+ }): Promise<ReactNode> =>
105
+ createElement(contract.reference, {
106
+ ...(await value),
107
+ key: publicKey,
108
+ })
109
+ wrappers.set(Guarded, contract)
110
+ return createElement(Guarded, {
111
+ value: props,
112
+ key: publicKey,
113
+ })
114
+ }
115
+
116
+ export function guardedCreateElement(
117
+ type: unknown,
118
+ props?: unknown,
119
+ ...children: ReactNode[]
120
+ ): ReactNode {
121
+ const raw =
122
+ children.length === 0
123
+ ? props
124
+ : {
125
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Registered native references and projected props retain the pinned React ABI; key and prop vocabulary are checked before serialization.
126
+ ...(props as Record<string, unknown> | undefined),
127
+ children: children.length === 1 ? children[0] : children,
128
+ }
129
+ const key =
130
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Registered native references and projected props retain the pinned React ABI; key and prop vocabulary are checked before serialization.
131
+ typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>).key : undefined
132
+ const guarded = guardedClientElement(type, raw, key)
133
+ if (guarded !== undefined) return guarded
134
+ return createElement(
135
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Registered native references and projected props retain the pinned React ABI; key and prop vocabulary are checked before serialization.
136
+ type as ComponentType,
137
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Registered native references and projected props retain the pinned React ABI; key and prop vocabulary are checked before serialization.
138
+ props as Record<string, unknown> | null | undefined,
139
+ ...children,
140
+ )
141
+ }
142
+
143
+ export function guardedCloneElement(
144
+ element: ReactElement,
145
+ props?: unknown,
146
+ ...children: ReactNode[]
147
+ ): ReactNode {
148
+ const contract =
149
+ (typeof element.type === "object" || typeof element.type === "function") &&
150
+ element.type !== null
151
+ ? (wrappers.get(element.type) ?? contracts.get(element.type))
152
+ : undefined
153
+ if (contract === undefined)
154
+ return cloneElement(
155
+ element,
156
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Registered native references and projected props retain the pinned React ABI; key and prop vocabulary are checked before serialization.
157
+ props === null ? undefined : (props as Record<string, unknown> | undefined),
158
+ ...children,
159
+ )
160
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Registered native references and projected props retain the pinned React ABI; key and prop vocabulary are checked before serialization.
161
+ const previous = element.props as Record<string, unknown>
162
+ const value = Object.hasOwn(previous, "value")
163
+ ? // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Registered native references and projected props retain the pinned React ABI; key and prop vocabulary are checked before serialization.
164
+ (previous.value as Promise<Record<string, unknown>>)
165
+ : publicProps(contract, previous)
166
+ const raw = props ?? {}
167
+ if (
168
+ typeof raw !== "object" ||
169
+ raw === null ||
170
+ Array.isArray(raw) ||
171
+ Object.values(Object.getOwnPropertyDescriptors(raw)).some(
172
+ (descriptor) => !Object.hasOwn(descriptor, "value"),
173
+ )
174
+ )
175
+ throw failure()
176
+ const next = value.then((projected) =>
177
+ publicProps(contract, { ...projected, ...raw, ...(children.length === 0 ? {} : { children }) }),
178
+ )
179
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Registered native references and projected props retain the pinned React ABI; key and prop vocabulary are checked before serialization.
180
+ const rawKey = (raw as Record<string, unknown>).key ?? element.key
181
+ if (
182
+ rawKey !== null &&
183
+ !(
184
+ (typeof rawKey === "string" && encoder.encode(rawKey).length <= 128) ||
185
+ (typeof rawKey === "number" && Number.isSafeInteger(rawKey))
186
+ )
187
+ )
188
+ throw failure()
189
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Registered native references and projected props retain the pinned React ABI; key and prop vocabulary are checked before serialization.
190
+ const key = rawKey as string | number | null
191
+ const Guarded = async ({
192
+ value,
193
+ }: {
194
+ value: Promise<Record<string, unknown>>
195
+ }): Promise<ReactNode> => createElement(contract.reference, { ...(await value), key })
196
+ wrappers.set(Guarded, contract)
197
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Registered native references and projected props retain the pinned React ABI; key and prop vocabulary are checked before serialization.
198
+ return createElement(Guarded, { value: next, key: key as string | number | null })
199
+ }
@@ -0,0 +1,27 @@
1
+ import { Fragment, jsxDEV as nativeJsxDEV } from "react/jsx-dev-runtime"
2
+ import { guardedClientElement } from "./guard.ts"
3
+
4
+ export { Fragment }
5
+
6
+ export function jsxDEV(
7
+ type: unknown,
8
+ props: unknown,
9
+ key: unknown,
10
+ isStaticChildren: boolean,
11
+ source?: { fileName: string; lineNumber: number; columnNumber: number },
12
+ self?: unknown,
13
+ ): unknown {
14
+ return (
15
+ guardedClientElement(type, props, key) ??
16
+ nativeJsxDEV(
17
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: The guard handles registered client references; other elements use the unchanged native React development JSX ABI.
18
+ type as Parameters<typeof nativeJsxDEV>[0],
19
+ props,
20
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: The guard handles registered client references; other elements use the unchanged native React development JSX ABI.
21
+ key as string | undefined,
22
+ isStaticChildren,
23
+ source,
24
+ self,
25
+ )
26
+ )
27
+ }
@@ -0,0 +1,19 @@
1
+ import { Fragment, jsx as nativeJsx, jsxs as nativeJsxs } from "react/jsx-runtime"
2
+ import { guardedClientElement } from "./guard.ts"
3
+
4
+ export { Fragment }
5
+
6
+ export function jsx(type: unknown, props: unknown, key?: unknown): unknown {
7
+ return (
8
+ guardedClientElement(type, props, key) ??
9
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: The guard handles registered client references; other elements use the unchanged native React JSX ABI.
10
+ nativeJsx(type as Parameters<typeof nativeJsx>[0], props, key as string | undefined)
11
+ )
12
+ }
13
+ export function jsxs(type: unknown, props: unknown, key?: unknown): unknown {
14
+ return (
15
+ guardedClientElement(type, props, key) ??
16
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: The guard handles registered client references; other elements use the unchanged native React JSX ABI.
17
+ nativeJsxs(type as Parameters<typeof nativeJsxs>[0], props, key as string | undefined)
18
+ )
19
+ }
@@ -0,0 +1,2 @@
1
+ import { t } from "@nifrajs/schema"
2
+ export const NestedServerComponent = t.object({ instance: t.string(), html: t.string() })
@@ -0,0 +1,18 @@
1
+ "use client"
2
+ import { createElement, useRef } from "react"
3
+
4
+ /** The outer Flight root preserves this host; the controller owns the complete nested range. */
5
+ export function NestedServerComponent({
6
+ instance,
7
+ html,
8
+ }: {
9
+ readonly instance: string
10
+ readonly html: string
11
+ }) {
12
+ const initial = useRef({ __html: html })
13
+ return createElement("nifra-component", {
14
+ "data-nifra-component": instance,
15
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: Parent rendering provides the verified server-owned nested range.
16
+ dangerouslySetInnerHTML: initial.current,
17
+ })
18
+ }