@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.
- package/dist/analyzer.d.ts +17 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/compiler.d.ts +21 -5
- package/dist/compiler.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +707 -431
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +60 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/prop-handling.d.ts +4 -7
- package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +26 -2
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/props-binding.d.ts +35 -0
- package/dist/props-binding.d.ts.map +1 -1
- package/dist/types.d.ts +50 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +145 -97
- package/src/__tests__/child-component-ref-not-mirrored.test.ts +90 -0
- package/src/__tests__/ir-to-client-js/imports.test.ts +107 -0
- package/src/__tests__/ir-to-client-js/merge-compiled-client-js-imports.test.ts +138 -0
- package/src/__tests__/issue-2754-rest-spread-needs-slot.test.ts +85 -0
- package/src/__tests__/issue-2756-loop-row-honors-client-only.test.ts +173 -0
- package/src/__tests__/merge-template-imports.test.ts +41 -1
- package/src/__tests__/multi-component-shared-default-import.test.ts +55 -0
- package/src/__tests__/root-key-relay.test.ts +170 -0
- package/src/__tests__/signal-getter-not-called.test.ts +149 -0
- package/src/__tests__/state-only-file-default-import.test.ts +47 -0
- package/src/analyzer.ts +36 -0
- package/src/compiler.ts +94 -104
- package/src/index.ts +1 -1
- package/src/ir-to-client-js/collect-elements.ts +27 -5
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +6 -2
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -2
- package/src/ir-to-client-js/html-template.ts +122 -12
- package/src/ir-to-client-js/imports.ts +178 -5
- package/src/ir-to-client-js/index.ts +5 -0
- package/src/ir-to-client-js/prop-handling.ts +6 -17
- package/src/ir-to-client-js/utils.ts +30 -2
- package/src/jsx-to-ir.ts +480 -52
- package/src/props-binding.ts +51 -0
- 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
|
|
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
|
})
|