@barefootjs/hono 0.31.0 → 0.31.2

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 (38) hide show
  1. package/dist/adapter/hono-adapter.d.ts +67 -2
  2. package/dist/adapter/hono-adapter.d.ts.map +1 -1
  3. package/dist/adapter/index.js +46 -187398
  4. package/dist/app.js +0 -71
  5. package/dist/async.js +0 -71
  6. package/dist/client-shim.js +0 -71
  7. package/dist/dev-worker.js +0 -71
  8. package/dist/dialog-context.js +0 -71
  9. package/dist/index.js +46 -187398
  10. package/dist/jsx/jsx-dev-runtime/index.d.ts +3 -1
  11. package/dist/jsx/jsx-dev-runtime/index.d.ts.map +1 -1
  12. package/dist/jsx/jsx-dev-runtime/index.js +14 -69
  13. package/dist/jsx/jsx-runtime/index.d.ts +4 -1
  14. package/dist/jsx/jsx-runtime/index.d.ts.map +1 -1
  15. package/dist/jsx/jsx-runtime/index.js +24 -69
  16. package/dist/jsx/resolve-dangerously-set-inner-html.d.ts +2 -0
  17. package/dist/jsx/resolve-dangerously-set-inner-html.d.ts.map +1 -0
  18. package/dist/portal-ssr.js +0 -71
  19. package/dist/portals.js +0 -71
  20. package/dist/preload.js +0 -71
  21. package/dist/render.js +0 -71
  22. package/dist/request-env.js +0 -71
  23. package/dist/scripts.d.ts +3 -2
  24. package/dist/scripts.d.ts.map +1 -1
  25. package/dist/scripts.js +0 -71
  26. package/dist/utils.js +0 -71
  27. package/dist/vite.js +313 -142
  28. package/package.json +2 -2
  29. package/src/__tests__/aliased-destructured-prop.test.ts +8 -7
  30. package/src/__tests__/consumer-typecheck.test.ts +403 -0
  31. package/src/__tests__/corpus-typecheck.test.ts +130 -0
  32. package/src/__tests__/dangerously-set-inner-html.test.ts +70 -0
  33. package/src/__tests__/nested-ternary-bare-branch.test.ts +70 -0
  34. package/src/adapter/hono-adapter.ts +123 -166
  35. package/src/jsx/jsx-dev-runtime/index.ts +13 -1
  36. package/src/jsx/jsx-runtime/index.ts +24 -1
  37. package/src/jsx/resolve-dangerously-set-inner-html.ts +34 -0
  38. package/src/scripts.tsx +3 -2
@@ -0,0 +1,70 @@
1
+ /**
2
+ * #2470: a nested ternary chain (`a ? … : b ? … : …`) sitting in the
3
+ * ALTERNATE of a NON-reactive outer conditional (no signal/prop/call —
4
+ * a module-level `const`) used to emit
5
+ *
6
+ * {MODE === 'a' ? <span>A</span> : {MODE === 'b' ? <span>B</span> : <span>C</span>}}
7
+ *
8
+ * — the nested conditional re-wrapped in its own `{…}` in a position where
9
+ * only a bare JS expression is legal, breaking the `.tsx` parse
10
+ * (`Expected "}" but found "==="`) with zero diagnostics. See
11
+ * `renderConditionalBody` / `renderBareBranch` in `hono-adapter.ts`.
12
+ */
13
+ import { describe, expect, test } from 'bun:test'
14
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
15
+ import { join, resolve } from 'node:path'
16
+ import ts from 'typescript'
17
+ import { compileJSX } from '@barefootjs/jsx'
18
+ import { HonoAdapter } from '../adapter/index.ts'
19
+
20
+ const HERE = resolve(import.meta.dir)
21
+
22
+ const SOURCE = `
23
+ const MODE = 'b'
24
+ export function Chain() {
25
+ return <div>{MODE === 'a' ? <span>A</span> : MODE === 'b' ? <span>B</span> : <span>C</span>}</div>
26
+ }
27
+ `
28
+
29
+ describe('nested ternary under a non-reactive outer condition (#2470)', () => {
30
+ test('the emitted template has no double-braced nested conditional', () => {
31
+ const result = compileJSX(SOURCE, '/virtual/Chain.tsx', {
32
+ adapter: new HonoAdapter(),
33
+ })
34
+ expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
35
+ const template = result.files.find(f => f.type === 'markedTemplate')?.content
36
+ expect(template).toBeDefined()
37
+
38
+ // The nested conditional's branch must stay a bare `cond ? … : …`
39
+ // splice, not a second `{…}`-wrapped expression, inside the outer
40
+ // ternary's alternate.
41
+ expect(template).toContain(
42
+ "MODE === 'a' ? <span>A</span> : MODE === 'b' ? <span>B</span> : <span>C</span>",
43
+ )
44
+ expect(template).not.toContain(': {MODE ===')
45
+
46
+ // The template must actually PARSE as valid TSX — this is the
47
+ // regression the bug produced silently (no compiler diagnostic, just a
48
+ // downstream syntax error at build time). `getSyntacticDiagnostics` is
49
+ // the public API for this (mirrors `consumer-typecheck.test.ts`'s
50
+ // `ts.createProgram` usage, scoped to syntax only since this file has
51
+ // no import to resolve).
52
+ const tmp = mkdtempSync(join(HERE, '.nested-ternary-bare-branch-'))
53
+ try {
54
+ const file = join(tmp, 'Chain.tsx')
55
+ writeFileSync(file, template!)
56
+ const program = ts.createProgram([file], {
57
+ noEmit: true,
58
+ target: ts.ScriptTarget.ESNext,
59
+ jsx: ts.JsxEmit.ReactJSX,
60
+ jsxImportSource: '@barefootjs/hono/jsx',
61
+ allowImportingTsExtensions: true,
62
+ skipLibCheck: true,
63
+ })
64
+ const syntaxDiagnostics = program.getSyntacticDiagnostics(program.getSourceFile(file))
65
+ expect(syntaxDiagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, ' '))).toEqual([])
66
+ } finally {
67
+ rmSync(tmp, { recursive: true, force: true })
68
+ }
69
+ }, 30_000)
70
+ })
@@ -34,8 +34,8 @@ import {
34
34
  emitIRNode,
35
35
  emitAttrValue,
36
36
  buildLoopChainExpr,
37
+ propsDestructureBinding,
37
38
  } from '@barefootjs/jsx'
38
- import ts from 'typescript'
39
39
 
40
40
  /**
41
41
  * Hono adapter's IRNode render context: which surrounding render
@@ -98,28 +98,6 @@ function applyHonoLoopChain(loop: IRLoop): string {
98
98
  })
99
99
  }
100
100
 
101
- /**
102
- * Authoritative IdentifierName classification for a destructure-pattern
103
- * property key, built on TS's own `isIdentifierStart` / `isIdentifierPart`
104
- * primitives (Unicode-aware, stays aligned with what TS itself accepts as
105
- * a bare property key). Mirrors the `isIdent` precedent in
106
- * `jsx-to-ir.ts` (#1244) — a source key like `data-key` or `aria-label`
107
- * can't be emitted as a bare `key: local` destructure and must be quoted
108
- * (`"data-key": local`).
109
- */
110
- function isIdentifierName(key: string): boolean {
111
- if (key.length === 0) return false
112
- for (let i = 0; i < key.length; ) {
113
- const cp = key.codePointAt(i)!
114
- const ok = i === 0
115
- ? ts.isIdentifierStart(cp, ts.ScriptTarget.Latest)
116
- : ts.isIdentifierPart(cp, ts.ScriptTarget.Latest)
117
- if (!ok) return false
118
- i += cp > 0xFFFF ? 2 : 1
119
- }
120
- return true
121
- }
122
-
123
101
  export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderCtx> {
124
102
  name = 'hono'
125
103
  extension = '.tsx'
@@ -212,18 +190,16 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
212
190
  this.preloadAssets = options?.preloadAssets
213
191
  }
214
192
 
215
- // Generate component body FIRST so we can scan it for used imports
193
+ // Generate component body FIRST so we can scan it for used imports.
194
+ // Module-scope declarations stay at module scope (#2570) — see
195
+ // `generateModuleScopeDeclarations`; they join the scan text so a
196
+ // helper referenced only from a hoisted declaration still pulls its
197
+ // import.
216
198
  const component = this.generateComponent(ir)
217
- const types = this.generateTypes(ir, component)
218
- const componentCode = [types, component].filter(Boolean).join('\n')
199
+ const types = this.generateTypes(ir)
200
+ const moduleConstants = this.generateModuleScopeDeclarations(ir)
201
+ const componentCode = [moduleConstants, types, component].filter(Boolean).join('\n')
219
202
  const imports = this.generateImports(ir, componentCode)
220
- // Module-level Context bindings (`const Ctx = createContext()`) are
221
- // skipped from the SSR signal-initializer block by JsxAdapter — they
222
- // need to live at module scope so providers and consumers in the same
223
- // render share the same Context object identity. Emitted in a dedicated
224
- // section so multi-component dedup works on the full block (not per
225
- // line, which would split multi-line `({...})` arguments).
226
- const moduleConstants = this.generateModuleLevelContextBindings(ir)
227
203
 
228
204
  const defaultExport = ir.metadata.hasDefaultExport
229
205
  ? `\nexport default ${this.componentName}`
@@ -235,6 +211,7 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
235
211
  component,
236
212
  defaultExport,
237
213
  moduleConstants,
214
+ moduleConstantsIncludeExports: true,
238
215
  }
239
216
 
240
217
  // Assemble template for backward compat (external consumers using output.template)
@@ -275,20 +252,6 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
275
252
  return this.hasScriptAssets() && !!this.preloadAssets && this.preloadAssets.length > 0
276
253
  }
277
254
 
278
- private generateModuleLevelContextBindings(ir: ComponentIR): string {
279
- const lines: string[] = []
280
- for (const c of ir.metadata.localConstants) {
281
- if (!c.isModule) continue
282
- if (c.isExported) continue
283
- if (c.systemConstructKind !== 'createContext') continue
284
- if (!c.value) continue
285
- const keyword = c.declarationKind ?? 'const'
286
- const value = this.jsxConfig.preserveTypes ? (c.typedValue ?? c.value) : c.value
287
- lines.push(`${keyword} ${c.name} = ${value}`)
288
- }
289
- return lines.join('\n')
290
- }
291
-
292
255
  // ===========================================================================
293
256
  // Imports Generation
294
257
  // ===========================================================================
@@ -369,73 +332,23 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
369
332
  // Types Generation
370
333
  // ===========================================================================
371
334
 
372
- generateTypes(ir: ComponentIR, componentBody?: string): string | null {
335
+ /**
336
+ * Per-component synthesized types only. The source module's own type
337
+ * declarations are NOT emitted here — `generateModuleScopeDeclarations`
338
+ * carries them once, file-wide, in the module-constants section (#2570).
339
+ * The per-component reachability scan that used to live here (#1453) is
340
+ * gone with them: pruning per component both duplicated shared types
341
+ * across components' sections (TS2300 — the compiler dedups sections by
342
+ * whole string) and dropped exported types no component referenced
343
+ * (TS2305 for any consumer's `import type`).
344
+ */
345
+ generateTypes(ir: ComponentIR): string | null {
373
346
  const lines: string[] = []
374
347
 
375
- // Include original type definitions — only those referenced in the component body
376
- // or transitively referenced by other included type definitions
377
- if (componentBody && ir.metadata.typeDefinitions.length > 0) {
378
- const propsTypeName = this.getPropsTypeName(ir)
379
- // Seed the reachability scan with everything that ends up referencing
380
- // a type name in the FINAL emitted file, not just the component body.
381
- //
382
- // - `propsTypeName` is referenced by the synthesized
383
- // `${Name}PropsWithHydration = ${propsTypeName} & {...}` alias the
384
- // destructured-props branch emits below — but that alias is built
385
- // AFTER this scan, so the body never literally mentions e.g.
386
- // `ButtonProps`. Without seeding it here the alias references an
387
- // undeclared name (TS2304) and TS widens `variant`/`size` to `any`
388
- // at every `Record[variant]` lookup site (TS7053) downstream.
389
- //
390
- // - Named re-export blocks (`export type { ButtonVariant, ButtonSize,
391
- // ButtonProps }`) are emitted by the compiler's `generateModuleExports`
392
- // AFTER `s.types`. Each re-exported local name needs its declaration
393
- // carried forward too. Issue #1453 covers the full reproduction.
394
- const seedText = [
395
- componentBody,
396
- propsTypeName && !ir.metadata.propsObjectName ? propsTypeName : '',
397
- ...ir.metadata.namedExports
398
- .filter((block) => block.source === null)
399
- .flatMap((block) => block.specifiers.map((s) => s.name)),
400
- ].filter(Boolean).join('\n')
401
-
402
- const included = new Set<string>()
403
- // First pass: include types directly referenced in the seed text
404
- for (const typeDef of ir.metadata.typeDefinitions) {
405
- if (new RegExp(`\\b${typeDef.name}\\b`).test(seedText)) {
406
- included.add(typeDef.name)
407
- }
408
- }
409
- // Transitive pass: include types referenced by already-included types
410
- let changed = true
411
- while (changed) {
412
- changed = false
413
- for (const typeDef of ir.metadata.typeDefinitions) {
414
- if (included.has(typeDef.name)) continue
415
- for (const name of included) {
416
- const includedDef = ir.metadata.typeDefinitions.find(t => t.name === name)
417
- if (includedDef && new RegExp(`\\b${typeDef.name}\\b`).test(includedDef.definition)) {
418
- included.add(typeDef.name)
419
- changed = true
420
- break
421
- }
422
- }
423
- }
424
- }
425
- for (const typeDef of ir.metadata.typeDefinitions) {
426
- if (included.has(typeDef.name)) lines.push(typeDef.definition)
427
- }
428
- } else {
429
- for (const typeDef of ir.metadata.typeDefinitions) {
430
- lines.push(typeDef.definition)
431
- }
432
- }
433
-
434
348
  // Generate hydration props type (only when destructured-props pattern uses it;
435
349
  // SolidJS-style props use inline type annotation instead)
436
350
  const propsTypeName = this.getPropsTypeName(ir)
437
351
  if (propsTypeName && !ir.metadata.propsObjectName) {
438
- lines.push('')
439
352
  lines.push(`type ${this.componentName}PropsWithHydration = ${propsTypeName} & {`)
440
353
  lines.push(' __instanceId?: string')
441
354
  lines.push(' __bfScope?: string')
@@ -594,25 +507,11 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
594
507
  } else {
595
508
  const hydrationProps = `__instanceId, ${bfScopeAlias}, ${bfChildAlias}, ${bfParentPropsAlias}, ${bfParentAlias}, ${bfMountAlias}, ${dataKeyAlias}`
596
509
  const parts: string[] = []
510
+ // Rename-aware `key: local` bindings (b4f5075), shared with
511
+ // TestAdapter via `propsDestructureBinding` — see its docstring for
512
+ // the `class` → `className` reserved-word rationale.
597
513
  const propsParams = ir.metadata.propsParams
598
- .map((p: ParamInfo) => {
599
- // The caller-facing key is `sourceName ?? name` (ParamInfo's own
600
- // rule) — `name` is only ever the LOCAL binding. Emit the plain
601
- // shorthand when they match (byte-identical to before this was
602
- // rename-aware); emit a `key: local` rename otherwise. This also
603
- // covers the `class` → `className` rename correctly: a source
604
- // prop literally named `class` can only reach `propsParams` via
605
- // an aliased destructure (`{ class: className }` — `class` is a
606
- // reserved word, so it can never be an un-aliased binding), which
607
- // already sets `sourceName: 'class'` and is handled by the rename
608
- // branch below (`class: className`), not a bare `className`.
609
- const callerKey = p.sourceName ?? p.name
610
- const localName = p.name
611
- const binding = callerKey === localName
612
- ? localName
613
- : `${isIdentifierName(callerKey) ? callerKey : JSON.stringify(callerKey)}: ${localName}`
614
- return p.defaultValue ? `${binding} = ${p.defaultValue}` : binding
615
- })
514
+ .map((p: ParamInfo) => propsDestructureBinding(p))
616
515
  .join(', ')
617
516
  if (propsParams) {
618
517
  parts.push(propsParams)
@@ -696,7 +595,12 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
696
595
  // Skip functions and JSX elements (they can't be JSON serialized)
697
596
  // Use propsObjectName.propName for SolidJS-style, direct propName for destructured
698
597
  const propAccess = propsObjectName ? `${propsObjectName}.${p.name}` : p.name
699
- lines.push(` if (typeof ${propAccess} !== 'function' && !(typeof ${propAccess} === 'object' && ${propAccess} !== null && 'isEscaped' in ${propAccess})) __hydrateProps['${p.name}'] = ${propAccess}`)
598
+ // The `bf-p` blob key is always the caller-facing name (#2524 CSR
599
+ // half) — every non-Hono `_p` producer/consumer keys the same way,
600
+ // so a renaming destructure (`{ n: count }`) must serialize under
601
+ // `n`, not the local binding `count`.
602
+ const callerKey = p.sourceName ?? p.name
603
+ lines.push(` if (typeof ${propAccess} !== 'function' && !(typeof ${propAccess} === 'object' && ${propAccess} !== null && 'isEscaped' in ${propAccess})) __hydrateProps['${callerKey}'] = ${propAccess}`)
700
604
  }
701
605
  lines.push(` const __bfPropsJson = __bfParentProps || (Object.keys(__hydrateProps).length > 0 ? JSON.stringify(__hydrateProps) : undefined)`)
702
606
  } else if (hasClientInteractivity && isRootComponent) {
@@ -796,7 +700,7 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
796
700
  const v = node.valueProp.value
797
701
  switch (v.kind) {
798
702
  case 'literal': return JSON.stringify(v.value)
799
- case 'expression':
703
+ case 'expression': return this.expressionValueToJs(v)
800
704
  case 'spread': return v.expr
801
705
  case 'template': return this.renderTemplateLiteralParts(v.parts)
802
706
  case 'boolean-attr':
@@ -908,6 +812,39 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
908
812
  return `{bfComment("cond-start:${cond.slotId}")}{bfComment("cond-end:${cond.slotId}")}`
909
813
  }
910
814
 
815
+ return `{${this.renderConditionalBody(cond, ctx)}}`
816
+ }
817
+
818
+ /**
819
+ * The bare ternary text for `cond` — everything `renderConditional` would
820
+ * wrap in `{…}`, minus that wrapping. `renderConditional` is the ONLY
821
+ * place that should add the enclosing braces for a JSX-child position.
822
+ *
823
+ * The two branches below need genuinely different embedding rules, not
824
+ * just a brace/no-brace toggle:
825
+ *
826
+ * - Non-reactive (`cond.slotId` is null): the whole thing collapses to a
827
+ * FLAT `cond ? whenTrue : whenFalse` — one JS expression, wrapped in
828
+ * `{…}` exactly once by whichever caller owns that position (either
829
+ * `renderConditional` for a JSX-child position, or an ENCLOSING
830
+ * ternary's own bare-branch splice when this conditional is itself
831
+ * nested — see `renderBareBranch`). So whenTrue/whenFalse must stay
832
+ * bare all the way down: a nested conditional branch renders through
833
+ * `renderConditionalBody` again (bare), never through the generic
834
+ * `renderNode`/`emitConditional` dispatch, which would re-brace it and
835
+ * break the .tsx parse where only a plain expression is legal (#2470).
836
+ *
837
+ * - Reactive (`cond.slotId` set): each branch is spliced through
838
+ * `wrapWithCondMarker`, which expects a self-contained renderable
839
+ * chunk — an HTML-element string it can tag with `bf-c`, or otherwise
840
+ * text/JSX content it splices directly as JSX CHILDREN inside a
841
+ * `<>…</>` fragment. A nested conditional branch here must keep going
842
+ * through the ordinary `renderNode`/`emitConditional` dispatch (via
843
+ * `renderNodeRawCtx`, unchanged) so it comes back as a complete,
844
+ * already-`{…}`-wrapped JSX expression container — valid as fragment
845
+ * children, which bare ternary text would not be.
846
+ */
847
+ private renderConditionalBody(cond: IRConditional, ctx?: HonoRenderCtx): string {
911
848
  // A conditional that is itself a loop item root (#1665 whole-item
912
849
  // conditional: `arr.map(t => cond && <li/>)`) makes its branch element the
913
850
  // loop item's root, so the `data-key` that reconciliation/hydration expect
@@ -915,26 +852,29 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
915
852
  // the flag through so `renderElement` emits `data-key`, matching the Go /
916
853
  // CSR adapters' generic `key`→`data-key` rewrite.
917
854
  const branchCtx: HonoRenderCtx | undefined = ctx?.isLoopItemRoot ? { isLoopItemRoot: true } : undefined
855
+
856
+ if (!cond.slotId) {
857
+ const whenTrue = this.renderBareBranch(cond.whenTrue, branchCtx)
858
+ let whenFalse = this.renderBareBranch(cond.whenFalse, branchCtx)
859
+ if (!whenFalse || whenFalse === '' || whenFalse === 'null') {
860
+ whenFalse = 'null'
861
+ }
862
+ return `${cond.condition} ? ${whenTrue} : ${whenFalse}`
863
+ }
864
+
918
865
  const whenTrue = this.renderNodeRawCtx(cond.whenTrue, branchCtx)
919
866
  let whenFalse = this.renderNodeRawCtx(cond.whenFalse, branchCtx)
920
-
921
- // Handle empty/null whenFalse
922
867
  if (!whenFalse || whenFalse === '' || whenFalse === 'null') {
923
868
  whenFalse = 'null'
924
869
  }
925
870
 
926
- // If reactive, wrap with markers
927
- if (cond.slotId) {
928
- const trueWithMarker = this.wrapWithCondMarker(cond.whenTrue, whenTrue, cond.slotId)
929
- // For null false branch, render comment markers so client can insert content later
930
- const falseWithMarker = cond.whenFalse.type === 'expression' && cond.whenFalse.expr === 'null'
931
- ? `<>{bfComment("cond-start:${cond.slotId}")}{bfComment("cond-end:${cond.slotId}")}</>`
932
- : this.wrapWithCondMarker(cond.whenFalse, whenFalse, cond.slotId)
933
-
934
- return `{${cond.condition} ? ${trueWithMarker} : ${falseWithMarker}}`
935
- }
871
+ const trueWithMarker = this.wrapWithCondMarker(cond.whenTrue, whenTrue, cond.slotId)
872
+ // For null false branch, render comment markers so client can insert content later
873
+ const falseWithMarker = cond.whenFalse.type === 'expression' && cond.whenFalse.expr === 'null'
874
+ ? `<>{bfComment("cond-start:${cond.slotId}")}{bfComment("cond-end:${cond.slotId}")}</>`
875
+ : this.wrapWithCondMarker(cond.whenFalse, whenFalse, cond.slotId)
936
876
 
937
- return `{${cond.condition} ? ${whenTrue} : ${whenFalse}}`
877
+ return `${cond.condition} ? ${trueWithMarker} : ${falseWithMarker}`
938
878
  }
939
879
 
940
880
  /**
@@ -951,6 +891,30 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
951
891
  return this.renderNode(node, ctx)
952
892
  }
953
893
 
894
+ /**
895
+ * A branch of a NON-reactive conditional's flat ternary (#2470) — the
896
+ * value must be a bare JS expression, since it's spliced directly into
897
+ * `cond ? whenTrue : whenFalse`. Mirrors `renderNodeRawCtx`'s existing
898
+ * `null`/`undefined` special case, extended to a nested conditional: that
899
+ * branch renders through `renderConditionalBody` itself (bare), instead
900
+ * of falling through to `renderNode`/`emitConditional`, which would wrap
901
+ * it in its own `{…}` and break the .tsx parse in this brace-free
902
+ * position. The `@client`-directive combination is excluded — its
903
+ * rendering is a pair of independent marker expressions, not a single JS
904
+ * expression, so it can't be spliced bare into a ternary branch either
905
+ * way; that shape falls through to the pre-existing (unrelated) behavior.
906
+ */
907
+ private renderBareBranch(node: IRNode, ctx?: HonoRenderCtx): string {
908
+ if (node.type === 'expression') {
909
+ if (node.expr === 'null' || node.expr === 'undefined') return 'null'
910
+ return node.expr
911
+ }
912
+ if (node.type === 'conditional' && !(node.clientOnly && node.slotId)) {
913
+ return this.renderConditionalBody(node, ctx)
914
+ }
915
+ return this.renderNode(node, ctx)
916
+ }
917
+
954
918
  private wrapWithCondMarker(node: IRNode, content: string, condId: string): string {
955
919
  // Components don't reliably forward bf-c to their root element.
956
920
  // Use comment markers so insert() can find them via TreeWalker.
@@ -1292,13 +1256,14 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
1292
1256
  // runtime sees the decoded string and escapes it on render).
1293
1257
  emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
1294
1258
  emitExpression: (value, name) => {
1259
+ const expr = this.expressionValueToJs(value)
1295
1260
  // Boolean attrs / presence-folded expressions: pass `undefined` when
1296
1261
  // falsy so Hono omits the attribute. Wrap in parens to keep `??`
1297
1262
  // operators inside `expr` from breaking the surrounding `|| undefined`.
1298
1263
  if (isBooleanAttr(name) || value.presenceOrUndefined) {
1299
- return `${name}={(${value.expr}) || undefined}`
1264
+ return `${name}={(${expr}) || undefined}`
1300
1265
  }
1301
- return `${name}={${value.expr}}`
1266
+ return `${name}={${expr}}`
1302
1267
  },
1303
1268
  emitBooleanAttr: (_value, name) => name,
1304
1269
  emitBooleanShorthand: () => '',
@@ -1325,7 +1290,7 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
1325
1290
  // `calc(...)`) from a JS expression. The decoded value re-encodes
1326
1291
  // so the JSX parser hands the component the same decoded string.
1327
1292
  `${name}="${escapeHtml(value.value)}"`,
1328
- emitExpression: (value, name) => `${name}={${value.expr}}`,
1293
+ emitExpression: (value, name) => `${name}={${this.expressionValueToJs(value)}}`,
1329
1294
  emitBooleanAttr: (_value, name) => name,
1330
1295
  emitBooleanShorthand: (_value, name) => name,
1331
1296
  emitTemplate: (value, name) => `${name}={${this.renderTemplateLiteralParts(value.parts)}}`,
@@ -1397,7 +1362,7 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
1397
1362
  private attrValueToJsExpr(value: AttrValue): string {
1398
1363
  switch (value.kind) {
1399
1364
  case 'literal': return JSON.stringify(value.value)
1400
- case 'expression':
1365
+ case 'expression': return this.expressionValueToJs(value)
1401
1366
  case 'spread': return value.expr
1402
1367
  case 'template': return this.renderTemplateLiteralParts(value.parts)
1403
1368
  case 'boolean-shorthand':
@@ -1406,28 +1371,20 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
1406
1371
  }
1407
1372
  }
1408
1373
 
1374
+ /**
1375
+ * Hono runs JS at SSR time, so a structured template can be
1376
+ * re-materialised as the equivalent runtime JS — a `${MAP[KEY]}` lookup
1377
+ * becomes an indexed access against the resolved cases, runtime-identical
1378
+ * to the client emit path. Delegates to the single renderer shared with
1379
+ * that path and with the IR-time component-prop collapse, which also adds
1380
+ * the `preserveTypes` index annotation for this .tsx output (#2565).
1381
+ *
1382
+ * The parts' RAW keys/conditions are used (not their `templateX`
1383
+ * projections) because this output runs inside the destructured-prop
1384
+ * scope of the emitted component.
1385
+ */
1409
1386
  private renderTemplateLiteralParts(parts: IRTemplatePart[]): string {
1410
- let output = '`'
1411
- for (const part of parts) {
1412
- if (part.type === 'string') {
1413
- output += part.value
1414
- } else if (part.type === 'ternary') {
1415
- output += `\${${part.condition} ? '${part.whenTrue}' : '${part.whenFalse}'}`
1416
- } else if (part.type === 'lookup') {
1417
- // Hono runs JS at SSR time, so a `${MAP[KEY]}` lookup can be
1418
- // re-materialised as a runtime indexed access against the
1419
- // resolved cases — byte-identical to the client emit path in
1420
- // `ir-to-client-js/utils.ts`. Use `part.key` (raw JS source)
1421
- // because this output runs inside the destructured-prop scope
1422
- // of the component, mirroring the `'ternary'` branch above.
1423
- const obj = '{' + Object.entries(part.cases).map(
1424
- ([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`
1425
- ).join(', ') + '}'
1426
- output += `\${(${obj})[${part.key}]}`
1427
- }
1428
- }
1429
- output += '`'
1430
- return output
1387
+ return this.renderTemplatePartsAsJs(parts)
1431
1388
  }
1432
1389
 
1433
1390
  }
@@ -5,5 +5,17 @@
5
5
  * JSX namespace as the production runtime so dev builds see identical types.
6
6
  */
7
7
 
8
- export { jsxDEV, Fragment } from 'hono/jsx/jsx-dev-runtime'
8
+ import { jsxDEV as honoJsxDEV, Fragment } from 'hono/jsx/jsx-dev-runtime'
9
+ import { resolveDangerouslySetInnerHTML } from '../resolve-dangerously-set-inner-html.ts'
10
+
11
+ export { Fragment }
9
12
  export type { JSX } from '../jsx-runtime/index.ts'
13
+
14
+ // See `../resolve-dangerously-set-inner-html.ts` for why this is needed —
15
+ // hono's own `jsxFn` throws for a childless `<svg>`/`<head>` element using
16
+ // `dangerouslySetInnerHTML` (https://github.com/piconic-ai/barefootjs/issues/2557).
17
+ // Intrinsic string tags only: a function component must receive the caller's
18
+ // props untouched (it may forward `dangerouslySetInnerHTML` itself).
19
+ export function jsxDEV(tag: string | Function, props: Record<string, unknown>, key?: string) {
20
+ return honoJsxDEV(tag, typeof tag === 'string' ? resolveDangerouslySetInnerHTML(props) : props, key)
21
+ }
@@ -11,7 +11,30 @@
11
11
  */
12
12
 
13
13
  // Runtime functions from hono/jsx.
14
- export { jsx, jsxs, Fragment, jsxAttr, jsxEscape, jsxTemplate } from 'hono/jsx/jsx-runtime'
14
+ import {
15
+ jsx as honoJsx,
16
+ jsxs as honoJsxs,
17
+ Fragment,
18
+ jsxAttr,
19
+ jsxEscape,
20
+ jsxTemplate,
21
+ } from 'hono/jsx/jsx-runtime'
22
+ import { resolveDangerouslySetInnerHTML } from '../resolve-dangerously-set-inner-html.ts'
23
+
24
+ export { Fragment, jsxAttr, jsxEscape, jsxTemplate }
25
+
26
+ // See `../resolve-dangerously-set-inner-html.ts` for why this is needed —
27
+ // hono's own `jsxFn` throws for a childless `<svg>`/`<head>` element using
28
+ // `dangerouslySetInnerHTML` (https://github.com/piconic-ai/barefootjs/issues/2557).
29
+ // Intrinsic string tags only: a function component must receive the caller's
30
+ // props untouched (it may forward `dangerouslySetInnerHTML` itself).
31
+ export function jsx(tag: string | Function, props: Record<string, unknown>, key?: string) {
32
+ return honoJsx(tag, typeof tag === 'string' ? resolveDangerouslySetInnerHTML(props) : props, key)
33
+ }
34
+
35
+ export function jsxs(tag: string | Function, props: Record<string, unknown>, key?: string) {
36
+ return honoJsxs(tag, typeof tag === 'string' ? resolveDangerouslySetInnerHTML(props) : props, key)
37
+ }
15
38
 
16
39
  // Re-export JSX namespace from @barefootjs/jsx, but override Element type for Hono.
17
40
  import type { JSX as BaseJSX } from '@barefootjs/jsx/jsx-runtime'
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared by `./jsx-runtime/index.ts` and `./jsx-dev-runtime/index.ts`.
3
+ *
4
+ * hono's own `jsxFn` (`hono/dist/jsx/base.js`) always wraps `<svg>` /
5
+ * `<head>` children in an internal namespace-context node — even when the
6
+ * caller passed no children at all. That phantom wrapper makes the outer
7
+ * `JSXNode`'s `children.length > 0` true, which trips hono's own
8
+ * "Can only set one of `children` or `props.dangerouslySetInnerHTML`" guard
9
+ * for any childless `<svg>`/`<head>` element using `dangerouslySetInnerHTML`
10
+ * — see https://github.com/piconic-ai/barefootjs/issues/2557.
11
+ *
12
+ * Work around it at the one place BarefootJS's compiled output calls into
13
+ * hono's JSX runtime: when `dangerouslySetInnerHTML` is present and no real
14
+ * `children` prop was given, resolve it into `children` ourselves (mirroring
15
+ * what hono would do internally) before delegating to hono. If the caller
16
+ * supplied *both* real children and `dangerouslySetInnerHTML`, that's a
17
+ * genuine conflict — leave `props` untouched so hono's own guard still
18
+ * rejects it.
19
+ */
20
+ import { raw } from 'hono/html'
21
+
22
+ export function resolveDangerouslySetInnerHTML(props: Record<string, unknown>): Record<string, unknown> {
23
+ if (
24
+ props &&
25
+ 'dangerouslySetInnerHTML' in props &&
26
+ props.dangerouslySetInnerHTML != null &&
27
+ !('children' in props)
28
+ ) {
29
+ const { dangerouslySetInnerHTML, ...rest } = props
30
+ const html = (dangerouslySetInnerHTML as { __html: string }).__html
31
+ return { ...rest, children: raw(html) }
32
+ }
33
+ return props
34
+ }
package/src/scripts.tsx CHANGED
@@ -40,6 +40,7 @@
40
40
 
41
41
  import { useRequestContext } from 'hono/jsx-renderer'
42
42
  import { Fragment } from 'hono/jsx'
43
+ import type { JSX } from 'hono/jsx/jsx-runtime'
43
44
  import { relPathFromComponentsBase, type BarefootBuildManifest } from './app.ts'
44
45
 
45
46
  export type CollectedScript = {
@@ -284,8 +285,8 @@ export function registerComponentPreloads(urls: string[]): string[] {
284
285
  * only ever pass `inlineScripts` (a component with `scriptAssets` but no
285
286
  * `preloadAssets`) keep compiling and behaving exactly as before.
286
287
  */
287
- export function wrapWithInlineScripts(jsx: unknown, inlineScripts: string[], inlinePreloads: string[] = []) {
288
- if (inlineScripts.length === 0 && inlinePreloads.length === 0) return jsx
288
+ export function wrapWithInlineScripts(jsx: unknown, inlineScripts: string[], inlinePreloads: string[] = []): JSX.Element {
289
+ if (inlineScripts.length === 0 && inlinePreloads.length === 0) return jsx as JSX.Element
289
290
  return (
290
291
  <Fragment>
291
292
  {inlinePreloads.map(href => <link rel="modulepreload" crossorigin="" href={href} />)}