@barefootjs/jsx 0.33.4 → 0.34.0
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/adapters/child-scope.d.ts +13 -0
- package/dist/adapters/child-scope.d.ts.map +1 -1
- package/dist/adapters/parsed-expr-emitter.d.ts +17 -0
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +17 -4
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1399 -1173
- package/dist/ir-to-client-js/child-components.d.ts.map +1 -1
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/component-scope.d.ts +20 -0
- package/dist/ir-to-client-js/component-scope.d.ts.map +1 -1
- package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/shared.d.ts +4 -3
- package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/csr-substitute.d.ts +35 -9
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts +10 -0
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +49 -2
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -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/rewrite-props-object.d.ts +12 -1
- package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/lowering-registry.d.ts +12 -0
- package/dist/lowering-registry.d.ts.map +1 -1
- package/dist/prop-rewrite.d.ts +5 -2
- package/dist/prop-rewrite.d.ts.map +1 -1
- package/dist/props-binding.d.ts +49 -11
- package/dist/props-binding.d.ts.map +1 -1
- package/dist/types.d.ts +6 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +15 -15
- package/src/__tests__/aliased-import-child-registry-2777.test.ts +112 -0
- package/src/__tests__/date-lowering.test.ts +18 -4
- package/src/__tests__/inner-loop-mathml-namespace.test.ts +1 -1
- package/src/__tests__/inner-loop-svg-namespace.test.ts +4 -4
- package/src/__tests__/issue-2724-prop-alias-loop-not-static.test.ts +355 -0
- package/src/__tests__/issue-2737-props-object-alias-template.test.ts +86 -0
- package/src/__tests__/issue-2756-input-skeleton-bails.test.ts +123 -0
- package/src/__tests__/issue-2778-getter-alias-csr-template.test.ts +113 -0
- package/src/__tests__/issue-2806-rest-bag-template-rewrite.test.ts +136 -0
- package/src/__tests__/issue-2835-props-type-member-name-collision.test.ts +259 -0
- package/src/__tests__/loop-item-root-scope.test.ts +7 -3
- package/src/__tests__/lowering-value-position.test.ts +232 -0
- package/src/__tests__/markup-prop-brand.test.ts +165 -26
- package/src/__tests__/namespace-import-primitive.test.ts +244 -0
- package/src/__tests__/rewrite-destructured-props.test.ts +53 -0
- package/src/__tests__/static-loop-csr-materialize.test.ts +2 -2
- package/src/adapters/child-scope.ts +23 -0
- package/src/adapters/parsed-expr-emitter.ts +43 -0
- package/src/analyzer.ts +130 -0
- package/src/errors.ts +14 -1
- package/src/expression-parser.ts +63 -29
- package/src/index.ts +9 -1
- package/src/ir-to-client-js/child-components.ts +8 -1
- package/src/ir-to-client-js/collect-elements.ts +10 -38
- package/src/ir-to-client-js/component-scope.ts +60 -0
- package/src/ir-to-client-js/compute-inlinability.ts +46 -11
- package/src/ir-to-client-js/control-flow/shared.ts +12 -5
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +8 -2
- package/src/ir-to-client-js/csr-substitute.ts +82 -16
- package/src/ir-to-client-js/emit-reactive.ts +29 -13
- package/src/ir-to-client-js/emit-registration.ts +1 -1
- package/src/ir-to-client-js/html-template.ts +141 -70
- package/src/ir-to-client-js/imports.ts +4 -0
- package/src/ir-to-client-js/index.ts +34 -23
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +7 -2
- package/src/ir-to-client-js/rewrite-props-object.ts +14 -0
- package/src/jsx-to-ir.ts +200 -42
- package/src/lowering-registry.ts +21 -0
- package/src/prop-rewrite.ts +23 -5
- package/src/props-binding.ts +74 -18
- package/src/types.ts +6 -5
|
@@ -159,27 +159,18 @@ describe('markup-prop brand (#2651)', () => {
|
|
|
159
159
|
expect(clientJs).toMatch(/get header\(\)\s*\{\s*return __slot\(/)
|
|
160
160
|
})
|
|
161
161
|
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
// `
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
//
|
|
175
|
-
// Graduation: once `bfMarkup()` branding is extended to this multi-part
|
|
176
|
-
// shape, this assertion starts failing — replace it with a positive
|
|
177
|
-
// `bfMarkup(...)`-wrapped assertion (mirroring test (b) above) and
|
|
178
|
-
// delete this comment block; that is the fixture's own graduation
|
|
179
|
-
// trigger too (its `expectedHtml` is already the correct SSR output, so
|
|
180
|
-
// no fixture change is needed, only this pin's removal and #2667's
|
|
181
|
-
// BF021 suggestion regaining the fragment-wrap escape).
|
|
182
|
-
test('(h) KNOWN BUG (#2702): a conditional-in-fragment jsx-children prop reaches initChild UNbranded', () => {
|
|
162
|
+
// #2702: a conditional-in-fragment jsx-children prop now brands EACH
|
|
163
|
+
// element branch with `bfMarkup()` (`jsxChildrenPropGetterExpr`,
|
|
164
|
+
// `ir-to-client-js/html-template.ts`) — the shape `isSingleElementJsx
|
|
165
|
+
// Children`'s narrower single-element gate (#2651) left unbranded,
|
|
166
|
+
// which corrupted the DOM the moment the child's own `escapeTextOrNode`
|
|
167
|
+
// reactive effect first ran (re-escaped the chosen branch's HTML as
|
|
168
|
+
// literal text; see `jsx-element-prop-fragment-conditional`'s fixture
|
|
169
|
+
// docstring for the full mechanism). Branding each LEAF rather than the
|
|
170
|
+
// whole ternary keeps a non-element branch (text/expression/`&&`'s
|
|
171
|
+
// `''`) unbranded and thus still escaped normally — see the sibling
|
|
172
|
+
// tests below.
|
|
173
|
+
test('(h) a conditional-in-fragment jsx-children prop brands each element branch with bfMarkup()', () => {
|
|
183
174
|
const source = `
|
|
184
175
|
'use client'
|
|
185
176
|
import { createSignal } from '@barefootjs/client'
|
|
@@ -201,10 +192,158 @@ describe('markup-prop brand (#2651)', () => {
|
|
|
201
192
|
}
|
|
202
193
|
`
|
|
203
194
|
const clientJs = clientJsFor(source)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
195
|
+
expect(clientJs).toMatch(/get header\(\)\s*\{\s*return cond\(\)\s*\?\s*bfMarkup\(`<a>x<\/a>`\)\s*:\s*bfMarkup\(`<b>y<\/b>`\)\s*\}/)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('(i) a conditional-in-fragment jsx-children prop leaves a non-element branch unbranded', () => {
|
|
199
|
+
const source = `
|
|
200
|
+
'use client'
|
|
201
|
+
import { createSignal } from '@barefootjs/client'
|
|
202
|
+
export function Card(props: { header?: any }) {
|
|
203
|
+
return <section><header>{props.header}</header></section>
|
|
204
|
+
}
|
|
205
|
+
export function label() { return 'x' }
|
|
206
|
+
export function TextBranch() {
|
|
207
|
+
const [cond, setCond] = createSignal(true)
|
|
208
|
+
return <Card header={<>{cond() ? <a>x</a> : 'plain'}</>} />
|
|
209
|
+
}
|
|
210
|
+
export function ExpressionBranch() {
|
|
211
|
+
const [cond, setCond] = createSignal(true)
|
|
212
|
+
return <Card header={<>{cond() ? <a>x</a> : label()}</>} />
|
|
213
|
+
}
|
|
214
|
+
export function LogicalAnd() {
|
|
215
|
+
const [cond, setCond] = createSignal(true)
|
|
216
|
+
return <Card header={<>{cond() && <a>x</a>}</>} />
|
|
217
|
+
}
|
|
218
|
+
`
|
|
219
|
+
const clientJs = clientJsFor(source)
|
|
220
|
+
expect(clientJs).toMatch(/get header\(\)\s*\{\s*return cond\(\)\s*\?\s*bfMarkup\(`<a>x<\/a>`\)\s*:\s*'plain'\s*\}/)
|
|
221
|
+
expect(clientJs).toMatch(/get header\(\)\s*\{\s*return cond\(\)\s*\?\s*bfMarkup\(`<a>x<\/a>`\)\s*:\s*label\(\)\s*\}/)
|
|
222
|
+
expect(clientJs).toMatch(/get header\(\)\s*\{\s*return cond\(\)\s*\?\s*bfMarkup\(`<a>x<\/a>`\)\s*:\s*''\s*\}/)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('(j) nested conditionals brand every element branch', () => {
|
|
226
|
+
const source = `
|
|
227
|
+
'use client'
|
|
228
|
+
import { createSignal } from '@barefootjs/client'
|
|
229
|
+
export function Card(props: { header?: any }) {
|
|
230
|
+
return <section><header>{props.header}</header></section>
|
|
231
|
+
}
|
|
232
|
+
export function P() {
|
|
233
|
+
const [a, setA] = createSignal(true)
|
|
234
|
+
const [b, setB] = createSignal(true)
|
|
235
|
+
return <Card header={<>{a() ? (b() ? <a>x</a> : <c>z</c>) : <b>y</b>}</>} />
|
|
236
|
+
}
|
|
237
|
+
`
|
|
238
|
+
const clientJs = clientJsFor(source)
|
|
239
|
+
expect(clientJs).toMatch(
|
|
240
|
+
/get header\(\)\s*\{\s*return a\(\)\s*\?\s*b\(\)\s*\?\s*bfMarkup\(`<a>x<\/a>`\)\s*:\s*bfMarkup\(`<c>z<\/c>`\)\s*:\s*bfMarkup\(`<b>y<\/b>`\)\s*\}/,
|
|
241
|
+
)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// A multi-child fragment (more than one flattened part) has no `bfMarkup`/
|
|
245
|
+
// `escapeTextOrNode` array contract — stays unbranded, unchanged from
|
|
246
|
+
// before #2702 (a separate, pre-existing gap, not this fix's scope).
|
|
247
|
+
test('(k) a multi-child fragment jsx-children prop stays unbranded', () => {
|
|
248
|
+
const source = `
|
|
249
|
+
'use client'
|
|
250
|
+
export function Card(props: { header?: any }) {
|
|
251
|
+
return <section><header>{props.header}</header></section>
|
|
252
|
+
}
|
|
253
|
+
export function P() {
|
|
254
|
+
return <Card header={<>text<strong>x</strong></>} />
|
|
255
|
+
}
|
|
256
|
+
`
|
|
257
|
+
const clientJs = clientJsFor(source)
|
|
258
|
+
expect(clientJs).toMatch(/get header\(\)\s*\{\s*return \["text", `<strong>x<\/strong>`\]\s*\}/)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// An explicit `children={<jsx/>}` prop stays unbranded regardless of
|
|
262
|
+
// shape — its consumer is a bare `{children}` passthrough with no
|
|
263
|
+
// unwrap call (test (b)'s comment above explains the same exclusion).
|
|
264
|
+
// pullfrog review, PR #2820: `jsxChildrenPropGetterExpr` (the shared
|
|
265
|
+
// brand-decision door test (h)-(k) pin) wasn't wired into EVERY builder
|
|
266
|
+
// that constructs the identical `get <propName>() { return <expr> }`
|
|
267
|
+
// getter — `buildComponentPropsExpr` (`control-flow/shared.ts`, shared by
|
|
268
|
+
// loop AND conditional-branch component reconciliation) and
|
|
269
|
+
// `buildStaticPropsExpr` (`build-static-array-child-init.ts`) both still
|
|
270
|
+
// called the plain, unbranded `irChildrenToJsExpr` directly. The same
|
|
271
|
+
// conditional-in-fragment shape test (h) pins for a top-level component
|
|
272
|
+
// corrupted the DOM at hydrate time when the component was instead
|
|
273
|
+
// instantiated inside a `.map()` loop.
|
|
274
|
+
test('(m) a conditional-in-fragment jsx-children prop is branded for a component instantiated inside a .map() loop', () => {
|
|
275
|
+
const source = `
|
|
276
|
+
'use client'
|
|
277
|
+
import { createSignal } from '@barefootjs/client'
|
|
278
|
+
export function Card(props: { header?: any }) {
|
|
279
|
+
return <header>{props.header}</header>
|
|
280
|
+
}
|
|
281
|
+
export function List() {
|
|
282
|
+
const [items, setItems] = createSignal([{ id: 1 }])
|
|
283
|
+
return (
|
|
284
|
+
<div>
|
|
285
|
+
{items().map(item => (
|
|
286
|
+
<Card key={item.id} header={<>{item.id > 0 ? <a>x</a> : <b>y</b>}</>} />
|
|
287
|
+
))}
|
|
288
|
+
</div>
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
`
|
|
292
|
+
const clientJs = clientJsFor(source)
|
|
293
|
+
// Both the reactive-patch (initChild) and initial-mount (createComponent)
|
|
294
|
+
// paths through mapArray's callback must brand each element branch.
|
|
295
|
+
expect(clientJs).toMatch(
|
|
296
|
+
/initChild\('Card', __existing, \{ get header\(\) \{ return item\(\)\.id > 0 \? bfMarkup\(`<a>x<\/a>`\) : bfMarkup\(`<b>y<\/b>`\) \} \}\)/,
|
|
297
|
+
)
|
|
298
|
+
expect(clientJs).toMatch(
|
|
299
|
+
/createComponent\('Card', \{ get header\(\) \{ return item\(\)\.id > 0 \? bfMarkup\(`<a>x<\/a>`\) : bfMarkup\(`<b>y<\/b>`\) \} \}, item\(\)\.id\)/,
|
|
300
|
+
)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
// pullfrog review, PR #2820: test (m) covers the SIGNAL-driven `.map()`
|
|
304
|
+
// loop (`buildComponentPropsExpr`); `buildStaticPropsExpr`
|
|
305
|
+
// (`build-static-array-child-init.ts`) — the sibling builder for a
|
|
306
|
+
// STATIC, non-signal array `.map()` — got the identical fix but no
|
|
307
|
+
// dedicated test, which is exactly the untested-call-site pattern that
|
|
308
|
+
// let #2702's original corruption go uncaught. Pins the hydrate-time
|
|
309
|
+
// `initChild` path this builder feeds.
|
|
310
|
+
test('(n) a conditional-in-fragment jsx-children prop is branded for a component instantiated inside a static (non-signal) array .map()', () => {
|
|
311
|
+
const source = `
|
|
312
|
+
'use client'
|
|
313
|
+
import { createSignal } from '@barefootjs/client'
|
|
314
|
+
export function Card(props: { header?: any }) {
|
|
315
|
+
return <header>{props.header}</header>
|
|
316
|
+
}
|
|
317
|
+
export function List() {
|
|
318
|
+
const [cond, setCond] = createSignal(true)
|
|
319
|
+
return (
|
|
320
|
+
<div>
|
|
321
|
+
{[{ id: 1 }].map(item => (
|
|
322
|
+
<Card key={item.id} header={<>{cond() ? <a>x</a> : <b>y</b>}</>} />
|
|
323
|
+
))}
|
|
324
|
+
</div>
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
`
|
|
328
|
+
const clientJs = clientJsFor(source)
|
|
329
|
+
expect(clientJs).toMatch(
|
|
330
|
+
/initChild\('Card', childScope, \{ get header\(\) \{ return cond\(\) \? bfMarkup\(`<a>x<\/a>`\) : bfMarkup\(`<b>y<\/b>`\) \} \}\)/,
|
|
331
|
+
)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('(l) an explicit children prop with a conditional stays unbranded', () => {
|
|
335
|
+
const source = `
|
|
336
|
+
'use client'
|
|
337
|
+
import { createSignal } from '@barefootjs/client'
|
|
338
|
+
export function Card(props: { children?: any }) {
|
|
339
|
+
return <section>{props.children}</section>
|
|
340
|
+
}
|
|
341
|
+
export function P() {
|
|
342
|
+
const [cond, setCond] = createSignal(true)
|
|
343
|
+
return <Card children={<>{cond() ? <a>x</a> : <b>y</b>}</>} />
|
|
344
|
+
}
|
|
345
|
+
`
|
|
346
|
+
const clientJs = clientJsFor(source)
|
|
347
|
+
expect(clientJs).toMatch(/get children\(\)\s*\{\s*return cond\(\)\s*\?\s*`<a>x<\/a>`\s*:\s*`<b>y<\/b>`\s*\}/)
|
|
209
348
|
})
|
|
210
349
|
})
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2771 — `import * as bf from '@barefootjs/client'` followed by
|
|
3
|
+
* `bf.createSignal(0)` (or any other reactive primitive accessed off the
|
|
4
|
+
* namespace binding) compiled with ZERO diagnostics, but `resolvePrimitiveKind`'s
|
|
5
|
+
* fast path only matches a bare identifier callee — the checker-based slow
|
|
6
|
+
* path that DOES understand a namespace-qualified call only runs when a
|
|
7
|
+
* shared `ts.Program` is supplied (`CompileOptions.program`). Without one,
|
|
8
|
+
* the declaration is silently dropped from the compiled output and every
|
|
9
|
+
* reference to it throws `ReferenceError` at hydrate.
|
|
10
|
+
*
|
|
11
|
+
* BF013 refuses loudly instead, gated on NON-recognition — a compile that
|
|
12
|
+
* DOES supply a program (see `primitive-resolver-alias.test.ts`'s pattern)
|
|
13
|
+
* must never see this diagnostic, since that path already lowers the shape
|
|
14
|
+
* correctly.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, test, expect } from 'bun:test'
|
|
17
|
+
import path from 'path'
|
|
18
|
+
import ts from 'typescript'
|
|
19
|
+
import { compileJSX } from '../compiler'
|
|
20
|
+
import { analyzeComponent } from '../index'
|
|
21
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
22
|
+
|
|
23
|
+
const adapter = new TestAdapter()
|
|
24
|
+
|
|
25
|
+
function errorsFor(source: string): Array<{ code?: string; severity: string }> {
|
|
26
|
+
const result = compileJSX(source, 'Counter.tsx', { adapter })
|
|
27
|
+
return result.errors.map(e => ({ code: e.code, severity: e.severity }))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function bf013(source: string) {
|
|
31
|
+
return errorsFor(source).find(e => e.code === 'BF013')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('#2771 — BF013: reactive primitive called through an unresolved namespace import', () => {
|
|
35
|
+
test('bf.createSignal(...) (array destructure) fires BF013', () => {
|
|
36
|
+
const result = compileJSX(
|
|
37
|
+
`
|
|
38
|
+
'use client'
|
|
39
|
+
import * as bf from '@barefootjs/client'
|
|
40
|
+
export function Counter() {
|
|
41
|
+
const [n, setN] = bf.createSignal(0)
|
|
42
|
+
return <button onClick={() => setN(n() + 1)}>{n()}</button>
|
|
43
|
+
}
|
|
44
|
+
`,
|
|
45
|
+
'Counter.tsx',
|
|
46
|
+
{ adapter },
|
|
47
|
+
)
|
|
48
|
+
const err = result.errors.find(e => e.code === 'BF013')
|
|
49
|
+
expect(err).toBeDefined()
|
|
50
|
+
expect(err!.severity).toBe('error')
|
|
51
|
+
expect(err!.message).toContain('bf.createSignal')
|
|
52
|
+
expect(err!.suggestion?.escape).toEqual([{ kind: 'rewrite' }])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('bf.createMemo(...) (array destructure) fires BF013', () => {
|
|
56
|
+
const err = bf013(`
|
|
57
|
+
'use client'
|
|
58
|
+
import * as bf from '@barefootjs/client'
|
|
59
|
+
import { createSignal } from '@barefootjs/client'
|
|
60
|
+
export function Counter() {
|
|
61
|
+
const [n] = createSignal(1)
|
|
62
|
+
const [doubled] = bf.createMemo(() => n() * 2)
|
|
63
|
+
return <div>{doubled()}</div>
|
|
64
|
+
}
|
|
65
|
+
`)
|
|
66
|
+
expect(err).toBeDefined()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('bf.createEffect(...) (expression statement) fires BF013', () => {
|
|
70
|
+
const err = bf013(`
|
|
71
|
+
'use client'
|
|
72
|
+
import * as bf from '@barefootjs/client'
|
|
73
|
+
import { createSignal } from '@barefootjs/client'
|
|
74
|
+
export function Counter() {
|
|
75
|
+
const [n] = createSignal(0)
|
|
76
|
+
bf.createEffect(() => { console.log(n()) })
|
|
77
|
+
return <div>{n()}</div>
|
|
78
|
+
}
|
|
79
|
+
`)
|
|
80
|
+
expect(err).toBeDefined()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('bf.onMount(...) (expression statement) fires BF013', () => {
|
|
84
|
+
const err = bf013(`
|
|
85
|
+
'use client'
|
|
86
|
+
import * as bf from '@barefootjs/client'
|
|
87
|
+
export function Counter() {
|
|
88
|
+
bf.onMount(() => { console.log('mounted') })
|
|
89
|
+
return <div>static</div>
|
|
90
|
+
}
|
|
91
|
+
`)
|
|
92
|
+
expect(err).toBeDefined()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('bf.onCleanup(...) (expression statement) fires BF013', () => {
|
|
96
|
+
const err = bf013(`
|
|
97
|
+
'use client'
|
|
98
|
+
import * as bf from '@barefootjs/client'
|
|
99
|
+
export function Counter() {
|
|
100
|
+
bf.onCleanup(() => { console.log('cleaned up') })
|
|
101
|
+
return <div>static</div>
|
|
102
|
+
}
|
|
103
|
+
`)
|
|
104
|
+
expect(err).toBeDefined()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('bf.createSearchParams(...) (array destructure) fires BF013', () => {
|
|
108
|
+
const err = bf013(`
|
|
109
|
+
'use client'
|
|
110
|
+
import * as bf from '@barefootjs/client'
|
|
111
|
+
export function Counter() {
|
|
112
|
+
const [searchParams] = bf.createSearchParams()
|
|
113
|
+
return <div>{searchParams().get('sort')}</div>
|
|
114
|
+
}
|
|
115
|
+
`)
|
|
116
|
+
expect(err).toBeDefined()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('bf.createSignal(0)[0] (element access) fires BF013', () => {
|
|
120
|
+
const err = bf013(`
|
|
121
|
+
'use client'
|
|
122
|
+
import * as bf from '@barefootjs/client'
|
|
123
|
+
export function Counter() {
|
|
124
|
+
const n = bf.createSignal(0)[0]
|
|
125
|
+
return <div>{n()}</div>
|
|
126
|
+
}
|
|
127
|
+
`)
|
|
128
|
+
expect(err).toBeDefined()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('a bare named import is unaffected (no BF013)', () => {
|
|
132
|
+
const errs = errorsFor(`
|
|
133
|
+
'use client'
|
|
134
|
+
import { createSignal } from '@barefootjs/client'
|
|
135
|
+
export function Counter() {
|
|
136
|
+
const [n, setN] = createSignal(0)
|
|
137
|
+
return <button onClick={() => setN(n() + 1)}>{n()}</button>
|
|
138
|
+
}
|
|
139
|
+
`)
|
|
140
|
+
expect(errs.find(e => e.code === 'BF013')).toBeUndefined()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('a namespace import of a DIFFERENT module is unaffected (no BF013)', () => {
|
|
144
|
+
const result = compileJSX(
|
|
145
|
+
`
|
|
146
|
+
'use client'
|
|
147
|
+
import * as store from './store'
|
|
148
|
+
export function Counter() {
|
|
149
|
+
const [n, setN] = store.createSignal(0)
|
|
150
|
+
return <button onClick={() => setN(n() + 1)}>{n()}</button>
|
|
151
|
+
}
|
|
152
|
+
`,
|
|
153
|
+
'Counter.tsx',
|
|
154
|
+
{ adapter },
|
|
155
|
+
)
|
|
156
|
+
expect(result.errors.find(e => e.code === 'BF013')).toBeUndefined()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('a component-local binding shadowing the namespace name is unaffected (no BF013)', () => {
|
|
160
|
+
const result = compileJSX(
|
|
161
|
+
`
|
|
162
|
+
'use client'
|
|
163
|
+
import * as bf from '@barefootjs/client'
|
|
164
|
+
export function Counter() {
|
|
165
|
+
const bf = { createSignal: (x: number) => [() => x, (v: number) => {}] as const }
|
|
166
|
+
const [n, setN] = bf.createSignal(0)
|
|
167
|
+
return <button onClick={() => setN(n() + 1)}>{n()}</button>
|
|
168
|
+
}
|
|
169
|
+
`,
|
|
170
|
+
'Counter.tsx',
|
|
171
|
+
{ adapter },
|
|
172
|
+
)
|
|
173
|
+
expect(result.errors.find(e => e.code === 'BF013')).toBeUndefined()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('a type-only namespace import is unaffected (no BF013)', () => {
|
|
177
|
+
const result = compileJSX(
|
|
178
|
+
`
|
|
179
|
+
'use client'
|
|
180
|
+
import type * as bf from '@barefootjs/client'
|
|
181
|
+
export function Counter() {
|
|
182
|
+
return <div>static</div>
|
|
183
|
+
}
|
|
184
|
+
`,
|
|
185
|
+
'Counter.tsx',
|
|
186
|
+
{ adapter },
|
|
187
|
+
)
|
|
188
|
+
expect(result.errors.find(e => e.code === 'BF013')).toBeUndefined()
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe('#2771 — checker-path pin: a shared Program resolves the namespace and never fires BF013', () => {
|
|
193
|
+
const CLIENT_DIR = path.resolve(__dirname, '../../../client/src')
|
|
194
|
+
|
|
195
|
+
function programFor(filePath: string, source: string): ts.Program {
|
|
196
|
+
const absolute = path.resolve(filePath)
|
|
197
|
+
const compilerOptions: ts.CompilerOptions = {
|
|
198
|
+
target: ts.ScriptTarget.Latest,
|
|
199
|
+
module: ts.ModuleKind.ESNext,
|
|
200
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
201
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
202
|
+
strict: true,
|
|
203
|
+
skipLibCheck: true,
|
|
204
|
+
noEmit: true,
|
|
205
|
+
esModuleInterop: true,
|
|
206
|
+
baseUrl: path.dirname(absolute),
|
|
207
|
+
}
|
|
208
|
+
const defaultHost = ts.createCompilerHost(compilerOptions)
|
|
209
|
+
const host: ts.CompilerHost = {
|
|
210
|
+
...defaultHost,
|
|
211
|
+
getSourceFile(fileName, languageVersion) {
|
|
212
|
+
if (path.resolve(fileName) === absolute) {
|
|
213
|
+
return ts.createSourceFile(fileName, source, languageVersion, true, ts.ScriptKind.TSX)
|
|
214
|
+
}
|
|
215
|
+
return defaultHost.getSourceFile(fileName, languageVersion)
|
|
216
|
+
},
|
|
217
|
+
fileExists(fileName) {
|
|
218
|
+
if (path.resolve(fileName) === absolute) return true
|
|
219
|
+
return defaultHost.fileExists(fileName)
|
|
220
|
+
},
|
|
221
|
+
readFile(fileName) {
|
|
222
|
+
if (path.resolve(fileName) === absolute) return source
|
|
223
|
+
return defaultHost.readFile(fileName)
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
return ts.createProgram([absolute], compilerOptions, host)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
test('with a shared Program, bf.createSignal resolves and BF013 never fires', () => {
|
|
230
|
+
const source = `
|
|
231
|
+
'use client'
|
|
232
|
+
import * as bf from '@barefootjs/client'
|
|
233
|
+
export function Counter() {
|
|
234
|
+
const [n, setN] = bf.createSignal(0)
|
|
235
|
+
return <button onClick={() => setN(n() + 1)}>{n()}</button>
|
|
236
|
+
}
|
|
237
|
+
`
|
|
238
|
+
const testFile = path.join(CLIENT_DIR, '__ns_primitive_checker_pin__.tsx')
|
|
239
|
+
const program = programFor(testFile, source)
|
|
240
|
+
const ctx = analyzeComponent(source, testFile, undefined, program)
|
|
241
|
+
expect(ctx.signals.length).toBe(1)
|
|
242
|
+
expect(ctx.errors.find(e => e.code === 'BF013')).toBeUndefined()
|
|
243
|
+
})
|
|
244
|
+
})
|
|
@@ -71,3 +71,56 @@ export { Btn }
|
|
|
71
71
|
expect(js).not.toMatch(/\(_p\.size[^)]*\)-[0-9]/)
|
|
72
72
|
})
|
|
73
73
|
})
|
|
74
|
+
|
|
75
|
+
// #2741: `rewriteDestructuredPropsInExpr` (the reactive `createEffect`
|
|
76
|
+
// update path) used to run a bare `\b<prop>\b` regex over the whole
|
|
77
|
+
// expression text, so an object-literal property whose KEY happened to
|
|
78
|
+
// share a prop's name (`{ tag: tag }`) had the KEY rewritten too
|
|
79
|
+
// (`{ _p.tag: _p.tag }`), which is not valid JS — the whole client module
|
|
80
|
+
// failed to parse, silently (no diagnostic). Now delegates to the same
|
|
81
|
+
// AST-based `rewriteBarePropRefs` (`prop-rewrite.ts`) the hydrate template
|
|
82
|
+
// lambda already used, which distinguishes a key from a value reference.
|
|
83
|
+
describe('reactive-effect prop rewrite does not touch object-literal keys (#2741)', () => {
|
|
84
|
+
test('an explicit-key object literal used in a reactive attribute keeps its keys', () => {
|
|
85
|
+
const source = `
|
|
86
|
+
"use client"
|
|
87
|
+
import { queryHref } from '@barefootjs/client'
|
|
88
|
+
function Comp({ base, tag, page }: { base: string; tag: string; page: string }) {
|
|
89
|
+
return <a href={queryHref(base, { tag: tag, page: page ? page : '' })}>filter</a>
|
|
90
|
+
}
|
|
91
|
+
export { Comp }
|
|
92
|
+
`
|
|
93
|
+
const js = compileClientJs(source)
|
|
94
|
+
expect(js).toContain("{ tag: _p.tag, page: _p.page ? _p.page : '' }")
|
|
95
|
+
expect(js).not.toContain('_p.tag:')
|
|
96
|
+
expect(js).not.toContain('_p.page:')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('a keyed prop with a destructure default still gets the (_p.x ?? default) wrap only in value position', () => {
|
|
100
|
+
const source = `
|
|
101
|
+
"use client"
|
|
102
|
+
import { queryHref } from '@barefootjs/client'
|
|
103
|
+
function Comp({ base, tag = 'all', page }: { base: string; tag?: string; page: string }) {
|
|
104
|
+
return <a href={queryHref(base, { tag: tag, page: page ? page : '' })}>filter</a>
|
|
105
|
+
}
|
|
106
|
+
export { Comp }
|
|
107
|
+
`
|
|
108
|
+
const js = compileClientJs(source)
|
|
109
|
+
expect(js).toContain("{ tag: (_p.tag ?? 'all'), page: _p.page ? _p.page : '' }")
|
|
110
|
+
expect(js).not.toContain("(_p.tag ?? 'all'):")
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('a prop name shadowed by an inner callback parameter is not rewritten inside that callback', () => {
|
|
114
|
+
const source = `
|
|
115
|
+
"use client"
|
|
116
|
+
import { createSignal } from '@barefootjs/client'
|
|
117
|
+
function Comp({ tag, items }: { tag: string; items: { a: string }[] }) {
|
|
118
|
+
const [n] = createSignal(0)
|
|
119
|
+
return <div data-x={n() + items.map((tag) => tag.a).join(',') + tag}></div>
|
|
120
|
+
}
|
|
121
|
+
export { Comp }
|
|
122
|
+
`
|
|
123
|
+
const js = compileClientJs(source)
|
|
124
|
+
expect(js).toContain("items.map((tag) => tag.a).join(',') + _p.tag")
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -242,7 +242,7 @@ describe('#1247 — static-loop CSR self-heal', () => {
|
|
|
242
242
|
expect(clientJs).toMatch(/if \(!__iterEl\)/)
|
|
243
243
|
// The cloned template body is a single `renderChild('Tag', ...)`
|
|
244
244
|
// expression — props read from the destructured forEach param.
|
|
245
|
-
const matMatch = clientJs.match(/__tpl\.innerHTML = `([^`]*)
|
|
245
|
+
const matMatch = clientJs.match(/__tpl\.innerHTML = withParentScope\(__scopeId, \(\) => `([^`]*)`\)/)
|
|
246
246
|
expect(matMatch).toBeTruthy()
|
|
247
247
|
const inner = matMatch![1]
|
|
248
248
|
expect(inner).toMatch(/\$\{renderChild\('Tag', \{[^}]*id: id[^}]*variant: t\.variant[^}]*\}, id/)
|
|
@@ -287,7 +287,7 @@ describe('#1247 — static-loop CSR self-heal', () => {
|
|
|
287
287
|
// Materialize branch present.
|
|
288
288
|
expect(clientJs).toMatch(/if \(!__iterEl\)/)
|
|
289
289
|
// Per-iteration template is `<li ...>...renderChild('Cell', ...)...</li>`.
|
|
290
|
-
const matMatch = clientJs.match(/__tpl\.innerHTML = `([^`]*)
|
|
290
|
+
const matMatch = clientJs.match(/__tpl\.innerHTML = withParentScope\(__scopeId, \(\) => `([^`]*)`\)/)
|
|
291
291
|
expect(matMatch).toBeTruthy()
|
|
292
292
|
const inner = matMatch![1]
|
|
293
293
|
expect(inner).toMatch(/^<li\b/)
|
|
@@ -20,3 +20,26 @@ export function derivesScopeFromSlot(
|
|
|
20
20
|
): boolean {
|
|
21
21
|
return comp.slotId != null && comp.loopItemRoot !== true
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The `renderChild(name, props, key, <here>)` tail arguments — the single
|
|
26
|
+
* decision point both CSR template emitters (`html-template.ts`'s two
|
|
27
|
+
* `case 'component'` branches) share, so they can't drift on it the way
|
|
28
|
+
* #2444 happened for `derivesScopeFromSlot` itself.
|
|
29
|
+
*
|
|
30
|
+
* A loop item root (`!derivesScopeFromSlot`) still gets its `slotId` and the
|
|
31
|
+
* `loopItemRoot` flag (#2833) rather than dropping the slot argument
|
|
32
|
+
* entirely as before: Hono stamps `bf-h`/`bf-m` on a row root too (just
|
|
33
|
+
* without deriving its `bf-s` from them), and a pure-CSR mount's static
|
|
34
|
+
* `qsaChildScopes` init selector needs that same marker to find the row.
|
|
35
|
+
*/
|
|
36
|
+
export function renderChildScopeArgs(
|
|
37
|
+
comp: Pick<IRComponent, 'slotId' | 'loopItemRoot'>,
|
|
38
|
+
keyArg: string
|
|
39
|
+
): string {
|
|
40
|
+
if (comp.slotId == null) return keyArg
|
|
41
|
+
const key = keyArg || ', undefined'
|
|
42
|
+
return derivesScopeFromSlot(comp)
|
|
43
|
+
? `${key}, '${comp.slotId}'`
|
|
44
|
+
: `${key}, '${comp.slotId}', true`
|
|
45
|
+
}
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
|
|
36
36
|
import type { ParsedExpr, FlatDepth, TemplatePart, ObjectLiteralProperty } from '../expression-parser.ts'
|
|
37
37
|
import { asCallbackMethodCall } from '../expression-parser.ts'
|
|
38
|
+
import type { LoweringMatcher, LoweringNode } from '../lowering-registry.ts'
|
|
38
39
|
|
|
39
40
|
export type HigherOrderMethod = 'filter' | 'every' | 'some' | 'find' | 'findIndex' | 'findLast' | 'findLastIndex'
|
|
40
41
|
|
|
@@ -95,6 +96,22 @@ export type SortMethod = 'sort' | 'toSorted'
|
|
|
95
96
|
|
|
96
97
|
export type LiteralType = 'string' | 'number' | 'boolean' | 'null'
|
|
97
98
|
|
|
99
|
+
/**
|
|
100
|
+
* An adapter's registered-lowering seam for the shared `call` dispatch
|
|
101
|
+
* (#2843). `matchers` are the same `ctx.state.loweringMatchers`-style list
|
|
102
|
+
* every adapter already binds at init; `render` maps one matched neutral
|
|
103
|
+
* node to this adapter's own template syntax (its body is typically what
|
|
104
|
+
* used to live inline in the adapter's `call()` method before this seam
|
|
105
|
+
* existed — see `renderLoweringNode` in the Go adapter's `url-builder.ts`
|
|
106
|
+
* for the shape). Returning null for a `kind`/`helper` this adapter has no
|
|
107
|
+
* mapping for lets the caller fall through to the ordinary `call()`
|
|
108
|
+
* dispatch, same as an unmatched call.
|
|
109
|
+
*/
|
|
110
|
+
export interface LoweringEmitter {
|
|
111
|
+
readonly matchers: readonly LoweringMatcher[]
|
|
112
|
+
render(node: LoweringNode, emit: (e: ParsedExpr) => string): string | null
|
|
113
|
+
}
|
|
114
|
+
|
|
98
115
|
/**
|
|
99
116
|
* Adapter-side ParsedExpr lowering surface. One method per
|
|
100
117
|
* `ParsedExpr.kind`. The shared `emitParsedExpr` runner dispatches
|
|
@@ -109,6 +126,17 @@ export interface ParsedExprEmitter {
|
|
|
109
126
|
identifier(name: string): string
|
|
110
127
|
literal(value: string | number | boolean | null, literalType: LiteralType): string
|
|
111
128
|
call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string
|
|
129
|
+
// Optional (#2843): when present, `emitParsedExpr`'s `call` case tries
|
|
130
|
+
// every matcher FIRST — before `call()` itself, and before the
|
|
131
|
+
// higher-order `callbackMethod` recognition — so a registered lowering
|
|
132
|
+
// plugin's call is recognised no matter where in an expression tree it
|
|
133
|
+
// sits (a ternary branch, a template-literal interpolation, a binary
|
|
134
|
+
// operand, …), not only when the adapter's own top-level conversion entry
|
|
135
|
+
// point happens to consult the registry directly. Omitted entirely by an
|
|
136
|
+
// adapter that hasn't adopted the seam (e.g. Go, which still consults the
|
|
137
|
+
// registry inline in its own `call()` per #2842) — behaviour is then
|
|
138
|
+
// byte-identical to before this field existed.
|
|
139
|
+
lowering?: LoweringEmitter
|
|
112
140
|
// `optional` is true for a `?.`-written access (`user?.name`); see the
|
|
113
141
|
// `ParsedExpr` `member` variant's docstring in `expression-parser.ts`
|
|
114
142
|
// for the single-hop caveat. Every adapter's `member()` implementation
|
|
@@ -340,6 +368,21 @@ export function emitParsedExpr(expr: ParsedExpr, emitter: ParsedExprEmitter): st
|
|
|
340
368
|
case 'literal':
|
|
341
369
|
return emitter.literal(expr.value, expr.literalType)
|
|
342
370
|
case 'call': {
|
|
371
|
+
// #2843: a registered lowering plugin's call wins in EVERY value
|
|
372
|
+
// position this dispatcher reaches, not only an adapter's own
|
|
373
|
+
// top-level conversion entry point — see `LoweringEmitter`'s doc.
|
|
374
|
+
// First-match-wins; a matched node whose `render` declines (helper id
|
|
375
|
+
// this adapter has no mapping for) falls through to the ordinary
|
|
376
|
+
// dispatch below, same as an unmatched call.
|
|
377
|
+
if (emitter.lowering) {
|
|
378
|
+
for (const matcher of emitter.lowering.matchers) {
|
|
379
|
+
const node = matcher(expr.callee, expr.args)
|
|
380
|
+
if (!node) continue
|
|
381
|
+
const rendered = emitter.lowering.render(node, emit)
|
|
382
|
+
if (rendered !== null) return rendered
|
|
383
|
+
break
|
|
384
|
+
}
|
|
385
|
+
}
|
|
343
386
|
// A higher-order callback call (`arr.filter(p)` / `arr.sort(cmp)` / …)
|
|
344
387
|
// routes to the dedicated `callbackMethod` arm; any other call is generic.
|
|
345
388
|
const cb = asCallbackMethodCall(expr)
|