@barefootjs/jsx 0.33.1 → 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/expression-parser.d.ts +14 -0
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +817 -457
- 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/emit-registration.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +7 -7
- 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/index.d.ts.map +1 -1
- package/dist/ir-to-client-js/prop-handling.d.ts +30 -0
- package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts +5 -0
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/rewrite-props-object.d.ts +36 -8
- package/dist/ir-to-client-js/rewrite-props-object.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 +51 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +165 -98
- package/src/__tests__/binding-scope-ratchet.test.ts +5 -1
- package/src/__tests__/child-component-ref-not-mirrored.test.ts +90 -0
- package/src/__tests__/client-js-generation.test.ts +11 -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-2723-prop-alias-reactivity.test.ts +124 -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__/rewrite-props-object.test.ts +41 -4
- 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/expression-parser.ts +26 -0
- package/src/index.ts +2 -2
- package/src/ir-to-client-js/collect-elements.ts +45 -30
- 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/emit-registration.ts +26 -7
- package/src/ir-to-client-js/generate-init.ts +1 -1
- package/src/ir-to-client-js/html-template.ts +130 -20
- package/src/ir-to-client-js/imports.ts +178 -5
- package/src/ir-to-client-js/index.ts +11 -4
- package/src/ir-to-client-js/prop-handling.ts +83 -0
- package/src/ir-to-client-js/reactivity.ts +59 -0
- package/src/ir-to-client-js/rewrite-props-object.ts +50 -10
- package/src/ir-to-client-js/utils.ts +30 -2
- package/src/jsx-to-ir.ts +523 -49
- package/src/props-binding.ts +51 -0
- package/src/types.ts +48 -0
|
@@ -210,7 +210,11 @@ const ALLOWLIST: Record<string, Partial<Record<Pattern, number>>> = {
|
|
|
210
210
|
// FLOOR (shape 1, already guarded): both `expandDynamicPropValue` and
|
|
211
211
|
// `expandConstantForReactivity` precede their `.find(` with
|
|
212
212
|
// `scope?.isBound(trimmedValue)` — see this file's own header comment
|
|
213
|
-
// (added Stage 1b) for the full SHADOW GUARD reasoning.
|
|
213
|
+
// (added Stage 1b) for the full SHADOW GUARD reasoning. #2723's
|
|
214
|
+
// `resolveRestSpreadOrigin` deliberately does NOT add a third: it walks
|
|
215
|
+
// an alias chain hop by hop, so it indexes `ctx.localConstants` into a
|
|
216
|
+
// memoized `Map` (`localConstantValues`) instead — keeping this floor
|
|
217
|
+
// intact and avoiding a linear scan per hop.
|
|
214
218
|
'packages/jsx/src/ir-to-client-js/prop-handling.ts': { 'localConstants.find(': 2 },
|
|
215
219
|
// FLOOR (shape 3): `wrapExprWithLoopParams` / `LoopParamSpec` — the
|
|
216
220
|
// canonical definition of the accessor-rewrite payload every other
|
|
@@ -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
|
+
})
|
|
@@ -1338,6 +1338,12 @@ describe('Client JS generation', () => {
|
|
|
1338
1338
|
// hydrate() should include comment: true for fragment roots
|
|
1339
1339
|
expect(content).toMatch(/hydrate\('FragComp',/)
|
|
1340
1340
|
expect(content).toContain('comment: true')
|
|
1341
|
+
// #2722 regression pin: a genuine fragment root ALSO needs
|
|
1342
|
+
// `fragmentRoot: true` — distinct from the `comment: true` a
|
|
1343
|
+
// root-is-a-child-call component gets (see the "component roots"
|
|
1344
|
+
// describe block below) — so `materializeComponent` (component.ts)
|
|
1345
|
+
// generates its own CSR scope id instead of leaving it null.
|
|
1346
|
+
expect(content).toContain('fragmentRoot: true')
|
|
1341
1347
|
})
|
|
1342
1348
|
|
|
1343
1349
|
test('single-root component generates mount without comment flag', () => {
|
|
@@ -1390,6 +1396,11 @@ describe('Client JS generation', () => {
|
|
|
1390
1396
|
// hydrate() should include comment: true for component roots
|
|
1391
1397
|
expect(content).toMatch(/hydrate\('Wrapper',/)
|
|
1392
1398
|
expect(content).toContain('comment: true')
|
|
1399
|
+
// #2722 regression pin: the root-is-a-child-call shape must NOT get
|
|
1400
|
+
// `fragmentRoot: true` — the child's OWN markup already carries its
|
|
1401
|
+
// own scope id (#2649), so `materializeComponent` must keep leaving
|
|
1402
|
+
// this wrapper's `scopeId` null, not generate a fresh one.
|
|
1403
|
+
expect(content).not.toContain('fragmentRoot: true')
|
|
1393
1404
|
})
|
|
1394
1405
|
|
|
1395
1406
|
test('element-root client component does NOT generate comment: true', () => {
|
|
@@ -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,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression pin for #2723.
|
|
3
|
+
*
|
|
4
|
+
* A semantically-inert `const x__alias = x` hop between a destructured
|
|
5
|
+
* prop and its use site (exactly what the `alias-props` mutation sweep
|
|
6
|
+
* inserts, #2481) silently dropped the attribute's `createEffect` and, in
|
|
7
|
+
* the rest-spread case, its `applyRestAttrs` call too — collapsing `init`
|
|
8
|
+
* to `function initL() {}` whenever the effect was its only content.
|
|
9
|
+
*
|
|
10
|
+
* Five variants isolate the two independent defects the fix addresses:
|
|
11
|
+
* - A: no alias at all (control — everything present).
|
|
12
|
+
* - B: every destructured binding aliased, INCLUDING the rest
|
|
13
|
+
* parameter (`const rest__alias = rest`) — the shape the real
|
|
14
|
+
* `alias-props` mutation produces.
|
|
15
|
+
* - C: only the prop feeding the reactive attribute is aliased; the
|
|
16
|
+
* rest parameter is spread un-aliased.
|
|
17
|
+
* - D: aliased, but with NO rest spread at all — proves the defect is
|
|
18
|
+
* not spread-handling-specific.
|
|
19
|
+
* - E: aliased (rest included) AND an event handler is present, so
|
|
20
|
+
* `init` is non-empty regardless of the effect. This is the case a
|
|
21
|
+
* fix aimed only at "don't emit an empty init" would still leave
|
|
22
|
+
* broken: the `createEffect` silently disappears WITHOUT collapsing
|
|
23
|
+
* the function, so an empty-init check alone can't catch it.
|
|
24
|
+
*/
|
|
25
|
+
import { describe, test, expect } from 'bun:test'
|
|
26
|
+
import { compileJSX } from '../compiler'
|
|
27
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
28
|
+
|
|
29
|
+
const adapter = new TestAdapter()
|
|
30
|
+
|
|
31
|
+
function compileInit(source: string): string {
|
|
32
|
+
const result = compileJSX(source, 'L.tsx', { adapter })
|
|
33
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
34
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')!.content
|
|
35
|
+
return clientJs
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('#2723 — prop alias hop must not drop attribute reactivity', () => {
|
|
39
|
+
test('A: no alias — createEffect and applyRestAttrs both present (control)', () => {
|
|
40
|
+
const clientJs = compileInit(`
|
|
41
|
+
"use client";
|
|
42
|
+
const BASE = 'flex'
|
|
43
|
+
type LProps = { className?: string; children?: any }
|
|
44
|
+
export function L({ className = '', children, ...rest }: LProps) {
|
|
45
|
+
return <label className={\`\${BASE} \${className}\`} {...rest}>{children}</label>
|
|
46
|
+
}
|
|
47
|
+
`)
|
|
48
|
+
expect(clientJs).toContain('createEffect(')
|
|
49
|
+
expect(clientJs).toContain('applyRestAttrs(')
|
|
50
|
+
expect(clientJs).not.toContain('function initL() {}')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('B: every binding aliased, rest included — createEffect and applyRestAttrs survive', () => {
|
|
54
|
+
const clientJs = compileInit(`
|
|
55
|
+
"use client";
|
|
56
|
+
const BASE = 'flex'
|
|
57
|
+
type LProps = { className?: string; children?: any }
|
|
58
|
+
export function L({ className = '', children, ...rest }: LProps) {
|
|
59
|
+
const className__alias = className
|
|
60
|
+
const children__alias = children
|
|
61
|
+
const rest__alias = rest
|
|
62
|
+
return <label className={\`\${BASE} \${className__alias}\`} {...rest__alias}>{children__alias}</label>
|
|
63
|
+
}
|
|
64
|
+
`)
|
|
65
|
+
expect(clientJs).toContain('createEffect(')
|
|
66
|
+
expect(clientJs).toContain('applyRestAttrs(')
|
|
67
|
+
expect(clientJs).not.toContain('function initL() {}')
|
|
68
|
+
// The rest-parameter alias must resolve to the runtime props object,
|
|
69
|
+
// not to the never-declared source-level rest binding.
|
|
70
|
+
expect(clientJs).toContain('const rest__alias = _p')
|
|
71
|
+
expect(clientJs).not.toMatch(/const rest__alias = rest\b/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('C: only the reactive prop is aliased, rest is spread un-aliased', () => {
|
|
75
|
+
const clientJs = compileInit(`
|
|
76
|
+
"use client";
|
|
77
|
+
const BASE = 'flex'
|
|
78
|
+
type LProps = { className?: string; children?: any }
|
|
79
|
+
export function L({ className = '', children, ...rest }: LProps) {
|
|
80
|
+
const className__alias = className
|
|
81
|
+
return <label className={\`\${BASE} \${className__alias}\`} {...rest}>{children}</label>
|
|
82
|
+
}
|
|
83
|
+
`)
|
|
84
|
+
expect(clientJs).toContain('createEffect(')
|
|
85
|
+
expect(clientJs).toContain('applyRestAttrs(')
|
|
86
|
+
expect(clientJs).not.toContain('function initL() {}')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('D: aliased with NO rest spread — not a spread-handling bug', () => {
|
|
90
|
+
const clientJs = compileInit(`
|
|
91
|
+
"use client";
|
|
92
|
+
const BASE = 'flex'
|
|
93
|
+
type LProps = { className?: string; children?: any }
|
|
94
|
+
export function L({ className = '', children }: LProps) {
|
|
95
|
+
const className__alias = className
|
|
96
|
+
return <label className={\`\${BASE} \${className__alias}\`}>{children}</label>
|
|
97
|
+
}
|
|
98
|
+
`)
|
|
99
|
+
expect(clientJs).toContain('createEffect(')
|
|
100
|
+
expect(clientJs).not.toContain('applyRestAttrs(')
|
|
101
|
+
expect(clientJs).not.toContain('function initL() {}')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('E: aliased + event handler — createEffect must survive even though init is already non-empty', () => {
|
|
105
|
+
const clientJs = compileInit(`
|
|
106
|
+
"use client";
|
|
107
|
+
const BASE = 'flex'
|
|
108
|
+
type LProps = { className?: string; children?: any; onClick?: () => void }
|
|
109
|
+
export function L({ className = '', children, onClick, ...rest }: LProps) {
|
|
110
|
+
const className__alias = className
|
|
111
|
+
const rest__alias = rest
|
|
112
|
+
return <label className={\`\${BASE} \${className__alias}\`} onClick={onClick} {...rest__alias}>{children}</label>
|
|
113
|
+
}
|
|
114
|
+
`)
|
|
115
|
+
// A fix aimed only at "init must not be empty" would pass this
|
|
116
|
+
// assertion for free (the handler alone keeps init non-empty) while
|
|
117
|
+
// leaving the class binding frozen at its initial value — the
|
|
118
|
+
// `createEffect` assertion is the one that actually pins the fix.
|
|
119
|
+
expect(clientJs).not.toContain('function initL() {}')
|
|
120
|
+
expect(clientJs).toContain('createEffect(')
|
|
121
|
+
expect(clientJs).toContain('applyRestAttrs(')
|
|
122
|
+
expect(clientJs).toContain("addEventListener('click'")
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -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
|
+
})
|