@barefootjs/hono 0.30.5 → 0.31.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.
@@ -69,11 +69,10 @@ export interface HonoAdapterOptions {
69
69
  clientJsFilename?: string
70
70
 
71
71
  /**
72
- * Display name surfaced through `JsxAdapter.name` read by `bf build`
73
- * for its `Adapter: …` banner. Defaults to `'hono'`. CSR-mode callers
74
- * (`@barefootjs/client/build`) pass `'csr'` so the banner reflects the
75
- * mode the user picked at scaffold time instead of leaking the
76
- * fact that CSR currently reuses HonoAdapter under the hood.
72
+ * Display name surfaced through `JsxAdapter.name`. Defaults to `'hono'`.
73
+ * (CSR mode no longer reuses `HonoAdapter` under the hood — see
74
+ * `@barefootjs/client/csr-adapter`'s own in-package `CSRAdapter` so
75
+ * this option has no CSR-specific caller today.)
77
76
  */
78
77
  name?: string
79
78
  }
@@ -125,9 +124,6 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
125
124
  name = 'hono'
126
125
  extension = '.tsx'
127
126
  clientShimSource = '@barefootjs/hono/client-shim'
128
- // Importmap is injected at render time by the `BfImportMap` component
129
- // (reads `barefoot-externals.json`), so `bf build` emits no static snippet.
130
- importMapInjection = 'component' as const
131
127
 
132
128
  // The Hono SSR runtime is JavaScript (Node / Bun / CF Workers), so any
133
129
  // synchronous JS call the user writes can be rendered as-is at template
@@ -165,6 +161,28 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
165
161
  private rewriteRelativeImport?: (importPath: string) => string
166
162
  /** Stack of loop keys for generating data-key / data-key-1 attributes on loop items */
167
163
  private loopKeyStack: Array<{ key: string | null; param: string }> = []
164
+ /**
165
+ * Per-call `AdapterGenerateOptions.scriptAssets`, stashed for the
166
+ * duration of one `generate()` call (same lifecycle as
167
+ * `rewriteRelativeImport`) so `generateImports`/`generateComponent`/
168
+ * `renderIfStatement` can all see it without threading it through every
169
+ * method signature. `undefined` means "the caller didn't resolve
170
+ * scriptAssets", so no script-registration codegen is emitted; `[]`
171
+ * means "resolved, and empty" (a server-only file, or a client file
172
+ * whose bundle isn't in the manifest yet); a non-empty array means
173
+ * "bake exactly these URLs in". See `AdapterGenerateOptions.
174
+ * scriptAssets`'s docstring for the full precedence contract this
175
+ * mirrors from `GoTemplateAdapter`.
176
+ */
177
+ private scriptAssets?: string[]
178
+ /**
179
+ * Per-call `AdapterGenerateOptions.preloadAssets`, same stash-for-the-
180
+ * duration-of-one-`generate()`-call lifecycle as `scriptAssets`. Only
181
+ * consulted when `hasScriptAssets()` is also true — see
182
+ * `AdapterGenerateOptions.preloadAssets`'s docstring: preloads are only
183
+ * meaningful alongside a non-empty `scriptAssets`.
184
+ */
185
+ private preloadAssets?: string[]
168
186
 
169
187
  constructor(options: HonoAdapterOptions = {}) {
170
188
  super()
@@ -180,6 +198,19 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
180
198
  this.componentName = ir.metadata.componentName
181
199
  this.isClientComponent = ir.metadata.isClientComponent
182
200
  this.rewriteRelativeImport = options?.rewriteRelativeImport
201
+ // `skipScriptRegistration` wins over `scriptAssets`/`preloadAssets`
202
+ // unconditionally — see both fields' docstrings on
203
+ // `AdapterGenerateOptions`. Stashing `undefined` for both here (rather
204
+ // than gating each call site individually) makes every downstream
205
+ // `hasScriptAssets()`/`hasPreloadAssets()` check "just work" without
206
+ // its own skip check.
207
+ if (options?.skipScriptRegistration) {
208
+ this.scriptAssets = undefined
209
+ this.preloadAssets = undefined
210
+ } else {
211
+ this.scriptAssets = options?.scriptAssets
212
+ this.preloadAssets = options?.preloadAssets
213
+ }
183
214
 
184
215
  // Generate component body FIRST so we can scan it for used imports
185
216
  const component = this.generateComponent(ir)
@@ -216,9 +247,34 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
216
247
  extension: this.extension,
217
248
  }
218
249
  this.rewriteRelativeImport = undefined
250
+ this.scriptAssets = undefined
251
+ this.preloadAssets = undefined
219
252
  return result
220
253
  }
221
254
 
255
+ /**
256
+ * True when `options.scriptAssets` was resolved AND non-empty for this
257
+ * `generate()` call — the single guard `generateImports`/
258
+ * `generateComponent`/`renderIfStatement` all consult before emitting
259
+ * script-registration codegen. See `scriptAssets`'s field docstring for
260
+ * why `undefined` and `[]` are both "no" here.
261
+ */
262
+ private hasScriptAssets(): boolean {
263
+ return !!this.scriptAssets && this.scriptAssets.length > 0
264
+ }
265
+
266
+ /**
267
+ * True when `options.preloadAssets` was resolved AND non-empty for this
268
+ * `generate()` call, AND `hasScriptAssets()` also holds — see
269
+ * `AdapterGenerateOptions.preloadAssets`'s docstring: preloads are only
270
+ * meaningful alongside a non-empty `scriptAssets`. Consulted by
271
+ * `generateImports`/`generateComponent`/`renderIfStatement` alongside
272
+ * `hasScriptAssets()` before emitting preload-registration codegen.
273
+ */
274
+ private hasPreloadAssets(): boolean {
275
+ return this.hasScriptAssets() && !!this.preloadAssets && this.preloadAssets.length > 0
276
+ }
277
+
222
278
  private generateModuleLevelContextBindings(ir: ComponentIR): string {
223
279
  const lines: string[] = []
224
280
  for (const c of ir.metadata.localConstants) {
@@ -251,6 +307,18 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
251
307
  lines.push(`import { ${utilImports.join(', ')} } from '@barefootjs/hono/utils'`)
252
308
  }
253
309
 
310
+ // Only imported when this call's `scriptAssets` (see the field
311
+ // docstring, and `generateComponent`) actually resolved to a non-empty
312
+ // URL list, so a server-only file emits no dead import.
313
+ // `registerComponentPreloads` is imported alongside them only when
314
+ // `preloadAssets` also resolved non-empty (`hasPreloadAssets()`).
315
+ if (this.hasScriptAssets()) {
316
+ const names = this.hasPreloadAssets()
317
+ ? ['registerComponentScripts', 'registerComponentPreloads', 'wrapWithInlineScripts']
318
+ : ['registerComponentScripts', 'wrapWithInlineScripts']
319
+ lines.push(`import { ${names.join(', ')} } from '@barefootjs/hono/scripts'`)
320
+ }
321
+
254
322
  // Import Suspense / ErrorBoundary when async boundaries are used. Both
255
323
  // are imported under `__Bf`-prefixed aliases (and emitted as such by
256
324
  // `renderAsync`) so the generated tags can never collide with a user
@@ -580,6 +648,25 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
580
648
  const exportPrefix = ir.metadata.isExported === false ? '' : 'export '
581
649
  lines.push(`${exportPrefix}function ${name}(${fullPropsDestructure}${typeAnnotation}${noArgDefault}) {`)
582
650
 
651
+ // Vite-pipeline script registration (see `scriptAssets`'s field
652
+ // docstring): register this call's resolved URLs against the request
653
+ // context right away, independent of props — `__bfInlineScripts` is
654
+ // then threaded through every `return (...)` this function can reach
655
+ // (the plain tail return below, and both branches of an if-statement
656
+ // root via `renderIfStatement`) so a component rendered after
657
+ // `<BfScripts />` (e.g. inside Suspense) ships its scripts inline
658
+ // instead of silently dropping them from the already-flushed collector.
659
+ // `__bfInlinePreloads` (registered FIRST, so preloads flush ahead of
660
+ // scripts wherever `<BfScripts />` renders them) follows the exact
661
+ // same pattern for `preloadAssets` — see `AdapterGenerateOptions.
662
+ // preloadAssets` and `hasPreloadAssets()`.
663
+ if (this.hasPreloadAssets()) {
664
+ lines.push(` const __bfInlinePreloads = registerComponentPreloads(${JSON.stringify(this.preloadAssets)})`)
665
+ }
666
+ if (this.hasScriptAssets()) {
667
+ lines.push(` const __bfInlineScripts = registerComponentScripts(${JSON.stringify(this.scriptAssets)})`)
668
+ }
669
+
583
670
  // Add props extraction for SolidJS-style pattern
584
671
  if (propsExtraction) {
585
672
  lines.push(propsExtraction)
@@ -627,9 +714,19 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
627
714
  return lines.join('\n')
628
715
  }
629
716
 
630
- lines.push(` return (`)
631
- lines.push(` ${jsxBody}`)
632
- lines.push(` )`)
717
+ if (this.hasScriptAssets()) {
718
+ lines.push(` return wrapWithInlineScripts((`)
719
+ lines.push(` ${jsxBody}`)
720
+ lines.push(
721
+ this.hasPreloadAssets()
722
+ ? ` ), __bfInlineScripts, __bfInlinePreloads)`
723
+ : ` ), __bfInlineScripts)`,
724
+ )
725
+ } else {
726
+ lines.push(` return (`)
727
+ lines.push(` ${jsxBody}`)
728
+ lines.push(` )`)
729
+ }
633
730
  lines.push(`}`)
634
731
 
635
732
  return lines.join('\n')
@@ -1032,11 +1129,27 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
1032
1129
  // Render the consequent (then branch) JSX
1033
1130
  const consequent = this.renderNode(ifStmt.consequent, ctx)
1034
1131
 
1132
+ // Every early-return branch below wraps with `wrapWithInlineScripts`
1133
+ // when this call's `scriptAssets` resolved non-empty — same
1134
+ // `__bfInlineScripts` binding `generateComponent` declares at the top
1135
+ // of the function, reused across every branch this if-chain can take.
1136
+ // `__bfInlinePreloads` (same declare-once-reuse-every-branch pattern)
1137
+ // is threaded in as a third argument when `preloadAssets` also
1138
+ // resolved non-empty.
1139
+ const wrap = this.hasScriptAssets()
1140
+ const wrapPreload = this.hasPreloadAssets()
1141
+ const openReturn = wrap ? ' return wrapWithInlineScripts((' : ' return ('
1142
+ const closeReturn = wrap
1143
+ ? wrapPreload
1144
+ ? ' ), __bfInlineScripts, __bfInlinePreloads)'
1145
+ : ' ), __bfInlineScripts)'
1146
+ : ' )'
1147
+
1035
1148
  // Build the if statement
1036
1149
  lines.unshift(` if (${ifStmt.condition}) {`)
1037
- lines.push(` return (`)
1150
+ lines.push(openReturn)
1038
1151
  lines.push(` ${consequent}`)
1039
- lines.push(` )`)
1152
+ lines.push(closeReturn)
1040
1153
  lines.push(` }`)
1041
1154
 
1042
1155
  // Handle the alternate (else branch)
@@ -1049,9 +1162,15 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
1049
1162
  } else {
1050
1163
  // Final else branch with regular JSX
1051
1164
  const alternate = this.renderNode(ifStmt.alternate, ctx)
1052
- lines.push(` return (`)
1165
+ lines.push(wrap ? ' return wrapWithInlineScripts((' : ' return (')
1053
1166
  lines.push(` ${alternate}`)
1054
- lines.push(` )`)
1167
+ lines.push(
1168
+ wrap
1169
+ ? wrapPreload
1170
+ ? ' ), __bfInlineScripts, __bfInlinePreloads)'
1171
+ : ' ), __bfInlineScripts)'
1172
+ : ' )',
1173
+ )
1055
1174
  }
1056
1175
  } else {
1057
1176
  // No alternate - return null
package/src/app.ts CHANGED
@@ -9,10 +9,9 @@
9
9
  *
10
10
  * Two pieces:
11
11
  *
12
- * - **JSX components** (`<BfImportMap />`, `<BfScripts />`,
13
- * `<BfDevReload />`) — return raw HTML the caller composes inside
14
- * the Layout passed to Hono's `jsxRenderer`. All URL/data inputs
15
- * are required props.
12
+ * - **JSX components** (`<BfScripts />`, `<BfDevReload />`) — return
13
+ * raw HTML the caller composes inside the Layout passed to Hono's
14
+ * `jsxRenderer`. All URL/data inputs are required props.
16
15
  *
17
16
  * - **Middleware** (`barefootDevReload`) — registers the SSE endpoint
18
17
  * paired with `<BfDevReload />`. Both `endpoint` and `enabled` are
@@ -30,9 +29,6 @@ import type { MiddlewareHandler } from 'hono'
30
29
  import { html, raw } from 'hono/html'
31
30
  import type { HtmlEscapedString } from 'hono/utils/html'
32
31
  import { useRequestContext } from 'hono/jsx-renderer'
33
- // Zero-dependency subpath — keeps the compiler (and its `typescript` dep) out
34
- // of this runtime/Workers-bundled module while sharing one importmap renderer.
35
- import { renderImportMapHtml, type ImportMapManifest } from '@barefootjs/jsx/import-map'
36
32
  import { createDevReloader } from './dev-worker.ts'
37
33
  // Side-effect import: auto-wires request-scoped `searchParams()` for SSR so any
38
34
  // Hono app rendering the BarefootJS scripts gets it without an opt-in step.
@@ -43,10 +39,10 @@ const DEV_RELOAD_ENDPOINT_KEY = 'bfDevReloadEndpoint'
43
39
  // ── helpers ────────────────────────────────────────────────────────────────
44
40
 
45
41
  /**
46
- * Build manifest shape produced by `bf build`. Each compiled
47
- * component is keyed by its manifest name; `__barefoot__` is the
48
- * runtime entry. `clientJs` is a path under `dist/`, e.g.
49
- * `"components/Counter.client.js"`.
42
+ * Build manifest shape read by `manifestToScriptUrls` (and `BfScripts`
43
+ * below) to emit per-component `<script>` tags. Each compiled component
44
+ * is keyed by its manifest name; `__barefoot__` is the runtime entry.
45
+ * `clientJs` is a path under `dist/`, e.g. `"components/Counter.client.js"`.
50
46
  *
51
47
  * `stubDeps` lists the manifest keys of every `'use client'` sibling
52
48
  * this bundle reaches via a stub rewrite (i.e. via an imperative
@@ -59,8 +55,7 @@ const DEV_RELOAD_ENDPOINT_KEY = 'bfDevReloadEndpoint'
59
55
  * `ui/button/index.tsx`), not the runtime registry name passed to
60
56
  * `createComponent(...)` (e.g. `"Button"`). For top-level
61
57
  * single-component files the two coincide; for nested layouts they
62
- * differ. `build.ts` does the path → manifest-key conversion before
63
- * writing this field.
58
+ * differ.
64
59
  */
65
60
  export interface BarefootBuildManifest {
66
61
  __barefoot__?: { clientJs?: string }
@@ -94,62 +89,6 @@ export function relPathFromComponentsBase(p: string): string {
94
89
 
95
90
  // ── JSX components ─────────────────────────────────────────────────────────
96
91
 
97
- export interface BfImportMapProps {
98
- /** Base URL where the runtime + component bundles are served. */
99
- base: string
100
- /**
101
- * Contents of `barefoot-externals.json` (import it and pass it
102
- * through). Its `importmap.imports` are merged on top of the
103
- * built-in `@barefootjs/client*` mappings so islands importing
104
- * configured externals (e.g. `zod`, `@barefootjs/form`) resolve in
105
- * the browser. When omitted, only the `@barefootjs/client*`
106
- * mappings are emitted — the pre-#1639 behavior.
107
- *
108
- * Typed with the shared {@link ImportMapManifest} from `@barefootjs/jsx`,
109
- * so the component and the `bf build` snippet path describe the manifest
110
- * with one type.
111
- */
112
- externals?: ImportMapManifest
113
- /**
114
- * Whether to also emit `<link rel="modulepreload">` for the
115
- * manifest's `preloads`. Defaults to `true`; set `false` to emit
116
- * the importmap only.
117
- */
118
- preload?: boolean
119
- }
120
-
121
- /**
122
- * Emits the `<script type="importmap">` that maps the bare
123
- * `@barefootjs/client` / `@barefootjs/client/runtime` specifiers to
124
- * the runtime bundle, plus any externals from `barefoot-externals.json`
125
- * passed via the `externals` prop. Also emits `<link rel="modulepreload">`
126
- * for the manifest's `preloads` unless `preload` is `false`. Place in
127
- * `<head>`.
128
- *
129
- * The merge of the `@barefootjs/client*` defaults (synthesized from `base`
130
- * for prop-less / hand-written manifests) is Hono-specific; the actual HTML
131
- * rendering — importmap JSON escaping, `<link rel="modulepreload">`
132
- * emission with `crossorigin` (#1648) — is delegated to the shared
133
- * `renderImportMapHtml` so this path can never drift from the static
134
- * `barefoot-importmap.html` snippet `bf build` emits for template-string
135
- * adapters (#1644). Imported from the `@barefootjs/jsx/import-map` subpath,
136
- * a zero-dependency module, to keep this runtime file free of the compiler.
137
- */
138
- export function BfImportMap(props: BfImportMapProps): HtmlEscapedString | Promise<HtmlEscapedString> {
139
- const base = props.base.replace(/\/$/, '')
140
- // Built-in defaults first, then manifest imports so a configured
141
- // `@barefootjs/client` mapping (emitted by `bf build` against the
142
- // build's `externalsBasePath`) wins over the prop-derived one.
143
- const imports: Record<string, string> = {
144
- '@barefootjs/client': `${base}/barefoot.js`,
145
- '@barefootjs/client/runtime': `${base}/barefoot.js`,
146
- ...(props.externals?.importmap?.imports ?? {}),
147
- }
148
- const preloads = props.preload === false ? [] : props.externals?.preloads ?? []
149
-
150
- return html`${raw(renderImportMapHtml({ importmap: { imports }, preloads }))}`
151
- }
152
-
153
92
  export interface BfScriptsProps {
154
93
  /** Base URL where the runtime + component bundles are served. */
155
94
  base: string
@@ -168,9 +107,9 @@ let __bfEmptyManifestWarned = false
168
107
  * manifest, runtime first. Place at the end of `<body>`.
169
108
  *
170
109
  * Logs a one-time warning when the manifest is empty — a strong
171
- * signal the user is running the server before `bf build` has
172
- * produced anything, which would otherwise present as a silent
173
- * "page renders but nothing is interactive."
110
+ * signal the user is running the server before a build has produced
111
+ * anything, which would otherwise present as a silent "page renders
112
+ * but nothing is interactive."
174
113
  */
175
114
  export function BfScripts(props: BfScriptsProps): HtmlEscapedString | Promise<HtmlEscapedString> {
176
115
  const urls = manifestToScriptUrls(props.manifest, props.base)
@@ -178,7 +117,7 @@ export function BfScripts(props: BfScriptsProps): HtmlEscapedString | Promise<Ht
178
117
  __bfEmptyManifestWarned = true
179
118
  console.warn(
180
119
  '[barefootjs] BfScripts: manifest is empty — no <script> tags emitted. ' +
181
- 'Run `bf build` to compile components and rebuild the manifest.',
120
+ 'Run `vite build` to compile components and rebuild the manifest.',
182
121
  )
183
122
  }
184
123
  const tags = urls
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@ export { conformancePins } from './conformance-pins.ts'
11
11
 
12
12
  // BfScripts is exported from a separate entry point to avoid JSX runtime issues in tests
13
13
  // Usage: import { BfScripts } from '@barefootjs/hono/scripts'
14
- export type { CollectedScript } from './scripts.tsx'
14
+ export type { CollectedScript, CollectedPreload } from './scripts.tsx'
15
15
 
16
16
  // Portal components for SSR
17
17
  // Usage: import { BfPortals, Portal } from '@barefootjs/hono/portals'
package/src/preload.tsx CHANGED
@@ -40,6 +40,12 @@ export interface ManifestEntry {
40
40
  markedTemplate: string
41
41
  clientJs?: string
42
42
  props?: Array<{ name: string; type: string; optional: boolean }>
43
+ /**
44
+ * Dependency component names for recursive preloading.
45
+ * Note: no current producer (neither legacy site build nor @barefootjs/vite)
46
+ * emits this field, so dependency recursion only activates for
47
+ * hand-authored manifests. Kept for API compatibility.
48
+ */
43
49
  dependencies?: string[]
44
50
  }
45
51
 
@@ -84,7 +90,11 @@ export interface BfPreloadProps {
84
90
  * Resolves the full dependency chain for given components.
85
91
  * Uses a visited set to prevent infinite loops from circular dependencies.
86
92
  *
87
- * @param components - Component names to resolve
93
+ * Manifest keys are whatever the manifest producer used — the legacy site
94
+ * build uses path-qualified keys like `ui/button`, so component name entries
95
+ * must match those exactly. A key mismatch is silently skipped (no error).
96
+ *
97
+ * @param components - Component names/keys to resolve (must match manifest keys exactly)
88
98
  * @param manifest - Component manifest with dependency information
89
99
  * @param visited - Set of already visited component names (for cycle detection)
90
100
  * @returns Array of clientJs paths for all dependencies
@@ -126,7 +136,10 @@ function resolveDependencyChain(
126
136
  * by all BarefootJS components.
127
137
  *
128
138
  * When manifest and components props are provided, automatically
129
- * preloads the full dependency chain for those components.
139
+ * preloads the full dependency chain for those components. The `components`
140
+ * array entries must match manifest keys exactly — for the legacy site
141
+ * build, use path-qualified keys like `ui/button`, not component names.
142
+ * A mismatched key is silently skipped.
130
143
  */
131
144
  export function BfPreload({
132
145
  staticPath = '/static',
package/src/render.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * `<Suspense>` boundaries flush out-of-order.
16
16
  *
17
17
  * Both accept a `hono/jsx` node (typically a full page including the
18
- * layout shell, `<BfImportMap>`, and `<BfScripts>`).
18
+ * layout shell and `<BfScripts>`).
19
19
  */
20
20
 
21
21
  import { renderToReadableStream } from 'hono/jsx/streaming'
package/src/scripts.tsx CHANGED
@@ -46,6 +46,10 @@ export type CollectedScript = {
46
46
  src: string
47
47
  }
48
48
 
49
+ export type CollectedPreload = {
50
+ href: string
51
+ }
52
+
49
53
  export interface BfScriptsProps {
50
54
  /**
51
55
  * Build manifest from `dist/components/manifest.json`. When supplied
@@ -103,6 +107,7 @@ export function BfScripts(props: BfScriptsProps = {}) {
103
107
  c.set('bfScriptsRendered', true)
104
108
 
105
109
  const scripts: CollectedScript[] = c.get('bfCollectedScripts') || []
110
+ const preloads: CollectedPreload[] = c.get('bfCollectedPreloads') || []
106
111
  const outputSet: Set<string> = c.get('bfOutputScripts') || new Set()
107
112
  const { manifest, base, entryRoots } = props
108
113
  // `entryRoots` extends both the walk-root set AND the `excluded` set.
@@ -146,8 +151,16 @@ export function BfScripts(props: BfScriptsProps = {}) {
146
151
  ...componentScripts.reverse(),
147
152
  ]
148
153
 
154
+ // Preloads carry no execution-order constraint the way scripts do (they
155
+ // are just hints, not code that runs) — insertion order is emitted as-
156
+ // is, ahead of every `<script type="module">` tag, so the browser sees
157
+ // the hint before it would otherwise discover the same chunk as an
158
+ // import of one of those scripts.
149
159
  return (
150
160
  <Fragment>
161
+ {preloads.map(({ href }) => (
162
+ <link rel="modulepreload" crossorigin="" href={href} />
163
+ ))}
151
164
  {finalScripts.map(({ src }) => (
152
165
  <script type="module" src={src} />
153
166
  ))}
@@ -159,6 +172,129 @@ export function BfScripts(props: BfScriptsProps = {}) {
159
172
  }
160
173
  }
161
174
 
175
+ /**
176
+ * Register `urls` (a component's resolved script URLs for THIS render) into
177
+ * the same request-context collector `BfScripts` reads, and return the
178
+ * subset that must be rendered INLINE right here instead — see
179
+ * `BfScripts`'s docstring on `bfScriptsRendered` (a component rendering
180
+ * after `<BfScripts />` has already run, e.g. inside a Suspense boundary,
181
+ * can't rely on the end-of-body collector; its scripts have to ship inline
182
+ * at the point of render for streaming to deliver them at all).
183
+ *
184
+ * `HonoAdapter.generate()` calls this directly from CODEGEN — one call per
185
+ * rendered component, with `urls` baked in as
186
+ * `AdapterGenerateOptions.scriptAssets` (the Vite plugin's already-resolved,
187
+ * manifest-hashed or dev-origin URL list). Two properties follow from that:
188
+ *
189
+ * - No separate `barefoot.js` runtime registration: `scriptAssets` under
190
+ * Vite is just the component's own bundled entry (see
191
+ * `@barefootjs/vite`'s `resolveScriptAssets` docstring) — the
192
+ * `@barefootjs/client` runtime arrives as a shared ESM chunk that entry
193
+ * imports, which the browser follows on its own with no extra
194
+ * `<script src>` needed. Go's `.Scripts.Register` calls (generated by
195
+ * `GoTemplateAdapter`) make exactly the same simplification for the same
196
+ * reason.
197
+ * - Dedup keys on the URL itself (there is no separate `componentId` to key
198
+ * by): rendering the SAME component N times (e.g. inside a `.map()`) or
199
+ * two different components that happen to share a chunk both correctly
200
+ * collapse to one `<script>` tag.
201
+ *
202
+ * Swallows a missing request context (no `jsxRenderer` in the render path),
203
+ * returning `[]` — SSR still renders, just without hydration scripts.
204
+ */
205
+ export function registerComponentScripts(urls: string[]): string[] {
206
+ try {
207
+ const c = useRequestContext()
208
+ const scripts: CollectedScript[] = c.get('bfCollectedScripts') || []
209
+ const outputScripts: Set<string> = c.get('bfOutputScripts') || new Set()
210
+ const rendered = c.get('bfScriptsRendered')
211
+ const inline: string[] = []
212
+ for (const src of urls) {
213
+ if (outputScripts.has(src)) continue
214
+ outputScripts.add(src)
215
+ if (rendered) inline.push(src)
216
+ else scripts.push({ src })
217
+ }
218
+ c.set('bfCollectedScripts', scripts)
219
+ c.set('bfOutputScripts', outputScripts)
220
+ return inline
221
+ } catch {
222
+ return []
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Register `urls` (a component's resolved TRANSITIVE-chunk preload URLs for
228
+ * THIS render, per `AdapterGenerateOptions.preloadAssets`) into the same
229
+ * request-context collector `BfScripts` reads, and return the subset that
230
+ * must be rendered INLINE right here instead — same
231
+ * already-rendered-`<BfScripts />` escape hatch as `registerComponentScripts`
232
+ * (see that function's docstring), and the same reasons apply: a component
233
+ * whose SSR happens inside a Suspense boundary that renders after
234
+ * `<BfScripts />` has already flushed can't rely on the end-of-body
235
+ * collector.
236
+ *
237
+ * Kept as a SEPARATE collector (`bfCollectedPreloads` / `bfOutputPreloads`)
238
+ * from `registerComponentScripts`'s `bfCollectedScripts` /
239
+ * `bfOutputScripts` — a preload URL and a script URL are never the same
240
+ * concern (preloads are the entry's TRANSITIVE deps, never the entry
241
+ * itself — see `AdapterGenerateOptions.preloadAssets`), so there is no
242
+ * cross-dedup to do between the two sets; keeping them separate avoids
243
+ * conflating "this was preloaded" with "this was registered as a script".
244
+ *
245
+ * Dedup keys on the URL itself, same as `registerComponentScripts`:
246
+ * rendering the SAME component N times (e.g. inside a `.map()`), or two
247
+ * different components that happen to share a transitive chunk, both
248
+ * correctly collapse to one `<link rel="modulepreload">` tag.
249
+ *
250
+ * Swallows a missing request context, returning `[]` — SSR still renders,
251
+ * just without preload hints.
252
+ */
253
+ export function registerComponentPreloads(urls: string[]): string[] {
254
+ try {
255
+ const c = useRequestContext()
256
+ const preloads: CollectedPreload[] = c.get('bfCollectedPreloads') || []
257
+ const outputPreloads: Set<string> = c.get('bfOutputPreloads') || new Set()
258
+ const rendered = c.get('bfScriptsRendered')
259
+ const inline: string[] = []
260
+ for (const href of urls) {
261
+ if (outputPreloads.has(href)) continue
262
+ outputPreloads.add(href)
263
+ if (rendered) inline.push(href)
264
+ else preloads.push({ href })
265
+ }
266
+ c.set('bfCollectedPreloads', preloads)
267
+ c.set('bfOutputPreloads', outputPreloads)
268
+ return inline
269
+ } catch {
270
+ return []
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Wrap `jsx` with a leading `<link rel="modulepreload">` per entry in
276
+ * `inlinePreloads` and a trailing `<script type="module">` per entry in
277
+ * `inlineScripts` (the return values of `registerComponentPreloads` and
278
+ * `registerComponentScripts` respectively) — a single shared function
279
+ * every generated component imports, rather than per-file duplicated
280
+ * inline-asset logic. Returns `jsx` unchanged when both are empty (the
281
+ * common case: collected, not inline).
282
+ *
283
+ * `inlinePreloads` defaults to `[]` so existing generated call sites that
284
+ * only ever pass `inlineScripts` (a component with `scriptAssets` but no
285
+ * `preloadAssets`) keep compiling and behaving exactly as before.
286
+ */
287
+ export function wrapWithInlineScripts(jsx: unknown, inlineScripts: string[], inlinePreloads: string[] = []) {
288
+ if (inlineScripts.length === 0 && inlinePreloads.length === 0) return jsx
289
+ return (
290
+ <Fragment>
291
+ {inlinePreloads.map(href => <link rel="modulepreload" crossorigin="" href={href} />)}
292
+ {jsx as never}
293
+ {inlineScripts.map(src => <script type="module" src={src} />)}
294
+ </Fragment>
295
+ )
296
+ }
297
+
162
298
  /**
163
299
  * Walk stub-rewrite edges from each manifest entry in `roots`,
164
300
  * returning the script URLs for every transitively reachable