@barefootjs/hono 0.29.0 → 0.30.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.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Aliased (renaming) destructured props (#2460).
3
+ *
4
+ * The Hono adapter used to build its SSR props destructure keyed by
5
+ * `ParamInfo.name` (the LOCAL binding) instead of `sourceName ?? name`
6
+ * (the CALLER-facing key — see `ParamInfo.sourceName`'s docstring in
7
+ * `packages/jsx/src/types.ts`). For a renaming destructure
8
+ * (`{ n: count }`) the emitted SSR function read a `count` property the
9
+ * caller never passed (the caller passes `n`), so the local binding was
10
+ * always `undefined`.
11
+ *
12
+ * These tests render through Hono end-to-end (`renderHonoComponent`) and
13
+ * assert the actual rendered VALUE, not just the emitted destructure
14
+ * text — a byte-identical destructure with the wrong runtime binding
15
+ * would still pass a text-only assertion.
16
+ */
17
+
18
+ import { describe, test, expect } from 'bun:test'
19
+ import { compileJSX } from '@barefootjs/jsx'
20
+ import { renderHonoComponent } from '../test-render'
21
+ import { HonoAdapter } from '../adapter/hono-adapter'
22
+
23
+ describe('aliased destructured props (#2460)', () => {
24
+ test('{ text, n: count } — aliased, no default: the caller-supplied `n` reaches the `count` binding', async () => {
25
+ const html = await renderHonoComponent({
26
+ adapter: new HonoAdapter(),
27
+ source: `
28
+ export function Badge({ text, n: count }: { text: string; n: number }) {
29
+ return <span>{text}:{count}</span>
30
+ }
31
+ `,
32
+ props: { text: 'hello', n: 7 },
33
+ })
34
+
35
+ expect(html).toContain('hello')
36
+ expect(html).toContain(':<!--bf:s1-->7<!--/-->')
37
+ })
38
+
39
+ test('{ text, n: count = 7 } — aliased with a destructuring default, caller omits `n`', async () => {
40
+ const html = await renderHonoComponent({
41
+ adapter: new HonoAdapter(),
42
+ source: `
43
+ export function Badge({ text, n: count = 7 }: { text: string; n?: number }) {
44
+ return <span>{text}:{count}</span>
45
+ }
46
+ `,
47
+ props: { text: 'hello' },
48
+ })
49
+
50
+ expect(html).toContain(':<!--bf:s1-->7<!--/-->')
51
+ })
52
+
53
+ test('{ text, n: count = 7 } — aliased with a default, caller-supplied `n` overrides it', async () => {
54
+ const html = await renderHonoComponent({
55
+ adapter: new HonoAdapter(),
56
+ source: `
57
+ export function Badge({ text, n: count = 7 }: { text: string; n?: number }) {
58
+ return <span>{text}:{count}</span>
59
+ }
60
+ `,
61
+ props: { text: 'hello', n: 42 },
62
+ })
63
+
64
+ expect(html).toContain(':<!--bf:s1-->42<!--/-->')
65
+ })
66
+
67
+ test('{ text, n } — un-aliased: emitted destructure text is byte-identical to the pre-fix shorthand', () => {
68
+ const source = `
69
+ export function Badge({ text, n }: { text: string; n: number }) {
70
+ return <span>{text}:{n}</span>
71
+ }
72
+ `
73
+ const result = compileJSX(source, 'Badge.tsx', { adapter: new HonoAdapter() })
74
+ const errors = result.errors.filter((e) => e.severity === 'error')
75
+ expect(errors).toEqual([])
76
+ const tmpl = result.files.find((f) => f.type === 'markedTemplate')
77
+ expect(tmpl).toBeDefined()
78
+ // Plain shorthand destructure — no `sourceKey: localName` rename text
79
+ // for an un-aliased prop.
80
+ expect(tmpl!.content).toContain('export function Badge({ text, n, __instanceId,')
81
+ })
82
+
83
+ test('a renamed `class` prop emits the rename `class: className`, not a bare `className`', () => {
84
+ // `class` is a reserved word and can never be an un-aliased binding
85
+ // identifier (`{ class }` is a syntax error), so the only way a
86
+ // `class`-named caller prop reaches `propsParams` through a
87
+ // destructured component is via an explicit alias.
88
+ const source = `
89
+ export function Chip({ class: className }: { class: string }) {
90
+ return <span class={className}>x</span>
91
+ }
92
+ `
93
+ const result = compileJSX(source, 'Chip.tsx', { adapter: new HonoAdapter() })
94
+ const errors = result.errors.filter((e) => e.severity === 'error')
95
+ expect(errors).toEqual([])
96
+ const tmpl = result.files.find((f) => f.type === 'markedTemplate')
97
+ expect(tmpl).toBeDefined()
98
+ expect(tmpl!.content).toContain('class: className')
99
+ })
100
+
101
+ test('aliased prop reaches client-side hydration serialization with the correct value', async () => {
102
+ // The client init function reads `_p.<localName>` (props-extraction
103
+ // phase), so the SSR-serialized `bf-p` blob must carry the correct
104
+ // value under the LOCAL key (`count`) — the rename only affects the
105
+ // caller-facing key, not the local binding the hydration bridge uses.
106
+ const html = await renderHonoComponent({
107
+ adapter: new HonoAdapter(),
108
+ source: `
109
+ 'use client'
110
+ import { createEffect } from '@barefootjs/client'
111
+ export function Badge({ text, n: count }: { text: string; n: number }) {
112
+ createEffect(() => {
113
+ console.log(count)
114
+ })
115
+ return <span>{text}:{count}</span>
116
+ }
117
+ `,
118
+ props: { text: 'hello', n: 7 },
119
+ })
120
+
121
+ // The rendered value is correct...
122
+ expect(html).toContain(':<!--bf:s1-->7<!--/-->')
123
+ // ...and the serialized hydration payload carries it under the LOCAL
124
+ // binding name, which is what the generated client JS's
125
+ // `const count = _p.count` extraction reads.
126
+ expect(html).toMatch(/bf-p="[^"]*count[^"]*7/)
127
+ })
128
+ })
@@ -35,6 +35,7 @@ import {
35
35
  emitAttrValue,
36
36
  buildLoopChainExpr,
37
37
  } from '@barefootjs/jsx'
38
+ import ts from 'typescript'
38
39
 
39
40
  /**
40
41
  * Hono adapter's IRNode render context: which surrounding render
@@ -44,7 +45,6 @@ import {
44
45
  */
45
46
  type HonoRenderCtx = {
46
47
  isRootOfClientComponent?: boolean
47
- isInsideLoop?: boolean
48
48
  isLoopItemRoot?: boolean
49
49
  }
50
50
  import { BF_SCOPE, BF_HOST, BF_AT, BF_ROOT, BF_PROPS, BF_REGION, escapeHtml } from '@barefootjs/shared'
@@ -99,6 +99,28 @@ function applyHonoLoopChain(loop: IRLoop): string {
99
99
  })
100
100
  }
101
101
 
102
+ /**
103
+ * Authoritative IdentifierName classification for a destructure-pattern
104
+ * property key, built on TS's own `isIdentifierStart` / `isIdentifierPart`
105
+ * primitives (Unicode-aware, stays aligned with what TS itself accepts as
106
+ * a bare property key). Mirrors the `isIdent` precedent in
107
+ * `jsx-to-ir.ts` (#1244) — a source key like `data-key` or `aria-label`
108
+ * can't be emitted as a bare `key: local` destructure and must be quoted
109
+ * (`"data-key": local`).
110
+ */
111
+ function isIdentifierName(key: string): boolean {
112
+ if (key.length === 0) return false
113
+ for (let i = 0; i < key.length; ) {
114
+ const cp = key.codePointAt(i)!
115
+ const ok = i === 0
116
+ ? ts.isIdentifierStart(cp, ts.ScriptTarget.Latest)
117
+ : ts.isIdentifierPart(cp, ts.ScriptTarget.Latest)
118
+ if (!ok) return false
119
+ i += cp > 0xFFFF ? 2 : 1
120
+ }
121
+ return true
122
+ }
123
+
102
124
  export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderCtx> {
103
125
  name = 'hono'
104
126
  extension = '.tsx'
@@ -506,8 +528,22 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
506
528
  const parts: string[] = []
507
529
  const propsParams = ir.metadata.propsParams
508
530
  .map((p: ParamInfo) => {
509
- const paramName = p.name === 'class' ? 'className' : p.name
510
- return p.defaultValue ? `${paramName} = ${p.defaultValue}` : paramName
531
+ // The caller-facing key is `sourceName ?? name` (ParamInfo's own
532
+ // rule) `name` is only ever the LOCAL binding. Emit the plain
533
+ // shorthand when they match (byte-identical to before this was
534
+ // rename-aware); emit a `key: local` rename otherwise. This also
535
+ // covers the `class` → `className` rename correctly: a source
536
+ // prop literally named `class` can only reach `propsParams` via
537
+ // an aliased destructure (`{ class: className }` — `class` is a
538
+ // reserved word, so it can never be an un-aliased binding), which
539
+ // already sets `sourceName: 'class'` and is handled by the rename
540
+ // branch below (`class: className`), not a bare `className`.
541
+ const callerKey = p.sourceName ?? p.name
542
+ const localName = p.name
543
+ const binding = callerKey === localName
544
+ ? localName
545
+ : `${isIdentifierName(callerKey) ? callerKey : JSON.stringify(callerKey)}: ${localName}`
546
+ return p.defaultValue ? `${binding} = ${p.defaultValue}` : binding
511
547
  })
512
548
  .join(', ')
513
549
  if (propsParams) {
@@ -874,7 +910,10 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
874
910
  const indexParam = loop.index ? `, ${loop.index}${indexAnnotation}` : ''
875
911
  // Push loop key info for data-key attribute generation on loop items
876
912
  this.loopKeyStack.push({ key: loop.key, param: loop.param })
877
- // Render children with isInsideLoop flag so components generate their own scope IDs
913
+ // Render children with isLoopItemRoot so a DIRECT component member gets
914
+ // its own randomized scope id (matching `IRComponent.loopItemRoot`,
915
+ // #2444) rather than deriving from parent scope + slot like an
916
+ // ordinarily-slotted child.
878
917
  const children = this.renderChildrenInLoop(loop.children)
879
918
  this.loopKeyStack.pop()
880
919
 
@@ -969,7 +1008,7 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
969
1008
  }
970
1009
 
971
1010
  private renderChildrenInLoop(children: IRNode[]): string {
972
- return children.map((child) => this.renderNode(child, { isInsideLoop: true, isLoopItemRoot: true })).join('')
1011
+ return children.map((child) => this.renderNode(child, { isLoopItemRoot: true })).join('')
973
1012
  }
974
1013
 
975
1014
  /**
@@ -1040,7 +1079,7 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
1040
1079
  )
1041
1080
  }
1042
1081
 
1043
- renderComponent(comp: IRComponent, ctx?: { isRootOfClientComponent?: boolean; isInsideLoop?: boolean; isLoopItemRoot?: boolean }): string {
1082
+ renderComponent(comp: IRComponent, ctx?: { isRootOfClientComponent?: boolean; isLoopItemRoot?: boolean }): string {
1044
1083
  const props = this.renderComponentProps(comp)
1045
1084
  const children = this.renderChildren(comp.children)
1046
1085
 
@@ -1066,10 +1105,11 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
1066
1105
  // Also pass bf-s for asChild/Slot patterns where the component
1067
1106
  // forwards props to a DOM element via {...props}.
1068
1107
  scopeAttr += ` ${BF_SCOPE}={__scopeId}`
1069
- } else if (ctx?.isInsideLoop) {
1070
- // Components inside loops should generate their own unique scope IDs
1071
- // Pass __bfScope so they use it as fallback but generate unique IDs
1072
- // This ensures each loop iteration has a distinct component instance
1108
+ } else if (comp.loopItemRoot) {
1109
+ // A component that is the DIRECT root of a loop row owns its own
1110
+ // per-row identity generate a unique scope id rather than deriving
1111
+ // from parent scope + slot (#2444). Pass __bfScope so it uses it as
1112
+ // fallback but still generates a unique id per iteration.
1073
1113
  if (comp.slotId) {
1074
1114
  scopeAttr = ` __bfScope={\`\${__scopeId}_${comp.slotId}\`}${bfChildAttr}${bfMountAttr}`
1075
1115
  } else {