@barefootjs/jsx 0.33.2 → 0.33.3

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 (47) hide show
  1. package/dist/analyzer.d.ts +17 -0
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/compiler.d.ts +21 -5
  4. package/dist/compiler.d.ts.map +1 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +707 -431
  8. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/imports.d.ts +60 -2
  12. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/prop-handling.d.ts +4 -7
  14. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/utils.d.ts +26 -2
  16. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  17. package/dist/jsx-to-ir.d.ts.map +1 -1
  18. package/dist/props-binding.d.ts +35 -0
  19. package/dist/props-binding.d.ts.map +1 -1
  20. package/dist/types.d.ts +50 -13
  21. package/dist/types.d.ts.map +1 -1
  22. package/package.json +2 -2
  23. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +145 -97
  24. package/src/__tests__/child-component-ref-not-mirrored.test.ts +90 -0
  25. package/src/__tests__/ir-to-client-js/imports.test.ts +107 -0
  26. package/src/__tests__/ir-to-client-js/merge-compiled-client-js-imports.test.ts +138 -0
  27. package/src/__tests__/issue-2754-rest-spread-needs-slot.test.ts +85 -0
  28. package/src/__tests__/issue-2756-loop-row-honors-client-only.test.ts +173 -0
  29. package/src/__tests__/merge-template-imports.test.ts +41 -1
  30. package/src/__tests__/multi-component-shared-default-import.test.ts +55 -0
  31. package/src/__tests__/root-key-relay.test.ts +170 -0
  32. package/src/__tests__/signal-getter-not-called.test.ts +149 -0
  33. package/src/__tests__/state-only-file-default-import.test.ts +47 -0
  34. package/src/analyzer.ts +36 -0
  35. package/src/compiler.ts +94 -104
  36. package/src/index.ts +1 -1
  37. package/src/ir-to-client-js/collect-elements.ts +27 -5
  38. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +6 -2
  39. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -2
  40. package/src/ir-to-client-js/html-template.ts +122 -12
  41. package/src/ir-to-client-js/imports.ts +178 -5
  42. package/src/ir-to-client-js/index.ts +5 -0
  43. package/src/ir-to-client-js/prop-handling.ts +6 -17
  44. package/src/ir-to-client-js/utils.ts +30 -2
  45. package/src/jsx-to-ir.ts +480 -52
  46. package/src/props-binding.ts +51 -0
  47. package/src/types.ts +47 -13
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Regression tests for #2749: a `ref` on a CHILD-COMPONENT call site must not
3
+ * be mirrored onto the child's root element as a DOM attribute.
4
+ *
5
+ * Measured before the fix: `collectReactiveChildProps` (collect-elements.ts)
6
+ * hand-rolled an `on[A-Z]` event check and had no `ref` case, so a reactive
7
+ * `ref` prop fell through to the generic dynamic-prop mirror and emitted
8
+ *
9
+ * if (__v != null) __scope.setAttribute('ref', String(__v))
10
+ *
11
+ * i.e. the callback's SOURCE TEXT as an attribute value. SSR never emits a
12
+ * `ref` attribute, so only the hydrate leg grew it and the SSR-vs-hydrated
13
+ * snapshot diverged. The same prop was — and still is — passed correctly to
14
+ * `initChild` as `get ref() { … }`; the runtime child then routes it through
15
+ * `applyRestAttrs`, whose `classifyDOMProp` read already returns `kind: 'ref'`
16
+ * and invokes the callback instead of setting an attribute.
17
+ *
18
+ * The fix makes the compile-time mirror read the same classifier. These tests
19
+ * pin BOTH directions: `ref` (and `on*`) must not reach the mirror, and an
20
+ * ordinary reactive attribute prop must still reach it.
21
+ */
22
+
23
+ import { describe, test, expect } from 'bun:test'
24
+ import { compileJSX } from '../compiler'
25
+ import { TestAdapter } from '../adapters/test-adapter'
26
+
27
+ const adapter = new TestAdapter()
28
+
29
+ function getClientJs(source: string, filename: string): string {
30
+ const result = compileJSX(source, filename, { adapter })
31
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
32
+ const clientJs = result.files.find(f => f.type === 'clientJs')
33
+ expect(clientJs).toBeDefined()
34
+ return clientJs!.content
35
+ }
36
+
37
+ const REF_ON_CHILD = `
38
+ 'use client'
39
+ import { createSignal } from '@barefootjs/client'
40
+ import { Row } from './Row'
41
+
42
+ export function Case() {
43
+ const [val, setVal] = createSignal(0)
44
+ const handleMount = (el: Element) => {
45
+ el.setAttribute('data-mounted', String(val()))
46
+ }
47
+ return <Row ref={handleMount} data-x="1"><span>{val()}</span></Row>
48
+ }
49
+ `
50
+
51
+ describe('ref on a child-component call site (#2749)', () => {
52
+ test('is never mirrored into a setAttribute(\'ref\', …) update', () => {
53
+ const js = getClientJs(REF_ON_CHILD, 'Case.tsx')
54
+ expect(js).not.toContain(`setAttribute('ref'`)
55
+ expect(js).not.toContain(`removeAttribute('ref'`)
56
+ })
57
+
58
+ test('still reaches the child through initChild as a getter', () => {
59
+ const js = getClientJs(REF_ON_CHILD, 'Case.tsx')
60
+ expect(js).toContain('initChild(')
61
+ expect(js).toContain('get ref()')
62
+ })
63
+
64
+ test('does not appear as an attribute in the CSR template either', () => {
65
+ const js = getClientJs(REF_ON_CHILD, 'Case.tsx')
66
+ const template = js.slice(js.indexOf('template:'))
67
+ expect(template).not.toContain('ref=')
68
+ })
69
+
70
+ test('an ordinary reactive attribute prop on the same call site is still mirrored', () => {
71
+ // Reverse direction: the fix must not silence the generic mirror. `title`
72
+ // classifies as `attr`, so it keeps its setAttribute update.
73
+ const js = getClientJs(
74
+ `
75
+ 'use client'
76
+ import { createSignal } from '@barefootjs/client'
77
+ import { Row } from './Row'
78
+
79
+ export function Case() {
80
+ const [val, setVal] = createSignal(0)
81
+ const onMount = (el: Element) => { el.setAttribute('data-mounted', '1') }
82
+ return <Row ref={onMount} title={String(val())}>x</Row>
83
+ }
84
+ `,
85
+ 'Case.tsx',
86
+ )
87
+ expect(js).toContain(`setAttribute('title'`)
88
+ expect(js).not.toContain(`setAttribute('ref'`)
89
+ })
90
+ })
@@ -71,6 +71,29 @@ function makeSideEffectImport(source: string): ImportInfo {
71
71
  }
72
72
  }
73
73
 
74
+ /** `import <localName> from 'source'`, optionally alongside named specifiers. */
75
+ function makeDefaultImport(source: string, localName: string, namedSpecifiers: string[] = []): ImportInfo {
76
+ return {
77
+ source,
78
+ specifiers: [
79
+ { name: localName, alias: null, isDefault: true, isNamespace: false },
80
+ ...namedSpecifiers.map(name => ({ name, alias: null, isDefault: false, isNamespace: false })),
81
+ ],
82
+ isTypeOnly: false,
83
+ loc: dummyLoc,
84
+ }
85
+ }
86
+
87
+ /** `import * as localName from 'source'`. */
88
+ function makeNamespaceImport(source: string, localName: string): ImportInfo {
89
+ return {
90
+ source,
91
+ specifiers: [{ name: localName, alias: null, isDefault: false, isNamespace: true }],
92
+ isTypeOnly: false,
93
+ loc: dummyLoc,
94
+ }
95
+ }
96
+
74
97
  describe('collectExternalImports', () => {
75
98
  test('preserves third-party library imports used in generated code', () => {
76
99
  const ir = makeIR([makeImport('zod', ['z'])])
@@ -79,6 +102,90 @@ describe('collectExternalImports', () => {
79
102
  expect(result).toEqual(["import { z } from 'zod'"])
80
103
  })
81
104
 
105
+ // #2767 follow-up: a default-imported binding (e.g. a JSON module's
106
+ // `import lock from '...json' with { type: 'json' }`) was previously
107
+ // always re-emitted as a NAMED import (`import { lock } from '...'`),
108
+ // which is a real, silently-wrong ESM import — the module has no such
109
+ // named export — that only failed once a bundler actually resolved it.
110
+ test('preserves a default import with correct default-import syntax, not named braces', () => {
111
+ const ir = makeIR([makeDefaultImport('../data.json', 'lock')])
112
+ const code = 'lock.adapters'
113
+ const result = collectExternalImports(ir, code)
114
+ expect(result).toEqual(["import lock from '../data.json'"])
115
+ })
116
+
117
+ test('preserves a namespace import with correct namespace-import syntax', () => {
118
+ const ir = makeIR([makeNamespaceImport('./ns-module', 'NS')])
119
+ const code = 'NS.helper()'
120
+ const result = collectExternalImports(ir, code)
121
+ expect(result).toEqual(["import * as NS from './ns-module'"])
122
+ })
123
+
124
+ test('combines a used default specifier with used named specifiers on one declaration', () => {
125
+ const ir = makeIR([makeDefaultImport('./mixed', 'Default', ['helper', 'other'])])
126
+ const code = 'Default(); helper()'
127
+ const result = collectExternalImports(ir, code)
128
+ expect(result).toEqual(["import Default, { helper } from './mixed'"])
129
+ })
130
+
131
+ test('drops an unused default specifier but keeps a used named sibling', () => {
132
+ const ir = makeIR([makeDefaultImport('./mixed', 'Default', ['helper'])])
133
+ const code = 'helper()'
134
+ const result = collectExternalImports(ir, code)
135
+ expect(result).toEqual(["import { helper } from './mixed'"])
136
+ })
137
+
138
+ // `import Foo, * as NS from 'x'` is legal JS, but never emitted as one
139
+ // line — a namespace specifier always gets its own declaration (see
140
+ // `renderUsedImportLines`'s docstring). Pin the two-line output so a
141
+ // future refactor can't silently collapse or drop one of them.
142
+ test('a used default specifier and a used namespace specifier from the same source emit two separate lines', () => {
143
+ const ir = makeIR([
144
+ {
145
+ source: './both',
146
+ specifiers: [
147
+ { name: 'Foo', alias: null, isDefault: true, isNamespace: false },
148
+ { name: 'NS', alias: null, isDefault: false, isNamespace: true },
149
+ ],
150
+ isTypeOnly: false,
151
+ loc: dummyLoc,
152
+ },
153
+ ])
154
+ const code = 'Foo(); NS.helper()'
155
+ const result = collectExternalImports(ir, code)
156
+ expect(result).toEqual(["import Foo from './both'", "import * as NS from './both'"])
157
+ })
158
+
159
+ test('a default-imported COMPONENT is skipped, same as a named-imported one', () => {
160
+ const ir = makeIR([makeDefaultImport('./button', 'Button')], ['Button'])
161
+ const code = '<Button/>'
162
+ const result = collectExternalImports(ir, code)
163
+ expect(result).toEqual([])
164
+ })
165
+
166
+ test('rewrites a default import to the .client.js sibling when its source is a client-signal import', () => {
167
+ const ir = makeIR([makeDefaultImport('./state', 'store')])
168
+ ir.metadata.clientSignalImportSources = new Set(['./state'])
169
+ const code = 'store.count'
170
+ const result = collectExternalImports(ir, code)
171
+ expect(result).toEqual(["import store from './state.client.js'"])
172
+ })
173
+
174
+ test('rewrites a namespace import to the .client.js sibling when its source is a client-signal import', () => {
175
+ const ir = makeIR([makeNamespaceImport('./state', 'NS')])
176
+ ir.metadata.clientSignalImportSources = new Set(['./state'])
177
+ const code = 'NS.count'
178
+ const result = collectExternalImports(ir, code)
179
+ expect(result).toEqual(["import * as NS from './state.client.js'"])
180
+ })
181
+
182
+ test('drops an unused namespace specifier entirely', () => {
183
+ const ir = makeIR([makeNamespaceImport('./ns-module', 'NS')])
184
+ const code = 'somethingElse()'
185
+ const result = collectExternalImports(ir, code)
186
+ expect(result).toEqual([])
187
+ })
188
+
82
189
  test('skips @barefootjs/client imports', () => {
83
190
  const ir = makeIR([makeImport('@barefootjs/client', ['createSignal'])])
84
191
  const code = 'createSignal(0)'
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `mergeCompiledClientJsImports` merges sibling components' compiled
3
+ * client-JS blobs (`compileMultipleComponents`'s two call sites) via a
4
+ * real `ts.createSourceFile` AST walk — never a text/regex line scan — so
5
+ * a string or template-literal VALUE that merely contains a line starting
6
+ * with `import ` can never be torn out of its literal and hoisted into the
7
+ * imports block. Mirrors `combine-client-js.test.ts`'s `#1702` regression
8
+ * test for `parseAndMerge`, the established precedent this function
9
+ * follows (see its own docstring).
10
+ */
11
+ import { describe, test, expect } from 'bun:test'
12
+ import { mergeCompiledClientJsImports } from '../../ir-to-client-js/imports'
13
+
14
+ describe('mergeCompiledClientJsImports', () => {
15
+ test('does not treat an import-shaped line inside a string/template literal as a real import (#1702-class)', () => {
16
+ // A docs component embeds a code sample whose CONTENTS contain a
17
+ // line that starts with `import `. A line-based scan would tear that
18
+ // fake import out of the literal and hoist it into the imports
19
+ // block, leaving `hydrate` undefined and corrupting the literal.
20
+ const sample = [
21
+ '`Example usage:',
22
+ '',
23
+ "import { createSignal } from '@barefootjs/client'",
24
+ '',
25
+ 'export function Counter() {}`',
26
+ ].join('\n')
27
+
28
+ const componentA = [
29
+ "import { hydrate, createSignal } from '@barefootjs/client/runtime'",
30
+ `const SAMPLE = ${sample}`,
31
+ "hydrate('DocsExample', (el) => {})",
32
+ ].join('\n')
33
+
34
+ const merged = mergeCompiledClientJsImports([componentA])
35
+
36
+ // The real runtime import survives as its own top-level declaration,
37
+ // exactly once — not duplicated by the fake import line inside the
38
+ // string literal.
39
+ const realImportOccurrences = (merged.match(/^import \{ hydrate, createSignal \} from '@barefootjs\/client\/runtime'$/gm) ?? []).length
40
+ expect(realImportOccurrences).toBe(1)
41
+ // The sample string is untouched — the fake import line inside it is
42
+ // still there, still nested inside the backtick literal, not hoisted
43
+ // out as a separate top-level statement.
44
+ expect(merged).toContain('const SAMPLE = `Example usage:')
45
+ expect(merged).toContain("import { createSignal } from '@barefootjs/client'\n\nexport function Counter() {}`")
46
+ })
47
+
48
+ // #2767 follow-up: the same duplicate-binding SyntaxError F1 pinned for
49
+ // the old regex-based merge, now exercised through the AST-based path.
50
+ test('folds a default import shared across sibling components instead of redeclaring the binding', () => {
51
+ const componentA = [
52
+ "import cfg from './config'",
53
+ "hydrate('CompA', (el) => {})",
54
+ ].join('\n')
55
+ const componentB = [
56
+ "import cfg, { helper } from './config'",
57
+ "hydrate('CompB', (el) => {})",
58
+ ].join('\n')
59
+
60
+ const merged = mergeCompiledClientJsImports([componentA, componentB])
61
+
62
+ expect(merged).toContain("import cfg, { helper } from './config'")
63
+ // Exactly one declaration — not one per sibling component.
64
+ expect((merged.match(/\bcfg\b/g) ?? []).length).toBe(1)
65
+ })
66
+
67
+ test('keeps an unresolved @bf-child: placeholder import (does not drop it, unlike parent-child inlining)', () => {
68
+ const componentA = [
69
+ "import { hydrate, initChild } from '@barefootjs/client/runtime'",
70
+ "import '/* @bf-child:Child */'",
71
+ "hydrate('Parent', (el) => { initChild('Child', el, {}) })",
72
+ ].join('\n')
73
+
74
+ const merged = mergeCompiledClientJsImports([componentA])
75
+
76
+ expect(merged).toContain("import '/* @bf-child:Child */'")
77
+ })
78
+
79
+ test('dedupes an identical @bf-child: placeholder shared by two sibling components', () => {
80
+ const componentA = [
81
+ "import '/* @bf-child:Shared */'",
82
+ "hydrate('A', (el) => {})",
83
+ ].join('\n')
84
+ const componentB = [
85
+ "import '/* @bf-child:Shared */'",
86
+ "hydrate('B', (el) => {})",
87
+ ].join('\n')
88
+
89
+ const merged = mergeCompiledClientJsImports([componentA, componentB])
90
+
91
+ expect((merged.match(/@bf-child:Shared/g) ?? []).length).toBe(1)
92
+ })
93
+
94
+ // pullfrog[bot] review of #2769: a combined `import Default, * as NS
95
+ // from '…'` line has a default clause AND a namespace binding — a
96
+ // naive "has a default clause" check would route it into the fold
97
+ // branch and silently drop the namespace half, since only named
98
+ // bindings are read there. No current producer emits this combined
99
+ // shape (`renderUsedImportLines` always splits a used default+namespace
100
+ // pair into two lines), but the classification must stay correct
101
+ // independent of that invariant.
102
+ test('keeps a combined default+namespace import verbatim, not silently dropping the namespace half', () => {
103
+ const componentA = [
104
+ "import Default, * as NS from './combined'",
105
+ "hydrate('A', (el) => {})",
106
+ ].join('\n')
107
+
108
+ const merged = mergeCompiledClientJsImports([componentA])
109
+
110
+ expect(merged).toContain("import Default, * as NS from './combined'")
111
+ })
112
+
113
+ test('preserves a namespace import on its own line, deduped by exact source+name across components', () => {
114
+ const componentA = [
115
+ "import * as util from './util'",
116
+ "hydrate('A', (el) => {})",
117
+ ].join('\n')
118
+ const componentB = [
119
+ "import * as util from './util'",
120
+ "hydrate('B', (el) => {})",
121
+ ].join('\n')
122
+
123
+ const merged = mergeCompiledClientJsImports([componentA, componentB])
124
+
125
+ expect((merged.match(/import \* as util from '\.\/util'/g) ?? []).length).toBe(1)
126
+ })
127
+
128
+ test('preserves both components\' code sections after import extraction', () => {
129
+ const componentA = "import { hydrate } from '@barefootjs/client/runtime'\nhydrate('A', (el) => {})"
130
+ const componentB = "import { hydrate } from '@barefootjs/client/runtime'\nhydrate('B', (el) => {})"
131
+
132
+ const merged = mergeCompiledClientJsImports([componentA, componentB])
133
+
134
+ expect(merged).toContain("hydrate('A', (el) => {})")
135
+ expect(merged).toContain("hydrate('B', (el) => {})")
136
+ expect((merged.match(/^import \{ hydrate \}/gm) ?? []).length).toBe(1)
137
+ })
138
+ })
@@ -0,0 +1,85 @@
1
+ /**
2
+ * #2754 — a `{...props}` / `{...rest}` forward is the one attribute source
3
+ * no template can carry (its keys are unknown at compile time), so the
4
+ * runtime's `applyRestAttrs` is the only thing that can apply it. That
5
+ * call is addressed by slot id and lives in `init`, and BOTH gates used to
6
+ * miss the stateless case:
7
+ *
8
+ * - Phase 1 gave the host element no slot id, because a spread trips
9
+ * none of the reactivity heuristics; and
10
+ * - `needsClientJs` did not count `restAttrElements`, so even with a
11
+ * slot the component fell to the template-only mount with `init` empty.
12
+ *
13
+ * SSR and hydration hid both: the SSR markup already carries the caller's
14
+ * attributes. Only a pure `createComponent` mount showed the drop.
15
+ */
16
+ import { describe, test, expect } from 'bun:test'
17
+ import { compileJSX } from '../compiler'
18
+ import { TestAdapter } from '../adapters/test-adapter'
19
+
20
+ const adapter = new TestAdapter()
21
+
22
+ function compile(source: string) {
23
+ const result = compileJSX(source, 'Repro.tsx', { adapter })
24
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
25
+ return {
26
+ clientJs: result.files.find(f => f.type === 'clientJs')!.content,
27
+ template: result.files.find(f => f.type === 'markedTemplate')!.content,
28
+ }
29
+ }
30
+
31
+ describe('#2754 — a caller-props forward earns a slot and an init', () => {
32
+ test('a stateless `{ children, ...props }` forwarder emits applyRestAttrs against a slot', () => {
33
+ const { clientJs, template } = compile(`
34
+ "use client";
35
+ export function Plain({ children, ...props }: { children?: unknown; [k: string]: unknown }) {
36
+ return <span className="plain" {...props}>{children}</span>
37
+ }
38
+ `)
39
+ expect(clientJs).toContain('applyRestAttrs')
40
+ // The slot the call addresses must exist in the SSR markup too, or
41
+ // hydration's `$(__scope, 's0')` finds nothing.
42
+ expect(clientJs).toMatch(/const \[_s\d\] = \$\(__scope, 's\d'\)/)
43
+ expect(template).toMatch(/bf="s\d"/)
44
+ // `children` and the statically-set `class` stay excluded so the
45
+ // forward neither double-renders children nor re-emits `class`.
46
+ expect(clientJs).toMatch(/applyRestAttrs\(_s\d, _p, \["children","class"\]\)/)
47
+ })
48
+
49
+ test('a whole undestructured props object spread gets the same treatment', () => {
50
+ const { clientJs } = compile(`
51
+ "use client";
52
+ export function Whole(props: { [k: string]: unknown }) {
53
+ return <span className="plain" {...props} />
54
+ }
55
+ `)
56
+ expect(clientJs).toContain('applyRestAttrs')
57
+ })
58
+
59
+ test('an alias hop onto the rest binding resolves the same way (#2723 shape)', () => {
60
+ const { clientJs } = compile(`
61
+ "use client";
62
+ export function Aliased({ children, ...props }: { children?: unknown; [k: string]: unknown }) {
63
+ const props__alias = props
64
+ return <span className="plain" {...props__alias}>{children}</span>
65
+ }
66
+ `)
67
+ expect(clientJs).toContain('applyRestAttrs')
68
+ })
69
+
70
+ test('a spread of an ordinary object still inlines into the template and earns no slot', () => {
71
+ // Reverse direction: only the caller-props forward is unknowable at
72
+ // compile time. An ordinary object spread is fully emitted by both
73
+ // templates and must not start allocating slot ids.
74
+ const { clientJs, template } = compile(`
75
+ "use client";
76
+ const extra = { title: 'x' }
77
+ export function Ordinary() {
78
+ return <span className="plain" {...extra} />
79
+ }
80
+ `)
81
+ expect(clientJs).toContain('spreadAttrs')
82
+ expect(clientJs).not.toContain('applyRestAttrs')
83
+ expect(template).not.toMatch(/bf="s\d"/)
84
+ })
85
+ })
@@ -0,0 +1,173 @@
1
+ /**
2
+ * #2756 — a client-built row/branch must carry the SAME attributes a
3
+ * hydration-reused (SSR-origin) row carries.
4
+ *
5
+ * `lowerFormControlValueSsr` already lowers a controlled `<textarea>` /
6
+ * `<select>` `value` in the SHARED IR: the attr becomes `clientOnly` and
7
+ * the value is re-expressed as element content / per-option `selected`,
8
+ * so every SSR adapter omits the attribute. `irToHtmlTemplate` — the
9
+ * builder for keyed-loop rows and conditional branches — ignored that
10
+ * flag and baked `value="…"` back in, so a rebuilt row and a reused row
11
+ * disagreed the moment a row-count change made both coexist in one list.
12
+ *
13
+ * Each assertion here is paired with the effect that OWNS the value, so
14
+ * "the attribute is gone" can never be satisfied by dropping the binding.
15
+ */
16
+ import { describe, test, expect } from 'bun:test'
17
+ import { compileJSX } from '../compiler'
18
+ import { TestAdapter } from '../adapters/test-adapter'
19
+
20
+ const adapter = new TestAdapter()
21
+
22
+ function clientJs(source: string): string {
23
+ const result = compileJSX(source, 'Repro.tsx', { adapter })
24
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
25
+ return result.files.find(f => f.type === 'clientJs')!.content
26
+ }
27
+
28
+ /** The `__tpl.innerHTML = \`…\`` / `insert(... html: \`…\`)` builder strings. */
29
+ function builderTemplates(content: string): string[] {
30
+ return [...content.matchAll(/innerHTML = `([^`]*)`/g)].map(m => m[1])
31
+ .concat([...content.matchAll(/html: `([^`]*)`/g)].map(m => m[1]))
32
+ }
33
+
34
+ describe('#2756 — client-built rows honour clientOnly', () => {
35
+ test('a keyed-loop row builder omits the controlled textarea `value` attribute and keeps the child text', () => {
36
+ const content = clientJs(`
37
+ "use client";
38
+ import { createSignal } from '@barefootjs/client'
39
+ export function LoopTextarea() {
40
+ const [val, setVal] = createSignal(0)
41
+ const [items] = createSignal([1, 2, 3])
42
+ return (
43
+ <ul>
44
+ {items().filter(i => i > 0).map(i => (
45
+ <li key={i}>
46
+ <textarea value={val()} onInput={() => setVal(1)} />
47
+ </li>
48
+ ))}
49
+ </ul>
50
+ )
51
+ }
52
+ `)
53
+ const rowTemplates = builderTemplates(content).filter(t => t.includes('<textarea'))
54
+ expect(rowTemplates.length).toBeGreaterThan(0)
55
+ for (const tpl of rowTemplates) {
56
+ expect(tpl).not.toContain('value=')
57
+ // The SSR projection of the same value — element content — must stay.
58
+ expect(tpl).toMatch(/<textarea[^>]*>\$\{/)
59
+ }
60
+ // The effect that owns the value is still emitted, so the attribute is
61
+ // absent because the effect applies it, not because nothing does.
62
+ expect(content).toContain(`'value' in`)
63
+ })
64
+
65
+ test('a conditional-branch builder omits it too, and still binds the value effect', () => {
66
+ const content = clientJs(`
67
+ "use client";
68
+ import { createSignal } from '@barefootjs/client'
69
+ export function CondTextarea() {
70
+ const [on, setOn] = createSignal(true)
71
+ const [v, setV] = createSignal('x')
72
+ return <div>{on() ? <textarea value={v()} onInput={() => setV('y')} /> : <p>off</p>}</div>
73
+ }
74
+ `)
75
+ const branchTemplates = builderTemplates(content).filter(t => t.includes('<textarea'))
76
+ expect(branchTemplates.length).toBeGreaterThan(0)
77
+ for (const tpl of branchTemplates) expect(tpl).not.toContain('value=')
78
+ expect(content).toContain('createDisposableEffect')
79
+ })
80
+
81
+ test('a loop row keeps per-option `selected` — the select value projection is not collateral', () => {
82
+ const content = clientJs(`
83
+ "use client";
84
+ import { createSignal } from '@barefootjs/client'
85
+ export function LoopSelect() {
86
+ const [val, setVal] = createSignal('a')
87
+ const [rows] = createSignal([1, 2])
88
+ return (
89
+ <ul>
90
+ {rows().filter(r => r > 0).map(r => (
91
+ <li key={r}>
92
+ <select value={val()}>
93
+ <option value="a">A</option>
94
+ <option value="b">B</option>
95
+ </select>
96
+ </li>
97
+ ))}
98
+ </ul>
99
+ )
100
+ }
101
+ `)
102
+ const rowTemplates = builderTemplates(content).filter(t => t.includes('<select'))
103
+ expect(rowTemplates.length).toBeGreaterThan(0)
104
+ for (const tpl of rowTemplates) {
105
+ // The `<select …>` opening tag only — `<option value="a">` is the
106
+ // literal option value and must survive.
107
+ const selectTag = tpl.slice(tpl.indexOf('<select'), tpl.indexOf('<option'))
108
+ expect(selectTag).not.toMatch(/value=/)
109
+ expect(tpl).toContain("'selected'")
110
+ }
111
+ })
112
+
113
+ test('the composite-row placeholder builder omits it too', () => {
114
+ // `irToPlaceholderTemplate` is the twin builder used when a row also
115
+ // hosts a child component (components become `data-bf-ph` placeholders).
116
+ // Same contract, separate function — it had the same gap.
117
+ const content = clientJs(`
118
+ "use client";
119
+ import { createSignal } from '@barefootjs/client'
120
+ function Badge({ label }: { label: string }) { return <em>{label}</em> }
121
+ export function CompositeRows() {
122
+ const [val, setVal] = createSignal('a')
123
+ const [rows] = createSignal([{ id: 1, label: 'x' }])
124
+ return (
125
+ <ul>
126
+ {rows().map(row => (
127
+ <li key={row.id}>
128
+ <Badge label={row.label} />
129
+ <textarea value={val()} onInput={() => setVal('b')} />
130
+ </li>
131
+ ))}
132
+ </ul>
133
+ )
134
+ }
135
+ `)
136
+ const rowTemplates = builderTemplates(content).filter(t => t.includes('data-bf-ph'))
137
+ expect(rowTemplates.length).toBeGreaterThan(0)
138
+ for (const tpl of rowTemplates) {
139
+ expect(tpl).toContain('<textarea')
140
+ expect(tpl).not.toMatch(/\bvalue=/)
141
+ }
142
+ })
143
+
144
+ test('an ordinary reactive attribute on the same row IS still emitted by the builder', () => {
145
+ // Reverse direction, on one row so both halves are read off the same
146
+ // builder string: `clientOnly` is the ONLY thing now deferred. The
147
+ // row's own `title` / `data-n` must keep their inline emission.
148
+ const content = clientJs(`
149
+ "use client";
150
+ import { createSignal } from '@barefootjs/client'
151
+ export function MixedRow() {
152
+ const [val, setVal] = createSignal('a')
153
+ const [rows] = createSignal([1, 2])
154
+ return (
155
+ <ul>
156
+ {rows().filter(r => r > 0).map(r => (
157
+ <li key={r} title={String(r)} data-n={r}>
158
+ <textarea value={val()} onInput={() => setVal('b')} />
159
+ </li>
160
+ ))}
161
+ </ul>
162
+ )
163
+ }
164
+ `)
165
+ const rowTemplates = builderTemplates(content).filter(t => t.includes('<textarea'))
166
+ expect(rowTemplates.length).toBeGreaterThan(0)
167
+ for (const tpl of rowTemplates) {
168
+ expect(tpl).toMatch(/title=/)
169
+ expect(tpl).toMatch(/data-n=/)
170
+ expect(tpl).not.toMatch(/\bvalue=/)
171
+ }
172
+ })
173
+ })
@@ -56,7 +56,7 @@ describe('mergeTemplateImports', () => {
56
56
  expect(out).toBe("import { Foo, Baz } from 'x'\nimport type { Bar } from 'x'")
57
57
  })
58
58
 
59
- test('passes through and dedupes side-effect / default imports by line', () => {
59
+ test('passes through and dedupes side-effect imports, and a lone default import, by line', () => {
60
60
  const out = mergeTemplateImports([
61
61
  "import './a.css'",
62
62
  "import Foo from 'foo'",
@@ -65,4 +65,44 @@ describe('mergeTemplateImports', () => {
65
65
  ])
66
66
  expect(out).toBe("import './a.css'\nimport Foo from 'foo'\nimport { x } from 'm'")
67
67
  })
68
+
69
+ // #2767 follow-up: two sibling components in a multi-component file both
70
+ // compile from the SAME module-scope `import cfg from 'lib'` declaration,
71
+ // but each component's own compiled output only lists the specifiers IT
72
+ // uses — so one component's output can carry `import cfg from 'lib'` and
73
+ // another's `import cfg, { helper } from 'lib'`. Exact-line dedup keeps
74
+ // BOTH (they're different strings), redeclaring `cfg` — a hard
75
+ // `SyntaxError`. Folding by source must collapse them into one line.
76
+ test('folds a default import shared across sibling components instead of redeclaring the binding', () => {
77
+ const out = mergeTemplateImports([
78
+ "import cfg from 'lib'",
79
+ "import cfg, { helper } from 'lib'",
80
+ ])
81
+ expect(out).toBe("import cfg, { helper } from 'lib'")
82
+ expect((out.match(/\bcfg\b/g) ?? []).length).toBe(1)
83
+ })
84
+
85
+ test('folds a default import with named specifiers arriving in the opposite order', () => {
86
+ const out = mergeTemplateImports([
87
+ "import cfg, { helperA } from 'lib'",
88
+ "import cfg, { helperB } from 'lib'",
89
+ ])
90
+ expect(out).toBe("import cfg, { helperA, helperB } from 'lib'")
91
+ })
92
+
93
+ test('keeps a default import separate from a differently-sourced named import', () => {
94
+ const out = mergeTemplateImports([
95
+ "import cfg from 'lib'",
96
+ "import { helper } from 'other-lib'",
97
+ ])
98
+ expect(out).toBe("import cfg from 'lib'\nimport { helper } from 'other-lib'")
99
+ })
100
+
101
+ test('dedupes an identical namespace import shared across sibling components', () => {
102
+ const out = mergeTemplateImports([
103
+ "import * as NS from 'lib'",
104
+ "import * as NS from 'lib'",
105
+ ])
106
+ expect(out).toBe("import * as NS from 'lib'")
107
+ })
68
108
  })