@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,1114 @@
1
+ /**
2
+ * BarefootJS Hono Adapter
3
+ *
4
+ * Generates Hono JSX from Pure IR.
5
+ */
6
+
7
+ import {
8
+ type ComponentIR,
9
+ type IRNode,
10
+ type IRElement,
11
+ type IRText,
12
+ type IRExpression,
13
+ type IRConditional,
14
+ type IRLoop,
15
+ type IRComponent,
16
+ type IRFragment,
17
+ type IRIfStatement,
18
+ type IRProvider,
19
+ type IRAsync,
20
+ type IRSlot,
21
+ type AttrValue,
22
+ type IRTemplatePart,
23
+ type ParamInfo,
24
+ type AdapterGenerateOptions,
25
+ type AdapterOutput,
26
+ type TemplateSections,
27
+ type JsxAdapterConfig,
28
+ type IRNodeEmitter,
29
+ type EmitIRNode,
30
+ type AttrValueEmitter,
31
+ JsxAdapter,
32
+ isBooleanAttr,
33
+ rewriteImportsForTemplate,
34
+ emitIRNode,
35
+ emitAttrValue,
36
+ buildLoopChainExpr,
37
+ } from '@barefootjs/jsx'
38
+
39
+ /**
40
+ * Hono adapter's IRNode render context: which surrounding render
41
+ * state matters when lowering a node. The `IRNodeEmitter` dispatcher
42
+ * threads this `Ctx` unchanged into per-kind methods; per-method
43
+ * documentation below records which flags each kind consults.
44
+ */
45
+ type HonoRenderCtx = {
46
+ isRootOfClientComponent?: boolean
47
+ isInsideLoop?: boolean
48
+ isLoopItemRoot?: boolean
49
+ }
50
+ import { BF_SCOPE, BF_HOST, BF_AT, BF_ROOT, BF_PROPS } from '@barefootjs/shared'
51
+
52
+ export interface HonoAdapterOptions {
53
+ /**
54
+ * Base path for client JS files (e.g., '/static/components/')
55
+ * Used to generate script src attributes.
56
+ */
57
+ clientJsBasePath?: string
58
+
59
+ /**
60
+ * Path to barefoot.js runtime (e.g., '/static/components/barefoot.js')
61
+ */
62
+ barefootJsPath?: string
63
+
64
+ /**
65
+ * Client JS filename (without path). When set, all components use this filename.
66
+ * When not set, uses `{componentName}.client.js`.
67
+ * Useful for files with multiple components that share a single client JS file.
68
+ */
69
+ clientJsFilename?: string
70
+
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.
77
+ */
78
+ name?: string
79
+ }
80
+
81
+ /**
82
+ * Mirror `IRLoop.sortComparator` / `IRLoop.filterPredicate` chaining
83
+ * into the JSX expression that backs the Hono `.map()` call.
84
+ * Delegates to the shared `buildLoopChainExpr` so the chain shape
85
+ * stays byte-equal with the client-template emit
86
+ * (`html-template.ts:applyLoopChain`) and the control-flow plans
87
+ * (`utils.ts:buildChainedArrayExpr`) — drift between the three
88
+ * would silently produce different sorted orders depending on
89
+ * which path consumed the IR. Always uses `.toSorted`
90
+ * (non-mutating) so shared prop arrays aren't reordered in place
91
+ * across renders.
92
+ */
93
+ function applyHonoLoopChain(loop: IRLoop): string {
94
+ return buildLoopChainExpr({
95
+ base: loop.array,
96
+ sortComparator: loop.sortComparator,
97
+ filterPredicate: loop.filterPredicate,
98
+ chainOrder: loop.chainOrder,
99
+ })
100
+ }
101
+
102
+ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderCtx> {
103
+ name = 'hono'
104
+ extension = '.tsx'
105
+ clientShimSource = '@barefootjs/hono/client-shim'
106
+
107
+ // The Hono SSR runtime is JavaScript (Node / Bun / CF Workers), so any
108
+ // synchronous JS call the user writes can be rendered as-is at template
109
+ // scope — there is no language-level subset to enumerate. Broad
110
+ // acceptance is the contract.
111
+ //
112
+ // What this delegates to the user: Hono accepts the call; whether that
113
+ // call actually works at SSR is the user's responsibility. A function
114
+ // that touches `window` / `document` / `localStorage` will throw a clear
115
+ // ReferenceError at build time rather than silently rendering as
116
+ // `undefined`. The fix is to wrap the offending JSX expression with
117
+ // `/* @client */` so the call is deferred to hydrate.
118
+ //
119
+ // Component-internal bindings (signals, memos, init-locals, destructured
120
+ // props) are still correctly rejected by the shadow guard inside
121
+ // `isCallAcceptedByAdapter` (relocate.ts), regardless of what this
122
+ // predicate returns — those identifiers carry a non-`global`/`module-*`
123
+ // BindingKind, so the guard short-circuits before the predicate runs.
124
+ acceptsTemplateCall = (): boolean => true
125
+
126
+ protected jsxConfig: JsxAdapterConfig = { preserveTypes: true }
127
+
128
+ private options: HonoAdapterOptions
129
+ private isClientComponent: boolean = false
130
+ private hasClientInteractivity: boolean = false
131
+ private currentComponentHasProps: boolean = false
132
+ /**
133
+ * Per-call relative-import rewriter supplied by the build pipeline so
134
+ * source-authored relative paths resolve correctly from the emitted
135
+ * file's on-disk position (#1453). Stashed for the duration of one
136
+ * `generate()` call so `generateImports` can apply it; cleared on exit
137
+ * so a singleton adapter instance does not leak state between
138
+ * components.
139
+ */
140
+ private rewriteRelativeImport?: (importPath: string) => string
141
+ /** Stack of loop keys for generating data-key / data-key-1 attributes on loop items */
142
+ private loopKeyStack: Array<{ key: string | null; param: string }> = []
143
+
144
+ constructor(options: HonoAdapterOptions = {}) {
145
+ super()
146
+ this.options = {
147
+ clientJsBasePath: options.clientJsBasePath ?? '/static/components/',
148
+ barefootJsPath: options.barefootJsPath ?? '/static/components/barefoot.js',
149
+ clientJsFilename: options.clientJsFilename,
150
+ }
151
+ if (options.name) this.name = options.name
152
+ }
153
+
154
+ generate(ir: ComponentIR, options?: AdapterGenerateOptions): AdapterOutput {
155
+ this.componentName = ir.metadata.componentName
156
+ this.isClientComponent = ir.metadata.isClientComponent
157
+ this.rewriteRelativeImport = options?.rewriteRelativeImport
158
+
159
+ // Generate component body FIRST so we can scan it for used imports
160
+ const component = this.generateComponent(ir)
161
+ const types = this.generateTypes(ir, component)
162
+ const componentCode = [types, component].filter(Boolean).join('\n')
163
+ const imports = this.generateImports(ir, componentCode)
164
+ // Module-level Context bindings (`const Ctx = createContext()`) are
165
+ // skipped from the SSR signal-initializer block by JsxAdapter — they
166
+ // need to live at module scope so providers and consumers in the same
167
+ // render share the same Context object identity. Emitted in a dedicated
168
+ // section so multi-component dedup works on the full block (not per
169
+ // line, which would split multi-line `({...})` arguments).
170
+ const moduleConstants = this.generateModuleLevelContextBindings(ir)
171
+
172
+ const defaultExport = ir.metadata.hasDefaultExport
173
+ ? `\nexport default ${this.componentName}`
174
+ : ''
175
+
176
+ const sections: TemplateSections = {
177
+ imports,
178
+ types: types || '',
179
+ component,
180
+ defaultExport,
181
+ moduleConstants,
182
+ }
183
+
184
+ // Assemble template for backward compat (external consumers using output.template)
185
+ const template = [imports, moduleConstants, types, component].filter(Boolean).join('\n\n') + defaultExport
186
+
187
+ const result: AdapterOutput = {
188
+ template,
189
+ sections,
190
+ types: types || undefined,
191
+ extension: this.extension,
192
+ }
193
+ this.rewriteRelativeImport = undefined
194
+ return result
195
+ }
196
+
197
+ private generateModuleLevelContextBindings(ir: ComponentIR): string {
198
+ const lines: string[] = []
199
+ for (const c of ir.metadata.localConstants) {
200
+ if (!c.isModule) continue
201
+ if (c.isExported) continue
202
+ if (c.systemConstructKind !== 'createContext') continue
203
+ if (!c.value) continue
204
+ const keyword = c.declarationKind ?? 'const'
205
+ const value = this.jsxConfig.preserveTypes ? (c.typedValue ?? c.value) : c.value
206
+ lines.push(`${keyword} ${c.name} = ${value}`)
207
+ }
208
+ return lines.join('\n')
209
+ }
210
+
211
+ // ===========================================================================
212
+ // Imports Generation
213
+ // ===========================================================================
214
+
215
+ private generateImports(ir: ComponentIR, componentCode: string): string {
216
+ const lines: string[] = []
217
+
218
+ // Only import bfComment/bfText/bfTextEnd utilities that are actually used
219
+ const utilImports: string[] = []
220
+ for (const util of ['bfComment', 'bfText', 'bfTextEnd']) {
221
+ if (new RegExp(`\\b${util}\\b`).test(componentCode)) {
222
+ utilImports.push(util)
223
+ }
224
+ }
225
+ if (utilImports.length > 0) {
226
+ lines.push(`import { ${utilImports.join(', ')} } from '@barefootjs/hono/utils'`)
227
+ }
228
+
229
+ // Import Suspense when async boundaries are used
230
+ if (componentCode.includes('<Suspense')) {
231
+ lines.push(`import { Suspense } from 'hono/jsx/streaming'`)
232
+ }
233
+
234
+ // Re-emit template imports, rewriting `@barefootjs/client` to this
235
+ // adapter's SSR shim AND re-anchoring relative paths from the emit
236
+ // location when the caller supplied a `rewriteRelativeImport` hook
237
+ // (#1453). Adapters own both rewrites; the compiler hands us the
238
+ // raw import list.
239
+ const templateImports = rewriteImportsForTemplate(
240
+ ir.metadata.templateImports,
241
+ this.clientShimSource,
242
+ this.rewriteRelativeImport,
243
+ )
244
+ for (const imp of templateImports) {
245
+ if (imp.specifiers.length === 0) {
246
+ if (!imp.isTypeOnly) {
247
+ lines.push(`import '${imp.source}'`)
248
+ }
249
+ continue
250
+ }
251
+ if (imp.isTypeOnly) {
252
+ lines.push(`import type ${this.formatImportSpecifiers(imp.specifiers)} from '${imp.source}'`)
253
+ } else {
254
+ lines.push(`import ${this.formatImportSpecifiers(imp.specifiers)} from '${imp.source}'`)
255
+ }
256
+ }
257
+
258
+ // Provider IR rendering emits `provideContextSSR(...)` calls. Emit the
259
+ // import on its own line so multi-component files dedupe it cleanly via
260
+ // the compiler's per-line import merging.
261
+ if (/\bprovideContextSSR\(/.test(componentCode)) {
262
+ lines.push(`import { provideContextSSR } from '@barefootjs/hono/client-shim'`)
263
+ }
264
+
265
+ return lines.join('\n')
266
+ }
267
+
268
+ // ===========================================================================
269
+ // Types Generation
270
+ // ===========================================================================
271
+
272
+ generateTypes(ir: ComponentIR, componentBody?: string): string | null {
273
+ const lines: string[] = []
274
+
275
+ // Include original type definitions — only those referenced in the component body
276
+ // or transitively referenced by other included type definitions
277
+ if (componentBody && ir.metadata.typeDefinitions.length > 0) {
278
+ const propsTypeName = this.getPropsTypeName(ir)
279
+ // Seed the reachability scan with everything that ends up referencing
280
+ // a type name in the FINAL emitted file, not just the component body.
281
+ //
282
+ // - `propsTypeName` is referenced by the synthesized
283
+ // `${Name}PropsWithHydration = ${propsTypeName} & {...}` alias the
284
+ // destructured-props branch emits below — but that alias is built
285
+ // AFTER this scan, so the body never literally mentions e.g.
286
+ // `ButtonProps`. Without seeding it here the alias references an
287
+ // undeclared name (TS2304) and TS widens `variant`/`size` to `any`
288
+ // at every `Record[variant]` lookup site (TS7053) downstream.
289
+ //
290
+ // - Named re-export blocks (`export type { ButtonVariant, ButtonSize,
291
+ // ButtonProps }`) are emitted by the compiler's `generateModuleExports`
292
+ // AFTER `s.types`. Each re-exported local name needs its declaration
293
+ // carried forward too. Issue #1453 covers the full reproduction.
294
+ const seedText = [
295
+ componentBody,
296
+ propsTypeName && !ir.metadata.propsObjectName ? propsTypeName : '',
297
+ ...ir.metadata.namedExports
298
+ .filter((block) => block.source === null)
299
+ .flatMap((block) => block.specifiers.map((s) => s.name)),
300
+ ].filter(Boolean).join('\n')
301
+
302
+ const included = new Set<string>()
303
+ // First pass: include types directly referenced in the seed text
304
+ for (const typeDef of ir.metadata.typeDefinitions) {
305
+ if (new RegExp(`\\b${typeDef.name}\\b`).test(seedText)) {
306
+ included.add(typeDef.name)
307
+ }
308
+ }
309
+ // Transitive pass: include types referenced by already-included types
310
+ let changed = true
311
+ while (changed) {
312
+ changed = false
313
+ for (const typeDef of ir.metadata.typeDefinitions) {
314
+ if (included.has(typeDef.name)) continue
315
+ for (const name of included) {
316
+ const includedDef = ir.metadata.typeDefinitions.find(t => t.name === name)
317
+ if (includedDef && new RegExp(`\\b${typeDef.name}\\b`).test(includedDef.definition)) {
318
+ included.add(typeDef.name)
319
+ changed = true
320
+ break
321
+ }
322
+ }
323
+ }
324
+ }
325
+ for (const typeDef of ir.metadata.typeDefinitions) {
326
+ if (included.has(typeDef.name)) lines.push(typeDef.definition)
327
+ }
328
+ } else {
329
+ for (const typeDef of ir.metadata.typeDefinitions) {
330
+ lines.push(typeDef.definition)
331
+ }
332
+ }
333
+
334
+ // Generate hydration props type (only when destructured-props pattern uses it;
335
+ // SolidJS-style props use inline type annotation instead)
336
+ const propsTypeName = this.getPropsTypeName(ir)
337
+ if (propsTypeName && !ir.metadata.propsObjectName) {
338
+ lines.push('')
339
+ lines.push(`type ${this.componentName}PropsWithHydration = ${propsTypeName} & {`)
340
+ lines.push(' __instanceId?: string')
341
+ lines.push(' __bfScope?: string')
342
+ lines.push(' __bfChild?: boolean')
343
+ lines.push(' __bfParentProps?: string')
344
+ lines.push(' __bfParent?: string')
345
+ lines.push(' __bfMount?: string')
346
+ lines.push(' "data-key"?: string | number')
347
+ lines.push('}')
348
+ }
349
+
350
+ return lines.length > 0 ? lines.join('\n') : null
351
+ }
352
+
353
+ private getPropsTypeName(ir: ComponentIR): string | null {
354
+ if (ir.metadata.propsType?.raw) {
355
+ return ir.metadata.propsType.raw
356
+ }
357
+ return null
358
+ }
359
+
360
+ // ===========================================================================
361
+ // Component Generation
362
+ // ===========================================================================
363
+
364
+ private generateComponent(ir: ComponentIR): string {
365
+ const name = ir.metadata.componentName
366
+ const propsTypeName = this.getPropsTypeName(ir)
367
+
368
+ // Validate: only reactive primitives (signals, memos, effects, onMounts) require "use client"
369
+ const hasReactivePrimitives =
370
+ ir.metadata.signals.length > 0 ||
371
+ ir.metadata.memos.length > 0 ||
372
+ ir.metadata.effects.length > 0 ||
373
+ ir.metadata.onMounts.length > 0
374
+
375
+ if (hasReactivePrimitives && !ir.metadata.isClientComponent) {
376
+ throw new Error(
377
+ `Component "${name}" has reactive primitives (signals, memos, effects, or onMounts) ` +
378
+ `but is not marked as a client component. Add "use client" directive at the top of the file.`
379
+ )
380
+ }
381
+
382
+ // A component needs client interactivity if it has "use client" OR if it has event handlers
383
+ // that need client JS wiring (detected by analyzeClientNeeds)
384
+ const needsClientInit = ir.metadata.clientAnalysis?.needsInit ?? false
385
+ const hasClientInteractivity = ir.metadata.isClientComponent || needsClientInit
386
+ this.hasClientInteractivity = hasClientInteractivity
387
+
388
+ // Check if component uses props object pattern (SolidJS-style)
389
+ const propsObjectName = ir.metadata.propsObjectName
390
+
391
+ // Build props parameter based on pattern
392
+ let fullPropsDestructure: string
393
+ let typeAnnotation: string
394
+ let propsExtraction: string | null = null
395
+
396
+ // Synthetic hydration-only props the generated wrapper destructures
397
+ // out of `props` before reaching the user's body. Kept as a shared
398
+ // constant so the `propsObjectName` (SolidJS-style) and destructured
399
+ // branches both list every hydration field — the destructured
400
+ // branch's fallback used to declare only `__instanceId / __bfScope
401
+ // / __bfChild`, but the generated body destructures `__bfParent /
402
+ // __bfMount / __bfParentProps / data-key` too, so tsc raised
403
+ // TS2339 ("Property '__bfParent' does not exist...") on every
404
+ // emitted SSR template for a component without an explicit Props
405
+ // type. See onboarding round 5 / PR #1450.
406
+ const HYDRATION_PROPS_TYPE =
407
+ '{ __instanceId?: string; __bfScope?: string; __bfChild?: boolean; __bfParentProps?: string; __bfParent?: string; __bfMount?: string; "data-key"?: string | number }'
408
+
409
+ if (propsObjectName) {
410
+ // SolidJS-style: function Component(props: Props)
411
+ // Accept all props as a single object, then destructure hydration props out
412
+ fullPropsDestructure = `__allProps`
413
+ typeAnnotation = propsTypeName
414
+ ? `: ${propsTypeName} & ${HYDRATION_PROPS_TYPE}`
415
+ : `: Record<string, unknown> & ${HYDRATION_PROPS_TYPE}`
416
+ // propsExtraction is rebuilt after jsxBody generation with unused-aware aliases
417
+ } else {
418
+ // Destructured props pattern — fullPropsDestructure rebuilt after jsxBody with unused-aware aliases
419
+ fullPropsDestructure = '' // placeholder, rebuilt below
420
+ typeAnnotation = propsTypeName
421
+ ? `: ${name}PropsWithHydration`
422
+ : `: ${HYDRATION_PROPS_TYPE}`
423
+ }
424
+
425
+ // Generate props serialization for hydration (for components with props)
426
+ // Only serialize props that the client JS init function actually reads
427
+ const clientUsedProps = new Set(ir.metadata.clientAnalysis?.usedProps ?? [])
428
+ const needsInit = ir.metadata.clientAnalysis?.needsInit ?? false
429
+ const propsToSerialize = ir.metadata.propsParams.filter(p => {
430
+ // Skip function props and internal props
431
+ return !p.name.startsWith('on') && !p.name.startsWith('__') && clientUsedProps.has(p.name)
432
+ })
433
+ const hasPropsToSerialize = propsToSerialize.length > 0 && hasClientInteractivity && needsInit
434
+
435
+ // Check if root is an if-statement (early return pattern)
436
+ const isIfStatement = ir.root.type === 'if-statement'
437
+
438
+ // Generate JSX body (for non-if-statement roots)
439
+ // Pass isRootOfClientComponent flag when the root is a component and this is a client component
440
+ // This ensures the child component receives __instanceId instead of __bfScope
441
+ const isRootComponent = ir.root.type === 'component'
442
+
443
+ // currentComponentHasProps: true when we need to emit bf-p on the root element.
444
+ // This is needed when: (1) the component has its own props to serialize, OR
445
+ // (2) the component's root is a component and it's a client component (namespaced props pass-through)
446
+ this.currentComponentHasProps = hasPropsToSerialize || (hasClientInteractivity && isRootComponent)
447
+ let jsxBody = isIfStatement ? '' : this.renderNode(ir.root, {
448
+ isRootOfClientComponent: hasClientInteractivity && isRootComponent
449
+ })
450
+
451
+ // Component roots of client components need comment-based scope markers.
452
+ // Unlike element roots (which get bf-s directly), the root component is
453
+ // a plain function whose output has no hydration markers.
454
+ if (!isIfStatement && hasClientInteractivity && isRootComponent) {
455
+ jsxBody = this.wrapWithScopeComment(jsxBody)
456
+ }
457
+
458
+ // For if-statement roots, render branches early so they're included in reference analysis
459
+ const ifCode = isIfStatement
460
+ ? this.renderIfStatement(ir.root as IRIfStatement, { isRootOfClientComponent: true })
461
+ : ''
462
+
463
+ // Generate signal initializers with unused-aware prefixing (needs jsxBody for reference analysis)
464
+ const fullBodyText = jsxBody + '\n' + ifCode
465
+ const signalInits = this.generateSignalInitializers(ir, fullBodyText)
466
+
467
+ // Determine which hydration params are actually used in the generated body
468
+ // Include scopeId line content for accurate reference checking
469
+ const scopeIdLine = hasClientInteractivity
470
+ ? `__instanceId`
471
+ : `__bfScope || __instanceId`
472
+ const bodyRefText = [
473
+ fullBodyText,
474
+ signalInits,
475
+ scopeIdLine,
476
+ // Props serialization references __bfParentProps
477
+ (hasPropsToSerialize || (hasClientInteractivity && isRootComponent)) ? '__bfParentProps' : '',
478
+ ].join('\n')
479
+
480
+ // Rebuild hydration props with _ prefix for unused ones
481
+ const bfScopeAlias = /\b__bfScope\b/.test(bodyRefText) ? '__bfScope' : '__bfScope: _bfScope'
482
+ const bfChildAlias = /\b__bfChild\b/.test(bodyRefText) ? '__bfChild' : '__bfChild: _bfChild'
483
+ const bfParentPropsAlias = /\b__bfParentProps\b/.test(bodyRefText) ? '__bfParentProps' : '__bfParentProps: _bfParentProps'
484
+ const bfParentAlias = /\b__bfParent\b/.test(bodyRefText) ? '__bfParent' : '__bfParent: _bfParent'
485
+ const bfMountAlias = /\b__bfMount\b/.test(bodyRefText) ? '__bfMount' : '__bfMount: _bfMount'
486
+ const dataKeyAlias = /\b__dataKey\b/.test(bodyRefText) ? '"data-key": __dataKey' : '"data-key": _dataKey'
487
+
488
+ if (propsObjectName) {
489
+ propsExtraction = ` const { __instanceId, ${bfScopeAlias}, ${bfChildAlias}, ${bfParentPropsAlias}, ${bfParentAlias}, ${bfMountAlias}, ${dataKeyAlias}, ...${propsObjectName} } = __allProps`
490
+ } else {
491
+ const hydrationProps = `__instanceId, ${bfScopeAlias}, ${bfChildAlias}, ${bfParentPropsAlias}, ${bfParentAlias}, ${bfMountAlias}, ${dataKeyAlias}`
492
+ const parts: string[] = []
493
+ const propsParams = ir.metadata.propsParams
494
+ .map((p: ParamInfo) => {
495
+ const paramName = p.name === 'class' ? 'className' : p.name
496
+ return p.defaultValue ? `${paramName} = ${p.defaultValue}` : paramName
497
+ })
498
+ .join(', ')
499
+ if (propsParams) {
500
+ parts.push(propsParams)
501
+ }
502
+ parts.push(hydrationProps)
503
+ const restPropsName = ir.metadata.restPropsName
504
+ if (restPropsName) {
505
+ parts.push(`...${restPropsName}`)
506
+ }
507
+ fullPropsDestructure = `{ ${parts.join(', ')} }`
508
+ }
509
+
510
+ const lines: string[] = []
511
+ // Module-export keyword belongs to the adapter: it knows the target language
512
+ // and whether the source declared the component as exported.
513
+ const exportPrefix = ir.metadata.isExported === false ? '' : 'export '
514
+ lines.push(`${exportPrefix}function ${name}(${fullPropsDestructure}${typeAnnotation}) {`)
515
+
516
+ // Add props extraction for SolidJS-style pattern
517
+ if (propsExtraction) {
518
+ lines.push(propsExtraction)
519
+ }
520
+
521
+ // Generate scope ID
522
+ if (hasClientInteractivity) {
523
+ // Interactive components always generate their own unique ID with component name prefix
524
+ // This ensures client JS query `[bf-s^="ComponentName_"]` matches
525
+ lines.push(` const __scopeId = __instanceId || \`${name}_\${Math.random().toString(36).slice(2, 8)}\``)
526
+ } else {
527
+ // Non-interactive components can inherit parent's scope or use fallback
528
+ lines.push(` const __scopeId = __bfScope || __instanceId || \`${name}_\${Math.random().toString(36).slice(2, 8)}\``)
529
+ }
530
+
531
+ if (signalInits) {
532
+ lines.push(signalInits)
533
+ }
534
+
535
+ // Generate props serialization code (flat format)
536
+ // Only the outermost component reads bf-p via hydrate(); children get props via initChild().
537
+ if (hasPropsToSerialize) {
538
+ lines.push('')
539
+ lines.push(` // Serialize props for client hydration`)
540
+ lines.push(` const __hydrateProps: Record<string, unknown> = {}`)
541
+ for (const p of propsToSerialize) {
542
+ // Skip functions and JSX elements (they can't be JSON serialized)
543
+ // Use propsObjectName.propName for SolidJS-style, direct propName for destructured
544
+ const propAccess = propsObjectName ? `${propsObjectName}.${p.name}` : p.name
545
+ lines.push(` if (typeof ${propAccess} !== 'function' && !(typeof ${propAccess} === 'object' && ${propAccess} !== null && 'isEscaped' in ${propAccess})) __hydrateProps['${p.name}'] = ${propAccess}`)
546
+ }
547
+ lines.push(` const __bfPropsJson = __bfParentProps || (Object.keys(__hydrateProps).length > 0 ? JSON.stringify(__hydrateProps) : undefined)`)
548
+ } else if (hasClientInteractivity && isRootComponent) {
549
+ // No own props, but root is a component — pass through parent's props
550
+ lines.push('')
551
+ lines.push(` const __bfPropsJson = __bfParentProps`)
552
+ }
553
+
554
+ lines.push('')
555
+
556
+ // Handle if-statement roots (early return pattern)
557
+ if (isIfStatement) {
558
+ lines.push(ifCode)
559
+ lines.push(`}`)
560
+ return lines.join('\n')
561
+ }
562
+
563
+ lines.push(` return (`)
564
+ lines.push(` ${jsxBody}`)
565
+ lines.push(` )`)
566
+ lines.push(`}`)
567
+
568
+ return lines.join('\n')
569
+ }
570
+
571
+ // ===========================================================================
572
+ // Node Rendering
573
+ // ===========================================================================
574
+
575
+ /**
576
+ * Public entry point for node rendering. Delegates to the shared
577
+ * `IRNodeEmitter` dispatcher (#1290 step 1); per-kind logic lives in
578
+ * the `IRNodeEmitter` methods below.
579
+ */
580
+ renderNode(node: IRNode, ctx?: HonoRenderCtx): string {
581
+ return emitIRNode<HonoRenderCtx>(node, this, ctx ?? {})
582
+ }
583
+
584
+ // ===========================================================================
585
+ // IRNodeEmitter implementation (Hono JSX)
586
+ // ===========================================================================
587
+
588
+ emitElement(node: IRElement, ctx: HonoRenderCtx, _emit: EmitIRNode<HonoRenderCtx>): string {
589
+ return this.renderElement(node, ctx)
590
+ }
591
+
592
+ emitText(node: IRText): string {
593
+ return this.renderText(node)
594
+ }
595
+
596
+ emitExpression(node: IRExpression): string {
597
+ return this.renderExpression(node)
598
+ }
599
+
600
+ emitConditional(node: IRConditional, _ctx: HonoRenderCtx, _emit: EmitIRNode<HonoRenderCtx>): string {
601
+ return this.renderConditional(node)
602
+ }
603
+
604
+ emitLoop(node: IRLoop, _ctx: HonoRenderCtx, _emit: EmitIRNode<HonoRenderCtx>): string {
605
+ return this.renderLoop(node)
606
+ }
607
+
608
+ emitComponent(node: IRComponent, ctx: HonoRenderCtx, _emit: EmitIRNode<HonoRenderCtx>): string {
609
+ return this.renderComponent(node, ctx)
610
+ }
611
+
612
+ emitFragment(node: IRFragment, _ctx: HonoRenderCtx, _emit: EmitIRNode<HonoRenderCtx>): string {
613
+ return this.renderFragment(node)
614
+ }
615
+
616
+ emitSlot(_node: IRSlot): string {
617
+ return '{children}'
618
+ }
619
+
620
+ emitIfStatement(_node: IRIfStatement, _ctx: HonoRenderCtx, _emit: EmitIRNode<HonoRenderCtx>): string {
621
+ // If-statements are rendered at the component level (early-return pattern),
622
+ // never inline. This arm is unreachable in practice but is required by
623
+ // the IRNodeEmitter exhaustiveness contract.
624
+ return ''
625
+ }
626
+
627
+ emitProvider(node: IRProvider, _ctx: HonoRenderCtx, _emit: EmitIRNode<HonoRenderCtx>): string {
628
+ const children = this.renderChildren(node.children)
629
+ // Quote literal values; expression / template / spread variants emit
630
+ // their JS source verbatim into the Hono JSX output.
631
+ const valueExpr = (() => {
632
+ const v = node.valueProp.value
633
+ switch (v.kind) {
634
+ case 'literal': return JSON.stringify(v.value)
635
+ case 'expression':
636
+ case 'spread': return v.expr
637
+ case 'template': return this.renderTemplateLiteralParts(v.parts)
638
+ case 'boolean-attr':
639
+ case 'boolean-shorthand': return 'true'
640
+ case 'jsx-children': return 'undefined'
641
+ }
642
+ })()
643
+ // Bridge BarefootJS Context to Hono's per-render context stack so
644
+ // descendants that call useContext() at SSR see the provided value.
645
+ // `provideContextSSR` is a helper exported from the client shim
646
+ // (`@barefootjs/hono/client-shim`); generateImports auto-injects the
647
+ // import when this expression is present in the rendered output.
648
+ // The outer fragment makes the form valid JSX whether the provider
649
+ // appears as the component root or nested inside JSX siblings.
650
+ return `<>{provideContextSSR(${node.contextName}, ${valueExpr}, <>${children}</>)}</>`
651
+ }
652
+
653
+ emitAsync(node: IRAsync, _ctx: HonoRenderCtx, _emit: EmitIRNode<HonoRenderCtx>): string {
654
+ return this.renderAsync(node)
655
+ }
656
+
657
+ renderElement(element: IRElement, ctx?: { isLoopItemRoot?: boolean }): string {
658
+ const tag = element.tag
659
+ const attrs = this.renderAttributes(element)
660
+ const children = this.renderChildren(element.children)
661
+
662
+ // Add hydration markers
663
+ let hydrationAttrs = ''
664
+ if (element.needsScope) {
665
+ // Hydration markers (see spec/compiler.md "Slot identity"):
666
+ // bf-s = addressable scope id
667
+ // bf-h / bf-m = slot identity of a child scope
668
+ // bf-r = root-of-client-component marker
669
+ // bf-p = serialized props (root only; children receive props via initChild)
670
+ hydrationAttrs += ` ${BF_SCOPE}={__scopeId}`
671
+ hydrationAttrs += ` {...(__bfParent ? { "${BF_HOST}": __bfParent } : {})}`
672
+ hydrationAttrs += ` {...(__bfMount ? { "${BF_AT}": __bfMount } : {})}`
673
+ hydrationAttrs += ` {...(!__bfChild ? { "${BF_ROOT}": "" } : {})}`
674
+ if (this.currentComponentHasProps) {
675
+ hydrationAttrs += ` {...(!__bfChild && __bfPropsJson ? { "${BF_PROPS}": __bfPropsJson } : {})}`
676
+ }
677
+ // Add data-key for list reconciliation (only on root elements with scope)
678
+ hydrationAttrs += ' {...(__dataKey !== undefined ? { "data-key": __dataKey } : {})}'
679
+ }
680
+ // Add data-key-N for loop items so event delegation can identify inner items
681
+ if (ctx?.isLoopItemRoot && this.loopKeyStack.length > 0) {
682
+ const loop = this.loopKeyStack[this.loopKeyStack.length - 1]
683
+ if (loop.key) {
684
+ const keyAttrName = this.loopKeyStack.length === 1 ? 'data-key' : `data-key-${this.loopKeyStack.length - 1}`
685
+ hydrationAttrs += ` ${keyAttrName}={String(${loop.key})}`
686
+ }
687
+ }
688
+ if (element.slotId) {
689
+ hydrationAttrs += ` bf="${element.slotId}"`
690
+ }
691
+
692
+ if (children) {
693
+ return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
694
+ } else {
695
+ return `<${tag}${attrs}${hydrationAttrs} />`
696
+ }
697
+ }
698
+
699
+ private renderText(text: IRText): string {
700
+ return text.value
701
+ }
702
+
703
+ renderExpression(expr: IRExpression): string {
704
+ // Keep null as 'null' for proper JSX rendering
705
+ if (expr.expr === 'null' || expr.expr === 'undefined') {
706
+ return 'null'
707
+ }
708
+ // Handle @client directive - render comment marker for client-side evaluation
709
+ if (expr.clientOnly && expr.slotId) {
710
+ return `{bfComment("client:${expr.slotId}")}`
711
+ }
712
+ // Mark expressions with slotId using comment nodes for client JS to find.
713
+ // This includes reactive expressions AND loop-param-dependent expressions
714
+ // (which become reactive via per-item signals on the client).
715
+ if (expr.slotId) {
716
+ return `{bfText("${expr.slotId}")}{${expr.expr}}{bfTextEnd()}`
717
+ }
718
+ return `{${expr.expr}}`
719
+ }
720
+
721
+ renderConditional(cond: IRConditional): string {
722
+ // Handle @client directive - render comment markers for client-side evaluation
723
+ if (cond.clientOnly && cond.slotId) {
724
+ return `{bfComment("cond-start:${cond.slotId}")}{bfComment("cond-end:${cond.slotId}")}`
725
+ }
726
+
727
+ const whenTrue = this.renderNodeRaw(cond.whenTrue)
728
+ let whenFalse = this.renderNodeRaw(cond.whenFalse)
729
+
730
+ // Handle empty/null whenFalse
731
+ if (!whenFalse || whenFalse === '' || whenFalse === 'null') {
732
+ whenFalse = 'null'
733
+ }
734
+
735
+ // If reactive, wrap with markers
736
+ if (cond.slotId) {
737
+ const trueWithMarker = this.wrapWithCondMarker(cond.whenTrue, whenTrue, cond.slotId)
738
+ // For null false branch, render comment markers so client can insert content later
739
+ const falseWithMarker = cond.whenFalse.type === 'expression' && cond.whenFalse.expr === 'null'
740
+ ? `<>{bfComment("cond-start:${cond.slotId}")}{bfComment("cond-end:${cond.slotId}")}</>`
741
+ : this.wrapWithCondMarker(cond.whenFalse, whenFalse, cond.slotId)
742
+
743
+ return `{${cond.condition} ? ${trueWithMarker} : ${falseWithMarker}}`
744
+ }
745
+
746
+ return `{${cond.condition} ? ${whenTrue} : ${whenFalse}}`
747
+ }
748
+
749
+ private wrapWithCondMarker(node: IRNode, content: string, condId: string): string {
750
+ // Components don't reliably forward bf-c to their root element.
751
+ // Use comment markers so insert() can find them via TreeWalker.
752
+ // This matches the client-side template behavior (renderChild returns
753
+ // ${...} expressions which also get comment-wrapped by addCondAttrToTemplate).
754
+ if (node.type === 'component') {
755
+ return `<>{bfComment("cond-start:${condId}")}${content}{bfComment("cond-end:${condId}")}</>`
756
+ }
757
+
758
+ // If content is a single raw HTML element, add bf-c attribute.
759
+ // For fragments (multiple sibling elements), use comment markers.
760
+ if (content.startsWith('<') && node.type !== 'fragment') {
761
+ const match = content.match(/^<(\w+)/)
762
+ if (match) {
763
+ return content.replace(`<${match[1]}`, `<${match[1]} bf-c="${condId}"`)
764
+ }
765
+ }
766
+
767
+ // Expression node: wrap in braces for valid JSX
768
+ if (node.type === 'expression') {
769
+ return `<>{bfComment("cond-start:${condId}")}{${content}}{bfComment("cond-end:${condId}")}</>`
770
+ }
771
+
772
+ // Text node or other: output as text
773
+ return `<>{bfComment("cond-start:${condId}")}${content}{bfComment("cond-end:${condId}")}</>`
774
+ }
775
+
776
+ renderLoop(loop: IRLoop): string {
777
+ // clientOnly loops must not render items at SSR time, but must still emit
778
+ // <!--bf-loop:<id>--><!--bf-/loop:<id>--> boundary markers so that mapArray()
779
+ // on the client can locate the correct anchor node when inserting items.
780
+ // Without the markers, mapArray() resolves anchor = null and appends new
781
+ // elements after sibling markers (e.g. <!--bf-cond-start-->). (#872)
782
+ // The marker id disambiguates sibling `.map()` calls under the same
783
+ // parent (#1087).
784
+ if (loop.clientOnly) {
785
+ return `{bfComment('loop:${loop.markerId}')}{bfComment('/loop:${loop.markerId}')}`
786
+ }
787
+
788
+ // Preserve type annotations for loop params in .tsx output
789
+ const paramAnnotation = loop.paramType ? `: ${loop.paramType}` : ''
790
+ const indexAnnotation = loop.indexType ? `: ${loop.indexType}` : ''
791
+ const indexParam = loop.index ? `, ${loop.index}${indexAnnotation}` : ''
792
+ // Push loop key info for data-key attribute generation on loop items
793
+ this.loopKeyStack.push({ key: loop.key, param: loop.param })
794
+ // Render children with isInsideLoop flag so components generate their own scope IDs
795
+ const children = this.renderChildrenInLoop(loop.children)
796
+ this.loopKeyStack.pop()
797
+
798
+ let mapExpr: string
799
+ // Use typed mapPreamble when available to preserve type annotations in .tsx output
800
+ const preamble = loop.typedMapPreamble ?? loop.mapPreamble
801
+ // When the rendered children are a JSX expression-container (e.g. a single
802
+ // ternary `{cond ? <A/> : <B/>}` from renderConditional), they cannot be
803
+ // used directly as an arrow body — `(x) => {…}` is parsed as a block
804
+ // statement and the function returns undefined. Wrap with a fragment so
805
+ // the body is unambiguously a JSX expression.
806
+ let safeChildren = children.startsWith('{') ? `<>${children}</>` : children
807
+ // Multi-root Fragment items (#1212): prepend a per-item start marker so
808
+ // mapArray can pair each key with all of its DOM nodes. Wrap the body
809
+ // in a Fragment so the prefix and the existing children share an arrow
810
+ // expression body.
811
+ if (loop.bodyIsMultiRoot) {
812
+ // Per-item start marker: BF_LOOP_ITEM ('bf-loop-i'). Hardcoded
813
+ // literal here to match the adapter's existing convention of
814
+ // emitting comment-marker strings directly.
815
+ safeChildren = `<>{bfComment('bf-loop-i')}${children}</>`
816
+ }
817
+ // Apply chained `.sort()` / `.filter()` extracted to
818
+ // `loop.sortComparator` / `loop.filterPredicate` (#1448 Tier B).
819
+ // Pre-Tier-B this used `loop.array` directly — fine when an
820
+ // SSR-side adapter (Go's `bf_sort`) applied the sort separately,
821
+ // broken on Hono where the emitted JSX is the source of truth
822
+ // for both SSR (runtime-eval) and CSR (template fallback).
823
+ // `.toSorted` (non-mutating) preserves shared prop arrays across
824
+ // renders — `.sort()` here would reorder `_p.items` in place.
825
+ const chainedArray = applyHonoLoopChain(loop)
826
+ const iterMethod = loop.method ?? 'map'
827
+
828
+ if (loop.flatMapCallback) {
829
+ // Complex flatMap: use the original raw callback body (preserves JSX
830
+ // for Hono's runtime JSX evaluation).
831
+ mapExpr = `{${chainedArray}.flatMap(${loop.flatMapCallback.params} => ${loop.flatMapCallback.rawBody})}`
832
+ } else if (preamble) {
833
+ mapExpr = `{${chainedArray}.${iterMethod}((${loop.param}${paramAnnotation}${indexParam}) => { ${preamble} return ${safeChildren} })}`
834
+ } else {
835
+ mapExpr = `{${chainedArray}.${iterMethod}((${loop.param}${paramAnnotation}${indexParam}) => ${safeChildren})}`
836
+ }
837
+ // Wrap with loop boundary markers so reconciliation doesn't affect siblings.
838
+ // bfComment('loop:<id>') → <!--bf-loop:<id>-->. The marker id is unique
839
+ // per loop call site so sibling `.map()` calls under the same parent
840
+ // get their own reconciliation range (#1087).
841
+ return `{bfComment('loop:${loop.markerId}')}${mapExpr}{bfComment('/loop:${loop.markerId}')}`
842
+ }
843
+
844
+ private renderChildrenInLoop(children: IRNode[]): string {
845
+ return children.map((child) => this.renderNode(child, { isInsideLoop: true, isLoopItemRoot: true })).join('')
846
+ }
847
+
848
+ /**
849
+ * Render an if-statement chain as function-level code.
850
+ * This is used for components with early return patterns.
851
+ */
852
+ renderIfStatement(ifStmt: IRIfStatement, ctx?: { isRootOfClientComponent?: boolean }): string {
853
+ const lines: string[] = []
854
+
855
+ // Generate scope variables declared in this if block. The Hono SSR
856
+ // template is a `.tsx` file checked by tsc, so prefer the typed
857
+ // initializer when present to keep `as <T>` casts intact — without
858
+ // them, an emitted `const Tag = children.tag` (cast lost) raises
859
+ // TS2604 at `<Tag/>` because `unknown` has no call signature. See
860
+ // IRIfStatement.scopeVariables.typedInitializer docstring (#1453).
861
+ for (const v of ifStmt.scopeVariables) {
862
+ const init = (this.jsxConfig.preserveTypes && v.typedInitializer) || v.initializer
863
+ lines.push(` const ${v.name} = ${init}`)
864
+ }
865
+
866
+ // Render the consequent (then branch) JSX
867
+ const consequent = this.renderNode(ifStmt.consequent, ctx)
868
+
869
+ // Build the if statement
870
+ lines.unshift(` if (${ifStmt.condition}) {`)
871
+ lines.push(` return (`)
872
+ lines.push(` ${consequent}`)
873
+ lines.push(` )`)
874
+ lines.push(` }`)
875
+
876
+ // Handle the alternate (else branch)
877
+ if (ifStmt.alternate) {
878
+ if (ifStmt.alternate.type === 'if-statement') {
879
+ // else if chain - recursively render
880
+ const elseIfCode = this.renderIfStatement(ifStmt.alternate as IRIfStatement, ctx)
881
+ // Replace the leading 'if' with 'else if'
882
+ lines.push(elseIfCode.replace(/^\s*if/, ' else if'))
883
+ } else {
884
+ // Final else branch with regular JSX
885
+ const alternate = this.renderNode(ifStmt.alternate, ctx)
886
+ lines.push(` return (`)
887
+ lines.push(` ${alternate}`)
888
+ lines.push(` )`)
889
+ }
890
+ } else {
891
+ // No alternate - return null
892
+ lines.push(` return null`)
893
+ }
894
+
895
+ return lines.join('\n')
896
+ }
897
+
898
+ renderAsync(node: IRAsync): string {
899
+ const fallback = this.renderNode(node.fallback)
900
+ const children = this.renderChildren(node.children)
901
+ return `<Suspense fallback={<>${fallback}</>}>${children}</Suspense>`
902
+ }
903
+
904
+ renderComponent(comp: IRComponent, ctx?: { isRootOfClientComponent?: boolean; isInsideLoop?: boolean; isLoopItemRoot?: boolean }): string {
905
+ const props = this.renderComponentProps(comp)
906
+ const children = this.renderChildren(comp.children)
907
+
908
+ // Determine how to pass scope to child component
909
+ let scopeAttr: string
910
+ // Mark child components with slotId for parent-first hydration
911
+ // Add __bfChild when parent has client interactivity (will call initChild)
912
+ const bfChildAttr = (comp.slotId && this.hasClientInteractivity) ? ' __bfChild={true}' : ''
913
+ // Pass parent scope + slot id to the child so it can stamp bf-h / bf-m
914
+ // for upsertChild's (bf-h, bf-m) primary lookup (#1249).
915
+ const bfMountAttr = comp.slotId ? ` __bfParent={__scopeId} __bfMount={'${comp.slotId}'}` : ''
916
+ if (ctx?.isRootOfClientComponent) {
917
+ // Root component: if it has a slotId, include it so client JS can find it
918
+ // with [bf-s$="_sX"] selector. Otherwise pass parent's scope directly.
919
+ // Note: Do NOT add __bfChild here - the root is the main hydration target, not a child.
920
+ // Pass __bfParentProps so child component can use parent's serialized props
921
+ const propsPassAttr = this.currentComponentHasProps ? ' __bfParentProps={__bfPropsJson}' : ''
922
+ if (comp.slotId) {
923
+ scopeAttr = ` __instanceId={\`\${__scopeId}_${comp.slotId}\`}${propsPassAttr}${bfMountAttr}`
924
+ } else {
925
+ scopeAttr = ` __instanceId={__scopeId}${propsPassAttr}`
926
+ }
927
+ // Also pass bf-s for asChild/Slot patterns where the component
928
+ // forwards props to a DOM element via {...props}.
929
+ scopeAttr += ` ${BF_SCOPE}={__scopeId}`
930
+ } else if (ctx?.isInsideLoop) {
931
+ // Components inside loops should generate their own unique scope IDs
932
+ // Pass __bfScope so they use it as fallback but generate unique IDs
933
+ // This ensures each loop iteration has a distinct component instance
934
+ if (comp.slotId) {
935
+ scopeAttr = ` __bfScope={\`\${__scopeId}_${comp.slotId}\`}${bfChildAttr}${bfMountAttr}`
936
+ } else {
937
+ scopeAttr = ' __bfScope={__scopeId}'
938
+ }
939
+ } else if (comp.slotId) {
940
+ // Components with slotId need unique scope with slot suffix
941
+ // Format: ParentName_slotX for client JS matching
942
+ scopeAttr = ` __instanceId={\`\${__scopeId}_${comp.slotId}\`}${bfChildAttr}${bfMountAttr}`
943
+ } else {
944
+ // Non-interactive components inherit parent's scope
945
+ scopeAttr = ' __instanceId={__scopeId}'
946
+ }
947
+
948
+ if (children) {
949
+ return `<${comp.name}${props}${scopeAttr}>${children}</${comp.name}>`
950
+ } else {
951
+ return `<${comp.name}${props}${scopeAttr} />`
952
+ }
953
+ }
954
+
955
+ private renderFragment(fragment: IRFragment): string {
956
+ const children = this.renderChildren(fragment.children)
957
+ if (fragment.needsScopeComment) {
958
+ return this.wrapWithScopeComment(children)
959
+ }
960
+ return `<>${children}</>`
961
+ }
962
+
963
+ /**
964
+ * Wrap `body` in a fragment-rooted scope comment.
965
+ * Shape (matches `hydrate.ts::hydrateCommentScope`):
966
+ * root: <!--bf-scope:<scopeId>|<propsJson>-->
967
+ * child: <!--bf-scope:<scopeId>|h=<host>|m=<slot>|<propsJson>-->
968
+ * `<scopeId>` stays at the front so child detection can anchor on `|h=`.
969
+ */
970
+ private wrapWithScopeComment(body: string): string {
971
+ const hostExpr = '${__bfParent ? `|h=${__bfParent}|m=${__bfMount}` : ""}'
972
+ const propsExpr = this.currentComponentHasProps
973
+ ? '${__bfPropsJson ? `|${__bfPropsJson}` : ""}'
974
+ : ''
975
+ return `<>{bfComment(\`scope:\${__scopeId}${hostExpr}${propsExpr}\`)}${body}</>`
976
+ }
977
+
978
+ // ===========================================================================
979
+ // Attribute Rendering
980
+ // ===========================================================================
981
+
982
+ /**
983
+ * AttrValue lowering for intrinsic-element attributes (Hono JSX).
984
+ * Per-kind logic that used to live in a `switch (v.kind)` inside
985
+ * `renderAttributes`; routed through the shared dispatcher so a new
986
+ * AttrValue kind becomes a TS compile error here (#1290 step 2).
987
+ */
988
+ private readonly elementAttrEmitter: AttrValueEmitter = {
989
+ emitLiteral: (value, name) => `${name}="${value.value}"`,
990
+ emitExpression: (value, name) => {
991
+ // Boolean attrs / presence-folded expressions: pass `undefined` when
992
+ // falsy so Hono omits the attribute. Wrap in parens to keep `??`
993
+ // operators inside `expr` from breaking the surrounding `|| undefined`.
994
+ if (isBooleanAttr(name) || value.presenceOrUndefined) {
995
+ return `${name}={(${value.expr}) || undefined}`
996
+ }
997
+ return `${name}={${value.expr}}`
998
+ },
999
+ emitBooleanAttr: (_value, name) => name,
1000
+ emitBooleanShorthand: () => '',
1001
+ emitTemplate: (value, name) => `${name}={${this.renderTemplateLiteralParts(value.parts)}}`,
1002
+ emitSpread: (value) => `{...${value.expr}}`,
1003
+ // Neither boolean-shorthand nor jsx-children is legal on intrinsic
1004
+ // elements. Returning empty string drops the entry silently — matches
1005
+ // pre-#1290 behavior.
1006
+ emitJsxChildren: () => '',
1007
+ }
1008
+
1009
+ /**
1010
+ * AttrValue lowering for component-invocation props (Hono JSX).
1011
+ * Component props differ from intrinsic attrs in several places —
1012
+ * `jsx-children` is rendered as `<>…</>`, `expression` skips the
1013
+ * boolean-attr fold, etc. Kept as a separate emitter so each method
1014
+ * does one thing.
1015
+ */
1016
+ private readonly componentPropEmitter: AttrValueEmitter = {
1017
+ emitLiteral: (value, name) =>
1018
+ // IR-authoritative string literal: `<X fill="var(--c)" />`.
1019
+ // Emitting verbatim is what distinguishes a CSS-shaped value
1020
+ // (`var(...)`, `url(...)`, `calc(...)`) from a JS expression.
1021
+ `${name}="${value.value}"`,
1022
+ emitExpression: (value, name) => `${name}={${value.expr}}`,
1023
+ emitBooleanAttr: (_value, name) => name,
1024
+ emitBooleanShorthand: (_value, name) => name,
1025
+ emitTemplate: (value, name) => `${name}={${this.renderTemplateLiteralParts(value.parts)}}`,
1026
+ emitSpread: (value) => `{...${value.expr}}`,
1027
+ emitJsxChildren: (value, name) => {
1028
+ const rendered = value.children.map((c) => this.renderNode(c)).join('')
1029
+ return `${name}={<>${rendered}</>}`
1030
+ },
1031
+ }
1032
+
1033
+ private renderAttributes(element: IRElement): string {
1034
+ const parts: string[] = []
1035
+
1036
+ for (const attr of element.attrs) {
1037
+ const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attr.name)
1038
+ if (lowered) parts.push(lowered)
1039
+ }
1040
+
1041
+ // Add event handlers (as no-op for SSR)
1042
+ for (const event of element.events) {
1043
+ const handlerName = event.originalAttr ?? `on${event.name.charAt(0).toUpperCase()}${event.name.slice(1)}`
1044
+ parts.push(`${handlerName}={() => {}}`)
1045
+ }
1046
+
1047
+ return parts.length > 0 ? ' ' + parts.join(' ') : ''
1048
+ }
1049
+
1050
+ private renderComponentProps(comp: IRComponent): string {
1051
+ const parts: string[] = []
1052
+ let keyValue: string | null = null
1053
+
1054
+ for (const prop of comp.props) {
1055
+ if (prop.name === 'key') {
1056
+ // JSX key → data-key only. Hono JSX strips `key` from HTML output
1057
+ // (delete props["key"]), so emitting key={} is a no-op. We only need
1058
+ // data-key which the BarefootJS client runtime uses for reconciliation.
1059
+ keyValue = this.attrValueToJsExpr(prop.value)
1060
+ continue
1061
+ }
1062
+ const lowered = emitAttrValue(prop.value, this.componentPropEmitter, prop.name)
1063
+ if (lowered) parts.push(lowered)
1064
+ }
1065
+
1066
+ // Add data-key prop when key is present for client-side reconciliation
1067
+ // This allows the child component to add data-key attribute to its root element
1068
+ if (keyValue) {
1069
+ parts.push(`data-key={${keyValue}}`)
1070
+ }
1071
+
1072
+ return parts.length > 0 ? ' ' + parts.join(' ') : ''
1073
+ }
1074
+
1075
+ private attrValueToJsExpr(value: AttrValue): string {
1076
+ switch (value.kind) {
1077
+ case 'literal': return JSON.stringify(value.value)
1078
+ case 'expression':
1079
+ case 'spread': return value.expr
1080
+ case 'template': return this.renderTemplateLiteralParts(value.parts)
1081
+ case 'boolean-shorthand':
1082
+ case 'boolean-attr': return 'true'
1083
+ case 'jsx-children': return 'undefined'
1084
+ }
1085
+ }
1086
+
1087
+ private renderTemplateLiteralParts(parts: IRTemplatePart[]): string {
1088
+ let output = '`'
1089
+ for (const part of parts) {
1090
+ if (part.type === 'string') {
1091
+ output += part.value
1092
+ } else if (part.type === 'ternary') {
1093
+ output += `\${${part.condition} ? '${part.whenTrue}' : '${part.whenFalse}'}`
1094
+ } else if (part.type === 'lookup') {
1095
+ // Hono runs JS at SSR time, so a `${MAP[KEY]}` lookup can be
1096
+ // re-materialised as a runtime indexed access against the
1097
+ // resolved cases — byte-identical to the client emit path in
1098
+ // `ir-to-client-js/utils.ts`. Use `part.key` (raw JS source)
1099
+ // because this output runs inside the destructured-prop scope
1100
+ // of the component, mirroring the `'ternary'` branch above.
1101
+ const obj = '{' + Object.entries(part.cases).map(
1102
+ ([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`
1103
+ ).join(', ') + '}'
1104
+ output += `\${(${obj})[${part.key}]}`
1105
+ }
1106
+ }
1107
+ output += '`'
1108
+ return output
1109
+ }
1110
+
1111
+ }
1112
+
1113
+ // Export singleton instance for convenience
1114
+ export const honoAdapter = new HonoAdapter()