@barefootjs/hono 0.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 (81) hide show
  1. package/dist/adapter/hono-adapter.d.ts +141 -0
  2. package/dist/adapter/hono-adapter.d.ts.map +1 -0
  3. package/dist/adapter/index.d.ts +6 -0
  4. package/dist/adapter/index.d.ts.map +1 -0
  5. package/dist/adapter/index.js +632 -0
  6. package/dist/app.d.ts +131 -0
  7. package/dist/app.d.ts.map +1 -0
  8. package/dist/app.js +139 -0
  9. package/dist/async.d.ts +15 -0
  10. package/dist/async.d.ts.map +1 -0
  11. package/dist/async.js +12 -0
  12. package/dist/build.d.ts +65 -0
  13. package/dist/build.d.ts.map +1 -0
  14. package/dist/build.js +785 -0
  15. package/dist/client-shim.d.ts +59 -0
  16. package/dist/client-shim.d.ts.map +1 -0
  17. package/dist/client-shim.js +90 -0
  18. package/dist/dev-worker.d.ts +25 -0
  19. package/dist/dev-worker.d.ts.map +1 -0
  20. package/dist/dev-worker.js +65 -0
  21. package/dist/dev.d.ts +36 -0
  22. package/dist/dev.d.ts.map +1 -0
  23. package/dist/dev.js +418 -0
  24. package/dist/dialog-context.d.ts +13 -0
  25. package/dist/dialog-context.d.ts.map +1 -0
  26. package/dist/dialog-context.js +10 -0
  27. package/dist/index.d.ts +13 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +632 -0
  30. package/dist/jsx/jsx-dev-runtime/index.d.ts +9 -0
  31. package/dist/jsx/jsx-dev-runtime/index.d.ts.map +1 -0
  32. package/dist/jsx/jsx-dev-runtime/index.js +6 -0
  33. package/dist/jsx/jsx-runtime/index.d.ts +32 -0
  34. package/dist/jsx/jsx-runtime/index.d.ts.map +1 -0
  35. package/dist/jsx/jsx-runtime/index.js +10 -0
  36. package/dist/portal-ssr.d.ts +22 -0
  37. package/dist/portal-ssr.d.ts.map +1 -0
  38. package/dist/portal-ssr.js +73 -0
  39. package/dist/portals.d.ts +26 -0
  40. package/dist/portals.d.ts.map +1 -0
  41. package/dist/portals.js +41 -0
  42. package/dist/preload.d.ts +56 -0
  43. package/dist/preload.d.ts.map +1 -0
  44. package/dist/preload.js +51 -0
  45. package/dist/scripts.d.ts +80 -0
  46. package/dist/scripts.d.ts.map +1 -0
  47. package/dist/scripts.js +198 -0
  48. package/dist/test-render.d.ts +28 -0
  49. package/dist/test-render.d.ts.map +1 -0
  50. package/dist/utils.d.ts +16 -0
  51. package/dist/utils.d.ts.map +1 -0
  52. package/dist/utils.js +16 -0
  53. package/package.json +116 -0
  54. package/src/__tests__/async.test.tsx +106 -0
  55. package/src/__tests__/bfscripts-entry-roots.test.tsx +135 -0
  56. package/src/__tests__/build.test.ts +299 -0
  57. package/src/__tests__/dev.test.tsx +123 -0
  58. package/src/__tests__/hydration-props-type.test.ts +141 -0
  59. package/src/__tests__/manifest-scripts.test.ts +87 -0
  60. package/src/__tests__/scaffold.test.ts +209 -0
  61. package/src/__tests__/ssr-context-bridge.test.ts +110 -0
  62. package/src/__tests__/string-literal-css-var-prop.test.ts +84 -0
  63. package/src/__tests__/stub-deps-scripts.test.ts +183 -0
  64. package/src/adapter/hono-adapter.ts +1114 -0
  65. package/src/adapter/index.ts +6 -0
  66. package/src/app.ts +220 -0
  67. package/src/async.tsx +55 -0
  68. package/src/build.ts +230 -0
  69. package/src/client-shim.ts +164 -0
  70. package/src/dev-worker.ts +93 -0
  71. package/src/dev.tsx +146 -0
  72. package/src/dialog-context.tsx +44 -0
  73. package/src/index.ts +26 -0
  74. package/src/jsx/jsx-dev-runtime/index.ts +9 -0
  75. package/src/jsx/jsx-runtime/index.ts +40 -0
  76. package/src/portal-ssr.tsx +92 -0
  77. package/src/portals.tsx +98 -0
  78. package/src/preload.tsx +166 -0
  79. package/src/scripts.tsx +220 -0
  80. package/src/test-render.ts +143 -0
  81. package/src/utils.ts +26 -0
@@ -0,0 +1,93 @@
1
+ /**
2
+ * BarefootJS Dev Reloader for Cloudflare Workers / other edge runtimes.
3
+ *
4
+ * Unlike `./dev`'s `createDevReloader`, this variant does not watch the local
5
+ * filesystem — it generates a fresh boot ID on every cold start and relies on
6
+ * the standard SSE `Last-Event-ID` reconnection protocol to detect restarts:
7
+ *
8
+ * 1. First connect → send `event: hello`, `id: <BOOT_ID>`.
9
+ * 2. Worker restart → SSE stream drops, client reconnects with
10
+ * `Last-Event-ID: <old BOOT_ID>`.
11
+ * 3. Server sees mismatch → send `event: reload`, client refreshes.
12
+ *
13
+ * Pair with `BfDevReload` from `@barefootjs/hono/dev-reload`.
14
+ */
15
+
16
+ import type { Context } from 'hono'
17
+
18
+ export interface CreateDevReloaderOptions {
19
+ /** Override the dev gate. Defaults to `process.env.NODE_ENV !== 'production'`. */
20
+ enabled?: boolean
21
+ }
22
+
23
+ const HEARTBEAT_MS = 5000
24
+
25
+ // Generated once per Worker isolate. A cold start or code change rotates this,
26
+ // which is exactly the signal we want to surface to the browser.
27
+ const BOOT_ID = generateBootId()
28
+
29
+ function generateBootId(): string {
30
+ try {
31
+ return crypto.randomUUID()
32
+ } catch {
33
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 10)
34
+ }
35
+ }
36
+
37
+ function isDevDefault(): boolean {
38
+ return process.env.NODE_ENV !== 'production'
39
+ }
40
+
41
+ /**
42
+ * Hono route handler that streams Server-Sent Events. Returns 404 in
43
+ * production (`NODE_ENV=production`) unless `enabled` is set explicitly.
44
+ */
45
+ export function createDevReloader(
46
+ options: CreateDevReloaderOptions = {},
47
+ ): (c: Context) => Response | Promise<Response> {
48
+ const { enabled = isDevDefault() } = options
49
+
50
+ return (c: Context) => {
51
+ if (!enabled) return c.notFound()
52
+
53
+ const lastEventId = (c.req.header('Last-Event-ID') ?? '').trim()
54
+ const signal = c.req.raw.signal
55
+
56
+ const stream = new ReadableStream<Uint8Array>({
57
+ start(controller) {
58
+ const encoder = new TextEncoder()
59
+ const send = (chunk: string) => {
60
+ try {
61
+ controller.enqueue(encoder.encode(chunk))
62
+ } catch {
63
+ // Stream already closed (client disconnected).
64
+ }
65
+ }
66
+
67
+ send(`retry: 1000\n\n`)
68
+ // On reconnect with a different boot id, the Worker restarted (or was
69
+ // evicted from the isolate cache) while the client was disconnected —
70
+ // signal a reload so the browser picks up any new code + assets.
71
+ const event = lastEventId && lastEventId !== BOOT_ID ? 'reload' : 'hello'
72
+ send(`event: ${event}\nid: ${BOOT_ID}\ndata: ${BOOT_ID}\n\n`)
73
+
74
+ const heartbeat = setInterval(() => send(`: hb\n\n`), HEARTBEAT_MS)
75
+ const onAbort = () => {
76
+ clearInterval(heartbeat)
77
+ try { controller.close() } catch { /* already closed */ }
78
+ }
79
+ if (signal.aborted) onAbort()
80
+ else signal.addEventListener('abort', onAbort, { once: true })
81
+ },
82
+ })
83
+
84
+ return new Response(stream, {
85
+ headers: {
86
+ 'Content-Type': 'text/event-stream',
87
+ 'Cache-Control': 'no-cache, no-transform',
88
+ 'Connection': 'keep-alive',
89
+ 'X-Accel-Buffering': 'no',
90
+ },
91
+ })
92
+ }
93
+ }
package/src/dev.tsx ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * BarefootJS Dev Reloader (Hono, Node-side)
3
+ *
4
+ * `createDevReloader` turns `bf build --watch`'s sentinel file
5
+ * (`<distDir>/.dev/build-id`) into an SSE stream:
6
+ *
7
+ * [bf build --watch] → writes `<distDir>/.dev/build-id` after each successful build
8
+ * [createDevReloader] → watches that file, streams SSE `event: reload`
9
+ *
10
+ * Mount it on a Hono route in the generated app; the matching browser-
11
+ * side subscriber (`<DevReload />`) lives in the project itself (see
12
+ * the hono-node scaffold's `dev-reload.tsx`) so its endpoint URL and
13
+ * reconnect behavior are an in-tree edit.
14
+ *
15
+ * ```ts
16
+ * // factory.ts
17
+ * import { createDevReloader } from '@barefootjs/hono/dev'
18
+ * app.get('/_bf/reload', createDevReloader({ distDir: './dist' }))
19
+ * ```
20
+ *
21
+ * Disabled (404) when `NODE_ENV === 'production'` unless `enabled: true`
22
+ * is passed explicitly.
23
+ */
24
+
25
+ import type { Context } from 'hono'
26
+ import { mkdir, readFile, watch } from 'node:fs/promises'
27
+ import { resolve } from 'node:path'
28
+
29
+ export interface CreateDevReloaderOptions {
30
+ /** Directory that `bf build` writes output into (contains `.dev/build-id`). */
31
+ distDir: string
32
+ /** Override the dev gate. Defaults to `process.env.NODE_ENV !== 'production'`. */
33
+ enabled?: boolean
34
+ }
35
+
36
+ // Sentinel path contract with `@barefootjs/cli`. These values must match
37
+ // `DEV_SENTINEL_SUBDIR` / `DEV_SENTINEL_FILENAME` in `packages/cli/src/lib/build.ts`
38
+ // — duplicated intentionally to avoid a runtime dep on the CLI.
39
+ const DEV_SUBDIR = '.dev'
40
+ const BUILD_ID_FILE = 'build-id'
41
+ /**
42
+ * Heartbeat interval for idle keepalive. Must stay comfortably under Bun's
43
+ * default 10s idleTimeout — otherwise the server would close a quiet SSE
44
+ * stream and the browser would EventSource-reconnect every cycle, which can
45
+ * lose a rebuild event emitted in the gap between close and reconnect.
46
+ */
47
+ const HEARTBEAT_MS = 5000
48
+
49
+ function isDevDefault(): boolean {
50
+ return process.env.NODE_ENV !== 'production'
51
+ }
52
+
53
+ /**
54
+ * Hono route handler that streams Server-Sent Events and emits `reload` every
55
+ * time `<distDir>/.dev/build-id` is written. Disabled (404) in production.
56
+ */
57
+ export function createDevReloader(
58
+ options: CreateDevReloaderOptions,
59
+ ): (c: Context) => Response | Promise<Response> {
60
+ const { distDir, enabled = isDevDefault() } = options
61
+
62
+ return async (c: Context) => {
63
+ if (!enabled) return c.notFound()
64
+
65
+ const devDir = resolve(distDir, DEV_SUBDIR)
66
+ // Ensure the directory exists so fs.watch doesn't ENOENT before the first build.
67
+ await mkdir(devDir, { recursive: true })
68
+
69
+ const buildIdPath = resolve(devDir, BUILD_ID_FILE)
70
+ const signal = c.req.raw.signal
71
+ // If the client reconnects with Last-Event-ID (the build-id it last saw)
72
+ // and the current build-id is newer, a rebuild happened while it was
73
+ // disconnected — recover by firing `reload` immediately instead of `hello`.
74
+ const lastEventId = (c.req.header('Last-Event-ID') ?? '').trim()
75
+
76
+ const readBuildId = async (): Promise<string> => {
77
+ try {
78
+ return (await readFile(buildIdPath, 'utf8')).trim()
79
+ } catch {
80
+ return ''
81
+ }
82
+ }
83
+
84
+ const stream = new ReadableStream<Uint8Array>({
85
+ async start(controller) {
86
+ const encoder = new TextEncoder()
87
+ const send = (chunk: string) => {
88
+ try {
89
+ controller.enqueue(encoder.encode(chunk))
90
+ } catch {
91
+ // Stream already closed (client disconnected).
92
+ }
93
+ }
94
+
95
+ send(`retry: 1000\n\n`)
96
+ let lastSentId = ''
97
+ const initialId = await readBuildId()
98
+ if (initialId) {
99
+ lastSentId = initialId
100
+ const event = lastEventId && lastEventId !== initialId ? 'reload' : 'hello'
101
+ send(`event: ${event}\nid: ${initialId}\ndata: ${initialId}\n\n`)
102
+ }
103
+
104
+ // Heartbeat keeps the connection under Bun's idleTimeout so that a
105
+ // silent period between builds doesn't close the socket (which would
106
+ // otherwise race with in-flight rebuilds and drop `reload` events).
107
+ const heartbeat = setInterval(() => send(`: hb\n\n`), HEARTBEAT_MS)
108
+
109
+ try {
110
+ // Watch the parent directory: the build-id file may not exist yet,
111
+ // and `fs.watch` on a missing path throws.
112
+ const iter = watch(devDir, { signal })
113
+ for await (const event of iter) {
114
+ if (event.filename !== BUILD_ID_FILE) continue
115
+ const id = await readBuildId()
116
+ if (!id || id === lastSentId) continue
117
+ lastSentId = id
118
+ send(`event: reload\nid: ${id}\ndata: ${id}\n\n`)
119
+ }
120
+ } catch (err) {
121
+ const name = (err as { name?: string } | undefined)?.name
122
+ if (name !== 'AbortError') {
123
+ const message = (err as Error).message ?? 'watch error'
124
+ send(`event: error\ndata: ${JSON.stringify(message)}\n\n`)
125
+ }
126
+ } finally {
127
+ clearInterval(heartbeat)
128
+ try { controller.close() } catch { /* already closed */ }
129
+ }
130
+ },
131
+ cancel() {
132
+ // Client disconnected; fs.watch will unwind via `signal`.
133
+ },
134
+ })
135
+
136
+ return new Response(stream, {
137
+ headers: {
138
+ 'Content-Type': 'text/event-stream',
139
+ 'Cache-Control': 'no-cache, no-transform',
140
+ 'Connection': 'keep-alive',
141
+ 'X-Accel-Buffering': 'no',
142
+ },
143
+ })
144
+ }
145
+ }
146
+
@@ -0,0 +1,44 @@
1
+ // @jsxRuntime automatic
2
+ // @jsxImportSource hono/jsx
3
+ //
4
+ // Pragmas as line-comments above the JSDoc; see scripts.tsx for the rationale.
5
+
6
+ /**
7
+ * Dialog Context
8
+ *
9
+ * Provides scopeId sharing between Dialog components.
10
+ * DialogRoot sets the context, child components (DialogOverlay, DialogContent) read it.
11
+ *
12
+ * Usage:
13
+ * ```tsx
14
+ * import { DialogContext, useDialogContext } from '@barefootjs/hono'
15
+ *
16
+ * // In DialogRoot
17
+ * <DialogContext.Provider value={{ scopeId }}>
18
+ * {children}
19
+ * </DialogContext.Provider>
20
+ *
21
+ * // In DialogOverlay/DialogContent
22
+ * const ctx = useDialogContext()
23
+ * <Portal scopeId={ctx?.scopeId}>...</Portal>
24
+ * ```
25
+ */
26
+
27
+ import { createContext, useContext } from 'hono/jsx'
28
+
29
+ export type DialogContextValue = {
30
+ scopeId: string
31
+ }
32
+
33
+ /**
34
+ * Context for sharing scopeId between Dialog components.
35
+ */
36
+ export const DialogContext = createContext<DialogContextValue | null>(null)
37
+
38
+ /**
39
+ * Hook to access Dialog context.
40
+ * Returns null if not inside a DialogRoot.
41
+ */
42
+ export function useDialogContext(): DialogContextValue | null {
43
+ return useContext(DialogContext)
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * BarefootJS Hono Integration
3
+ *
4
+ * Provides Hono-specific adapters and utilities for BarefootJS.
5
+ */
6
+
7
+ // Hono Adapter for JSX compilation
8
+ export { HonoAdapter, honoAdapter } from './adapter'
9
+ export type { HonoAdapterOptions } from './adapter'
10
+
11
+ // BfScripts is exported from a separate entry point to avoid JSX runtime issues in tests
12
+ // Usage: import { BfScripts } from '@barefootjs/hono/scripts'
13
+ export type { CollectedScript } from './scripts'
14
+
15
+ // Portal components for SSR
16
+ // Usage: import { BfPortals, Portal } from '@barefootjs/hono/portals'
17
+ export type { CollectedPortal } from './portals'
18
+ export type { PortalProps } from './portal-ssr'
19
+
20
+ // Async streaming boundary
21
+ // Usage: import { BfAsync } from '@barefootjs/hono/async'
22
+ export type { BfAsyncProps } from './async'
23
+
24
+ // Dialog context for scopeId sharing
25
+ // Usage: import { DialogContext, useDialogContext } from '@barefootjs/hono/dialog-context'
26
+ export type { DialogContextValue } from './dialog-context'
@@ -0,0 +1,9 @@
1
+ /**
2
+ * BarefootJS Hono JSX Extension - Development Runtime
3
+ *
4
+ * Re-exports `jsxDEV` / `Fragment` from hono/jsx and surfaces the same
5
+ * JSX namespace as the production runtime so dev builds see identical types.
6
+ */
7
+
8
+ export { jsxDEV, Fragment } from 'hono/jsx/jsx-dev-runtime'
9
+ export type { JSX } from '../jsx-runtime'
@@ -0,0 +1,40 @@
1
+ /**
2
+ * BarefootJS Hono JSX Extension
3
+ *
4
+ * Combines hono/jsx runtime with @barefootjs/jsx type definitions.
5
+ * - Runtime functions from hono/jsx
6
+ * - Typed event handlers and IntrinsicElements from @barefootjs/jsx
7
+ * - JSX.Element from hono/jsx (for Suspense/streaming support)
8
+ *
9
+ * Usage in tsconfig.json:
10
+ * "jsxImportSource": "@barefootjs/hono/jsx"
11
+ */
12
+
13
+ // Runtime functions from hono/jsx.
14
+ export { jsx, jsxs, Fragment, jsxAttr, jsxEscape, jsxTemplate } from 'hono/jsx/jsx-runtime'
15
+
16
+ // Re-export JSX namespace from @barefootjs/jsx, but override Element type for Hono.
17
+ import type { JSX as BaseJSX } from '@barefootjs/jsx/jsx-runtime'
18
+
19
+ export declare namespace JSX {
20
+ // Use Hono's Element type for Suspense/streaming compatibility.
21
+ type Element = import('hono/jsx/jsx-runtime').JSX.Element
22
+
23
+ // Re-use types from @barefootjs/jsx.
24
+ type IntrinsicElements = BaseJSX.IntrinsicElements
25
+ type IntrinsicAttributes = BaseJSX.IntrinsicAttributes
26
+ type ElementChildrenAttribute = BaseJSX.ElementChildrenAttribute
27
+ }
28
+
29
+ /**
30
+ * BarefootJS compiler built-in: streaming async boundary.
31
+ *
32
+ * The compiler intercepts `<Async fallback={...}>` in JSX source and emits it
33
+ * as a `<Suspense>` node in the Hono adapter output (IRAsync → renderAsync).
34
+ * This declaration provides TypeScript types for source files; no runtime
35
+ * implementation is needed because the compiler replaces it before execution.
36
+ */
37
+ export declare function Async(props: {
38
+ fallback: JSX.Element
39
+ children: JSX.Element | JSX.Element[] | null | undefined
40
+ }): JSX.Element
@@ -0,0 +1,92 @@
1
+ // @jsxRuntime automatic
2
+ // @jsxImportSource hono/jsx
3
+ //
4
+ // Pragmas as line-comments above the JSDoc; see scripts.tsx for the rationale.
5
+
6
+ /**
7
+ * Portal Component for SSR
8
+ *
9
+ * Renders children to document.body during SSR via BfPortals collection.
10
+ * On client-side, renders children normally (to be moved by createPortal).
11
+ *
12
+ * Usage:
13
+ * ```tsx
14
+ * import { Portal } from '@barefootjs/hono'
15
+ *
16
+ * function MyDialog({ scopeId }: { scopeId: string }) {
17
+ * return (
18
+ * <Portal scopeId={scopeId}>
19
+ * <div class="dialog">Dialog content</div>
20
+ * </Portal>
21
+ * )
22
+ * }
23
+ * ```
24
+ */
25
+
26
+ import { useRequestContext } from 'hono/jsx-renderer'
27
+ import { Fragment } from 'hono/jsx'
28
+ import type { Child } from 'hono/jsx'
29
+ import { collectPortal, isPortalsRendered } from './portals'
30
+
31
+ let portalCounter = 0
32
+
33
+ /**
34
+ * Generate unique portal ID for SSR/hydration matching.
35
+ * Counter resets per request in production (new context per request).
36
+ */
37
+ function generatePortalId(): string {
38
+ return `bf-portal-${++portalCounter}`
39
+ }
40
+
41
+ /**
42
+ * Reset portal counter (for testing or explicit reset).
43
+ */
44
+ export function resetPortalCounter(): void {
45
+ portalCounter = 0
46
+ }
47
+
48
+ export interface PortalProps {
49
+ children: Child
50
+ scopeId?: string
51
+ }
52
+
53
+ /**
54
+ * Portal component that moves children to document.body during SSR.
55
+ *
56
+ * During SSR:
57
+ * - Collects content for BfPortals output (before BfPortals renders)
58
+ * - Returns placeholder <template> for hydration matching
59
+ * - If BfPortals already rendered (Suspense), outputs inline
60
+ *
61
+ * On client:
62
+ * - Renders children normally (createPortal moves them later)
63
+ */
64
+ export function Portal(props: PortalProps) {
65
+ const portalId = generatePortalId()
66
+
67
+ try {
68
+ // Check if we're in SSR context (will throw if not)
69
+ useRequestContext()
70
+
71
+ if (isPortalsRendered()) {
72
+ // BfPortals already rendered (e.g., inside Suspense boundary)
73
+ // Output portal content inline
74
+ return (
75
+ <div bf-pi={portalId} bf-po={props.scopeId || ''}>
76
+ {props.children}
77
+ </div>
78
+ )
79
+ }
80
+
81
+ // Collect portal content for BfPortals output
82
+ collectPortal(portalId, props.scopeId || '', props.children)
83
+
84
+ // Return placeholder for hydration matching
85
+ // Client will find this and know the portal content is at body end
86
+ return <template bf-pp={portalId} />
87
+ } catch {
88
+ // Outside request context (client-side rendering)
89
+ // Render children normally - they will be moved by createPortal on mount
90
+ return <Fragment>{props.children}</Fragment>
91
+ }
92
+ }
@@ -0,0 +1,98 @@
1
+ // @jsxRuntime automatic
2
+ // @jsxImportSource hono/jsx
3
+ //
4
+ // Pragmas as line-comments above the JSDoc; see scripts.tsx for the rationale.
5
+
6
+ /**
7
+ * BfPortals Component
8
+ *
9
+ * Renders collected portal content at the end of the document body.
10
+ * BarefootJS Portal components collect their content during SSR render,
11
+ * and this component outputs them all at once to ensure correct positioning.
12
+ *
13
+ * Usage:
14
+ * ```tsx
15
+ * import { BfPortals } from '@barefootjs/hono'
16
+ *
17
+ * <html>
18
+ * <body>
19
+ * {children}
20
+ * <BfPortals />
21
+ * <BfScripts />
22
+ * </body>
23
+ * </html>
24
+ * ```
25
+ */
26
+
27
+ import { useRequestContext } from 'hono/jsx-renderer'
28
+ import { Fragment } from 'hono/jsx'
29
+ import type { Child } from 'hono/jsx'
30
+
31
+ export type CollectedPortal = {
32
+ id: string
33
+ scopeId: string
34
+ content: Child
35
+ }
36
+
37
+ /**
38
+ * Collect portal content for SSR output.
39
+ * Called by Portal component during SSR rendering.
40
+ */
41
+ export function collectPortal(id: string, scopeId: string, content: Child): void {
42
+ try {
43
+ const c = useRequestContext()
44
+ const portals: CollectedPortal[] = c.get('bfCollectedPortals') || []
45
+ portals.push({ id, scopeId, content })
46
+ c.set('bfCollectedPortals', portals)
47
+ } catch {
48
+ // Outside request context (client-side) - no-op
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Check if BfPortals has already been rendered.
54
+ * Used by Portal component to determine if it should output inline.
55
+ */
56
+ export function isPortalsRendered(): boolean {
57
+ try {
58
+ const c = useRequestContext()
59
+ return c.get('bfPortalsRendered') ?? false
60
+ } catch {
61
+ // Outside request context (client-side)
62
+ return true
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Renders all collected portal content.
68
+ * Place this component at the end of your <body> element, before BfScripts.
69
+ *
70
+ * After rendering, sets 'bfPortalsRendered' flag to true.
71
+ * Portal components rendered after BfPortals (e.g., inside Suspense boundaries)
72
+ * will check this flag and output their content inline instead.
73
+ */
74
+ export function BfPortals() {
75
+ try {
76
+ const c = useRequestContext()
77
+
78
+ // Mark that BfPortals has been rendered.
79
+ // Portal components rendered after this point (e.g., inside Suspense)
80
+ // should output their content inline.
81
+ c.set('bfPortalsRendered', true)
82
+
83
+ const portals: CollectedPortal[] = c.get('bfCollectedPortals') || []
84
+
85
+ return (
86
+ <Fragment>
87
+ {portals.map(({ id, scopeId, content }) => (
88
+ <div key={id} bf-pi={id} bf-po={scopeId}>
89
+ {content}
90
+ </div>
91
+ ))}
92
+ </Fragment>
93
+ )
94
+ } catch {
95
+ // Context unavailable (e.g., not using jsxRenderer)
96
+ return null
97
+ }
98
+ }