@barefootjs/jsx 0.17.1 → 0.18.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/interface.d.ts +20 -4
- package/dist/adapters/interface.d.ts.map +1 -1
- package/dist/adapters/parsed-expr-emitter.d.ts +3 -1
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/augment-inherited-props.d.ts +19 -0
- package/dist/augment-inherited-props.d.ts.map +1 -1
- package/dist/compiler.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +1 -0
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +19 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +815 -251
- package/dist/ir-to-client-js/collect-elements.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/plan/build-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
- package/dist/ir-to-client-js/control-flow/plan/loop.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/control-flow/stringify/template-parse.d.ts +19 -0
- package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-registration.d.ts +4 -2
- package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +41 -0
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +22 -1
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/loop-destructure.d.ts +55 -18
- package/dist/loop-destructure.d.ts.map +1 -1
- package/dist/lowering-registry.d.ts +13 -0
- package/dist/lowering-registry.d.ts.map +1 -1
- package/dist/relocate.d.ts +28 -0
- package/dist/relocate.d.ts.map +1 -1
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/types.d.ts +68 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +284 -12
- package/src/__tests__/augment-inherited-props.test.ts +96 -0
- package/src/__tests__/compiler-runtime-contract.test.ts +11 -1
- package/src/__tests__/compiler-stress-1244.test.ts +13 -4
- package/src/__tests__/csr-substitution-safety-divergence.test.ts +137 -0
- package/src/__tests__/destructured-map-params.test.ts +11 -1
- package/src/__tests__/expression-parser.test.ts +63 -2
- package/src/__tests__/ir-sort-comparator.test.ts +261 -0
- package/src/__tests__/loop-destructure.test.ts +313 -0
- package/src/__tests__/loop-hoisted-template.test.ts +235 -0
- package/src/__tests__/props-destructuring.test.ts +110 -0
- package/src/__tests__/serialize-parsed-expr.test.ts +70 -3
- package/src/__tests__/ssr-defaults.test.ts +20 -0
- package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +231 -1
- package/src/__tests__/tagged-template-interleave.test.ts +268 -0
- package/src/__tests__/unsupported-expression.test.ts +194 -7
- package/src/adapters/interface.ts +20 -4
- package/src/adapters/parsed-expr-emitter.ts +19 -2
- package/src/analyzer-context.ts +20 -0
- package/src/analyzer.ts +74 -1
- package/src/augment-inherited-props.ts +139 -9
- package/src/compiler.ts +4 -0
- package/src/expression-parser.ts +237 -56
- package/src/index.ts +23 -1
- package/src/ir-to-client-js/collect-elements.ts +15 -1
- package/src/ir-to-client-js/compute-inlinability.ts +6 -1
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
- package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +30 -8
- package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +30 -0
- package/src/ir-to-client-js/emit-registration.ts +4 -2
- package/src/ir-to-client-js/html-template.ts +198 -1
- package/src/ir-to-client-js/index.ts +1 -0
- package/src/ir-to-client-js/types.ts +22 -0
- package/src/jsx-to-ir.ts +409 -24
- package/src/loop-destructure.ts +89 -36
- package/src/lowering-registry.ts +16 -0
- package/src/relocate.ts +201 -14
- package/src/ssr-defaults.ts +34 -32
- package/src/types.ts +65 -0
|
@@ -121,8 +121,10 @@ describe('serializeParsedExpr', () => {
|
|
|
121
121
|
test('purity gate: a folded method call in the body → null', () => {
|
|
122
122
|
// `.toUpperCase()` folds to `array-method`, outside the evaluator surface.
|
|
123
123
|
expect(serializeParsedExpr(parseExpression('item.name.toUpperCase()'))).toBeNull()
|
|
124
|
-
// A higher-order call
|
|
125
|
-
|
|
124
|
+
// A higher-order call OTHER than `.map`/`.filter` (#2094 widens only
|
|
125
|
+
// those two) still folds — `.some` returns a boolean-from-search, not a
|
|
126
|
+
// per-element transform, so it has no evaluator lowering.
|
|
127
|
+
expect(serializeParsedExpr(parseExpression('item.tags.some(t => t)'))).toBeNull()
|
|
126
128
|
// An unsupported shape.
|
|
127
129
|
expect(serializeParsedExpr(parseExpression('a instanceof B'))).toBeNull()
|
|
128
130
|
})
|
|
@@ -159,8 +161,73 @@ describe('serializeParsedExpr', () => {
|
|
|
159
161
|
})
|
|
160
162
|
|
|
161
163
|
test('every other array-method still folds outside the evaluator surface', () => {
|
|
162
|
-
|
|
164
|
+
// `.join` is now serializable (#2094) — see the `.join` describe block
|
|
165
|
+
// below. `.slice` / `.flat` are not.
|
|
163
166
|
expect(serializeParsedExpr(parseExpression('item.tags.slice(0, 1)'))).toBeNull()
|
|
167
|
+
expect(serializeParsedExpr(parseExpression('item.tags.flat()'))).toBeNull()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// ----- #2094: nested `.map` / `.filter` / `.join` inside a callback body --
|
|
171
|
+
test('`.join(sep?)` array-method serializes', () => {
|
|
172
|
+
expect(evalJSON('item.tags.join(",")')).toEqual({
|
|
173
|
+
kind: 'array-method',
|
|
174
|
+
method: 'join',
|
|
175
|
+
object: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'tags' },
|
|
176
|
+
args: [{ kind: 'literal', value: ',' }],
|
|
177
|
+
})
|
|
178
|
+
// No-arg form keeps an empty `args` (the evaluator defaults the separator).
|
|
179
|
+
expect(evalJSON('item.tags.join()')).toEqual({
|
|
180
|
+
kind: 'array-method',
|
|
181
|
+
method: 'join',
|
|
182
|
+
object: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'tags' },
|
|
183
|
+
args: [],
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('nested `.map(cb)` serializes as an ordinary call/member/arrow — NOT a bespoke wrapper', () => {
|
|
188
|
+
// Deliberately the SAME shape a top-level `.filter(t => t)` parses to —
|
|
189
|
+
// this is what lets the `eval-vectors.json` corpus (which carries the
|
|
190
|
+
// genuine `ParsedExpr`, unfiltered by `toEvalNode`) exercise the exact
|
|
191
|
+
// encoding the compiled-template embedded body uses.
|
|
192
|
+
expect(evalJSON("item.tags.map(t => '#' + t)")).toEqual({
|
|
193
|
+
kind: 'call',
|
|
194
|
+
callee: {
|
|
195
|
+
kind: 'member',
|
|
196
|
+
object: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'tags' },
|
|
197
|
+
property: 'map',
|
|
198
|
+
computed: false,
|
|
199
|
+
},
|
|
200
|
+
args: [
|
|
201
|
+
{
|
|
202
|
+
kind: 'arrow',
|
|
203
|
+
params: ['t'],
|
|
204
|
+
body: {
|
|
205
|
+
kind: 'binary',
|
|
206
|
+
op: '+',
|
|
207
|
+
left: { kind: 'literal', value: '#' },
|
|
208
|
+
right: { kind: 'identifier', name: 't' },
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('nested `.filter(cb)` composed with `.length` and a comparison (the doc/#2038 shape) serializes', () => {
|
|
216
|
+
expect(serializeParsedExpr(parseExpression('item.tags.filter(t => t.active).length > 0'))).not.toBeNull()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('a 2-param nested arrow `(item, index)` serializes', () => {
|
|
220
|
+
expect(serializeParsedExpr(parseExpression('item.tags.map((t, i) => t.n + i)'))).not.toBeNull()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('nested `.some`/`.find`/`.every`/`.sort`/`.reduce`/`.flat`/`.flatMap` still fold to null', () => {
|
|
224
|
+
expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.some(p => p === t))'))).toBeNull()
|
|
225
|
+
expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.find(p => p === t))'))).toBeNull()
|
|
226
|
+
expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.every(p => p === t))'))).toBeNull()
|
|
227
|
+
expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.sort((a, b) => a - b).length > 0)'))).toBeNull()
|
|
228
|
+
expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.reduce((a, b) => a + b, 0) > 0)'))).toBeNull()
|
|
229
|
+
expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.flat().length > 0)'))).toBeNull()
|
|
230
|
+
expect(serializeParsedExpr(parseExpression('item.tags.filter(t => picked.flatMap(p => p).length > 0)'))).toBeNull()
|
|
164
231
|
})
|
|
165
232
|
|
|
166
233
|
test('a computed member value carries `computed: true` (plain access omits it)', () => {
|
|
@@ -82,6 +82,26 @@ describe('extractSsrDefaults', () => {
|
|
|
82
82
|
expect(defaults?.initial).toEqual({ propName: 'initial', value: null })
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
test('seeds every declared bare-props prop, including direct template reads (#2126)', () => {
|
|
86
|
+
// Template-stash adapters flatten `props.label` to the bare scalar
|
|
87
|
+
// `$label` even when no signal/memo references it (`<%= $label %>` in
|
|
88
|
+
// the body), so a prop the caller forgets to pass must still exist in
|
|
89
|
+
// the stash. Every prop declared on the props type gets an entry —
|
|
90
|
+
// not just the ones a signal / memo initializer reads.
|
|
91
|
+
const metadata = metadataFor(`
|
|
92
|
+
'use client'
|
|
93
|
+
import { createSignal } from '@barefootjs/client'
|
|
94
|
+
function Greeting(props: { label?: string }) {
|
|
95
|
+
const [n, setN] = createSignal(0)
|
|
96
|
+
return <div><p>{props.label}</p><button onClick={() => setN(n() + 1)}>{n()}</button></div>
|
|
97
|
+
}
|
|
98
|
+
`)
|
|
99
|
+
|
|
100
|
+
const defaults = extractSsrDefaults(metadata)
|
|
101
|
+
expect(defaults?.label).toEqual({ propName: 'label', value: null })
|
|
102
|
+
expect(defaults?.n).toEqual({ value: 0 })
|
|
103
|
+
})
|
|
104
|
+
|
|
85
105
|
test('memo derived from a signal evaluates through the chain', () => {
|
|
86
106
|
const metadata = metadataFor(`
|
|
87
107
|
'use client'
|
|
@@ -5,14 +5,25 @@
|
|
|
5
5
|
* rejections for callees the adapter promises it can render in template
|
|
6
6
|
* scope.
|
|
7
7
|
*
|
|
8
|
+
* #2069 extends this with two more acceptance mechanisms, tested in the
|
|
9
|
+
* two `describe` blocks at the bottom of this file:
|
|
10
|
+
* - `RelocateEnv.loweringMatchers` — a THIRD acceptance path alongside
|
|
11
|
+
* `templatePrimitives` / `acceptsTemplateCall`, for calls a
|
|
12
|
+
* `LoweringPlugin` recognises structurally (never string-keyed).
|
|
13
|
+
* - `RelocateEnv.aliasTargets` — one-hop alias resolution, so
|
|
14
|
+
* `const fmt = customSerialize; fmt(x)` keys/matches as
|
|
15
|
+
* `customSerialize`, not `fmt`.
|
|
16
|
+
*
|
|
8
17
|
* No adapter populates the registry yet — the wiring is exercised here
|
|
9
|
-
* via hand-built env objects. Phase 3
|
|
18
|
+
* via hand-built env objects. Phase 3 filled the Hono predicate; #2069
|
|
19
|
+
* adds the plugin-matcher and alias seams.
|
|
10
20
|
*/
|
|
11
21
|
|
|
12
22
|
import { describe, test, expect } from 'bun:test'
|
|
13
23
|
import type { RelocateEnv } from '../../relocate'
|
|
14
24
|
import { isInlinableInTemplate } from '../../relocate'
|
|
15
25
|
import type { BindingKind } from '../../types'
|
|
26
|
+
import type { LoweringMatcher } from '../../lowering-registry'
|
|
16
27
|
|
|
17
28
|
function envWith(
|
|
18
29
|
bindings: Array<[string, BindingKind]>,
|
|
@@ -28,6 +39,8 @@ function envWith(
|
|
|
28
39
|
allowFallback: options?.allowFallback ?? true,
|
|
29
40
|
templatePrimitives: options?.templatePrimitives,
|
|
30
41
|
acceptsTemplateCall: options?.acceptsTemplateCall,
|
|
42
|
+
loweringMatchers: options?.loweringMatchers,
|
|
43
|
+
aliasTargets: options?.aliasTargets,
|
|
31
44
|
}
|
|
32
45
|
}
|
|
33
46
|
|
|
@@ -256,3 +269,220 @@ describe('isInlinableInTemplate — adapter-aware acceptance (#1187 phase 2)', (
|
|
|
256
269
|
})
|
|
257
270
|
})
|
|
258
271
|
})
|
|
272
|
+
|
|
273
|
+
/** A `LoweringMatcher` that recognises a bare-identifier call to `name`. */
|
|
274
|
+
function matcherFor(name: string): LoweringMatcher {
|
|
275
|
+
return (callee, args) => {
|
|
276
|
+
if (callee.kind !== 'identifier' || callee.name !== name) return null
|
|
277
|
+
return { kind: 'helper-call', helper: 'test_helper', args }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
describe('isInlinableInTemplate — loweringMatchers acceptance (#2069)', () => {
|
|
282
|
+
test('a call recognised by a registered matcher is accepted', () => {
|
|
283
|
+
const env = envWith(
|
|
284
|
+
[
|
|
285
|
+
['customSerialize', 'module-import'],
|
|
286
|
+
['config', 'prop'],
|
|
287
|
+
],
|
|
288
|
+
{
|
|
289
|
+
propsObjectName: null,
|
|
290
|
+
loweringMatchers: [matcherFor('customSerialize')],
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
const r = isInlinableInTemplate('customSerialize(config)', env)
|
|
294
|
+
expect(r.ok).toBe(true)
|
|
295
|
+
expect(r.rewrittenValue).toContain('customSerialize')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('without a matching matcher, the call is still rejected', () => {
|
|
299
|
+
const env = envWith(
|
|
300
|
+
[
|
|
301
|
+
['customSerialize', 'module-import'],
|
|
302
|
+
['config', 'prop'],
|
|
303
|
+
],
|
|
304
|
+
{
|
|
305
|
+
propsObjectName: null,
|
|
306
|
+
loweringMatchers: [matcherFor('someOtherHelper')],
|
|
307
|
+
},
|
|
308
|
+
)
|
|
309
|
+
const r = isInlinableInTemplate('customSerialize(config)', env)
|
|
310
|
+
expect(r.ok).toBe(false)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test('templatePrimitives / acceptsTemplateCall / loweringMatchers are all independent acceptance paths', () => {
|
|
314
|
+
// Only the matcher accepts here — no string-keyed entry exists at all.
|
|
315
|
+
const env = envWith(
|
|
316
|
+
[
|
|
317
|
+
['customSerialize', 'module-import'],
|
|
318
|
+
['config', 'prop'],
|
|
319
|
+
],
|
|
320
|
+
{
|
|
321
|
+
propsObjectName: null,
|
|
322
|
+
templatePrimitives: {},
|
|
323
|
+
acceptsTemplateCall: () => false,
|
|
324
|
+
loweringMatchers: [matcherFor('customSerialize')],
|
|
325
|
+
},
|
|
326
|
+
)
|
|
327
|
+
const r = isInlinableInTemplate('customSerialize(config)', env)
|
|
328
|
+
expect(r.ok).toBe(true)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
describe('shadow guard applies to matcher acceptance too', () => {
|
|
332
|
+
test('a local binding shadowing the recognised name is refused', () => {
|
|
333
|
+
// `customSerialize` here is a component-local (e.g. reassigned from
|
|
334
|
+
// a prop), NOT the module import the plugin's `prepare()` resolved
|
|
335
|
+
// against. The matcher itself has no way to know this — the shadow
|
|
336
|
+
// guard in `isCallAcceptedByAdapter` is what keeps it from firing.
|
|
337
|
+
const env = envWith(
|
|
338
|
+
[
|
|
339
|
+
['customSerialize', 'init-local'], // shadows the import
|
|
340
|
+
['config', 'prop'],
|
|
341
|
+
],
|
|
342
|
+
{
|
|
343
|
+
propsObjectName: null,
|
|
344
|
+
loweringMatchers: [matcherFor('customSerialize')],
|
|
345
|
+
},
|
|
346
|
+
)
|
|
347
|
+
const r = isInlinableInTemplate('customSerialize(config)', env)
|
|
348
|
+
expect(r.ok).toBe(false)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
test('module-import / module-local bindings still activate the matcher', () => {
|
|
352
|
+
const env = envWith(
|
|
353
|
+
[
|
|
354
|
+
['customSerialize', 'module-local'],
|
|
355
|
+
['config', 'prop'],
|
|
356
|
+
],
|
|
357
|
+
{
|
|
358
|
+
propsObjectName: null,
|
|
359
|
+
loweringMatchers: [matcherFor('customSerialize')],
|
|
360
|
+
},
|
|
361
|
+
)
|
|
362
|
+
const r = isInlinableInTemplate('customSerialize(config)', env)
|
|
363
|
+
expect(r.ok).toBe(true)
|
|
364
|
+
})
|
|
365
|
+
})
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
describe('isInlinableInTemplate — one-hop alias resolution (#2069 R2)', () => {
|
|
369
|
+
test('alias → registered builtin (templatePrimitives) is accepted', () => {
|
|
370
|
+
// const fmt = Math.floor; fmt(score)
|
|
371
|
+
const env = envWith(
|
|
372
|
+
[
|
|
373
|
+
['fmt', 'init-local'],
|
|
374
|
+
['Math', 'global'],
|
|
375
|
+
['score', 'prop'],
|
|
376
|
+
],
|
|
377
|
+
{
|
|
378
|
+
propsObjectName: null,
|
|
379
|
+
aliasTargets: new Map([['fmt', 'Math.floor']]),
|
|
380
|
+
templatePrimitives: {
|
|
381
|
+
'Math.floor': (args) => `Math.floor(${args[0]})`,
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
)
|
|
385
|
+
const r = isInlinableInTemplate('fmt(score)', env)
|
|
386
|
+
expect(r.ok).toBe(true)
|
|
387
|
+
expect(r.rewrittenValue).toContain('Math.floor')
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
test('aliased-namespace MEMBER callee resolves too (const m = Math; m.floor)', () => {
|
|
391
|
+
// The leftmost segment is spliced and the `.path` tail carried over —
|
|
392
|
+
// `m.floor(score)` keys the registry as `Math.floor` (Copilot review
|
|
393
|
+
// on #2097 pinned that this is intended, not bare-identifier-only).
|
|
394
|
+
const env = envWith(
|
|
395
|
+
[
|
|
396
|
+
['m', 'init-local'],
|
|
397
|
+
['Math', 'global'],
|
|
398
|
+
['score', 'prop'],
|
|
399
|
+
],
|
|
400
|
+
{
|
|
401
|
+
propsObjectName: null,
|
|
402
|
+
aliasTargets: new Map([['m', 'Math']]),
|
|
403
|
+
templatePrimitives: {
|
|
404
|
+
'Math.floor': (args) => `Math.floor(${args[0]})`,
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
)
|
|
408
|
+
const r = isInlinableInTemplate('m.floor(score)', env)
|
|
409
|
+
expect(r.ok).toBe(true)
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
test('alias → a loweringMatchers-recognised import is accepted', () => {
|
|
413
|
+
// const serialize = customSerialize; serialize(config)
|
|
414
|
+
const env = envWith(
|
|
415
|
+
[
|
|
416
|
+
['serialize', 'init-local'],
|
|
417
|
+
['customSerialize', 'module-import'],
|
|
418
|
+
['config', 'prop'],
|
|
419
|
+
],
|
|
420
|
+
{
|
|
421
|
+
propsObjectName: null,
|
|
422
|
+
aliasTargets: new Map([['serialize', 'customSerialize']]),
|
|
423
|
+
loweringMatchers: [matcherFor('customSerialize')],
|
|
424
|
+
},
|
|
425
|
+
)
|
|
426
|
+
const r = isInlinableInTemplate('serialize(config)', env)
|
|
427
|
+
expect(r.ok).toBe(true)
|
|
428
|
+
expect(r.rewrittenValue).toContain('customSerialize')
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
test('alias → alias chain is refused — only ONE hop is resolved', () => {
|
|
432
|
+
// const g = f (f is itself just another local, never resolved further)
|
|
433
|
+
const env = envWith(
|
|
434
|
+
[
|
|
435
|
+
['g', 'init-local'],
|
|
436
|
+
['f', 'init-local'],
|
|
437
|
+
['config', 'prop'],
|
|
438
|
+
],
|
|
439
|
+
{
|
|
440
|
+
propsObjectName: null,
|
|
441
|
+
aliasTargets: new Map([['g', 'f']]),
|
|
442
|
+
// Maximally permissive predicate — even this can't help, because
|
|
443
|
+
// the shadow guard rejects on `f`'s own (unsafe) binding kind
|
|
444
|
+
// before the predicate is ever consulted.
|
|
445
|
+
acceptsTemplateCall: () => true,
|
|
446
|
+
},
|
|
447
|
+
)
|
|
448
|
+
const r = isInlinableInTemplate('g(config)', env)
|
|
449
|
+
expect(r.ok).toBe(false)
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
test('alias → a non-import component-local helper is refused', () => {
|
|
453
|
+
// const h = helper (helper is a local function/const, not module-scope)
|
|
454
|
+
const env = envWith(
|
|
455
|
+
[
|
|
456
|
+
['h', 'init-local'],
|
|
457
|
+
['helper', 'init-local'],
|
|
458
|
+
['config', 'prop'],
|
|
459
|
+
],
|
|
460
|
+
{
|
|
461
|
+
propsObjectName: null,
|
|
462
|
+
aliasTargets: new Map([['h', 'helper']]),
|
|
463
|
+
acceptsTemplateCall: () => true,
|
|
464
|
+
},
|
|
465
|
+
)
|
|
466
|
+
const r = isInlinableInTemplate('h(config)', env)
|
|
467
|
+
expect(r.ok).toBe(false)
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
test('a plain non-alias init-local is unaffected by aliasTargets being present', () => {
|
|
471
|
+
// Baseline: aliasTargets has entries for OTHER names, but this
|
|
472
|
+
// reference isn't one of them — falls through to the ordinary
|
|
473
|
+
// init-local fallback, same as if aliasTargets were absent.
|
|
474
|
+
const env = envWith(
|
|
475
|
+
[
|
|
476
|
+
['count', 'init-local'],
|
|
477
|
+
['config', 'prop'],
|
|
478
|
+
],
|
|
479
|
+
{
|
|
480
|
+
propsObjectName: null,
|
|
481
|
+
aliasTargets: new Map([['fmt', 'Math.floor']]),
|
|
482
|
+
},
|
|
483
|
+
)
|
|
484
|
+
const r = isInlinableInTemplate('count', env)
|
|
485
|
+
expect(r.ok).toBe(true) // fallback to 'undefined', not a rejection
|
|
486
|
+
expect(r.rewrittenValue).toBe('undefined')
|
|
487
|
+
})
|
|
488
|
+
})
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tagged-template interleave-tag desugaring (#2092, Refs #2069).
|
|
3
|
+
*
|
|
4
|
+
* The classname-tag idiom:
|
|
5
|
+
*
|
|
6
|
+
* function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
|
|
7
|
+
* return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
|
|
8
|
+
* }
|
|
9
|
+
* className={cn`base ${tone()}`}
|
|
10
|
+
*
|
|
11
|
+
* Before #2092 the compiler treated `cn\`...\`` as an opaque scalar leaf: a
|
|
12
|
+
* `TaggedTemplateExpression` falls through `expression-parser.ts` as
|
|
13
|
+
* `unsupported`, and every non-JS template adapter refused it with BF101.
|
|
14
|
+
*
|
|
15
|
+
* #2092 resolves the tag identifier one hop through same-file scope (reusing
|
|
16
|
+
* #2090's `findLocalConst` / `findLocalFunction`), structurally proves the
|
|
17
|
+
* resolved function matches the "interleave tag" catalogue (see
|
|
18
|
+
* `isInterleaveTagFunction` in `jsx-to-ir.ts`), and — only then — REWRITES
|
|
19
|
+
* the whole tagged template to the equivalent untagged template literal
|
|
20
|
+
* (`\`base \${(tone()) ?? ''}\``) before the rest of the compiler ever sees
|
|
21
|
+
* it. Everything else (dep analysis, template-literal parts, adapter emit,
|
|
22
|
+
* client-JS binding) runs unchanged against the rewritten node.
|
|
23
|
+
*
|
|
24
|
+
* These tests exercise the recognizer directly against the IR (so assertions
|
|
25
|
+
* don't depend on any one adapter's output formatting) via `analyzeComponent`
|
|
26
|
+
* + `jsxToIR`, matching `ir-sort-comparator.test.ts`'s style for the sibling
|
|
27
|
+
* #2090 feature.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, test, expect } from 'bun:test'
|
|
31
|
+
import { analyzeComponent } from '../analyzer'
|
|
32
|
+
import { jsxToIR } from '../jsx-to-ir'
|
|
33
|
+
import type { IRElement, ExpressionAttr } from '../types'
|
|
34
|
+
|
|
35
|
+
/** Compile `source` and return the root element's `className` attribute
|
|
36
|
+
* value from the IR (the IR keeps the JSX name `className`; adapters
|
|
37
|
+
* render it as `class` later). */
|
|
38
|
+
function classAttrValue(source: string, filename = 'TagDemo.tsx') {
|
|
39
|
+
const ctx = analyzeComponent(source, filename)
|
|
40
|
+
const ir = jsxToIR(ctx)
|
|
41
|
+
expect(ir).not.toBeNull()
|
|
42
|
+
expect(ir!.type).toBe('element')
|
|
43
|
+
const el = ir as IRElement
|
|
44
|
+
const attr = el.attrs.find(a => a.name === 'className')
|
|
45
|
+
expect(attr).toBeDefined()
|
|
46
|
+
return attr!.value
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('tagged-template interleave-tag recognition (#2092)', () => {
|
|
50
|
+
test('recognized cn`base ${tone()}` desugars to the untagged template literal', () => {
|
|
51
|
+
const source = `
|
|
52
|
+
'use client'
|
|
53
|
+
import { createSignal } from '@barefootjs/client'
|
|
54
|
+
function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
|
|
55
|
+
return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
|
|
56
|
+
}
|
|
57
|
+
export function TagDemo() {
|
|
58
|
+
const [tone, setTone] = createSignal('primary')
|
|
59
|
+
return <div onClick={() => setTone('secondary')} className={cn\`base \${tone()}\`}>x</div>
|
|
60
|
+
}
|
|
61
|
+
`
|
|
62
|
+
const value = classAttrValue(source)
|
|
63
|
+
expect(value.kind).toBe('expression')
|
|
64
|
+
const expr = (value as ExpressionAttr).expr
|
|
65
|
+
// The tag call is gone — replaced by the equivalent untagged template
|
|
66
|
+
// literal, each span wrapped in `(span) ?? ''`.
|
|
67
|
+
expect(expr).not.toMatch(/\bcn`/)
|
|
68
|
+
expect(expr).toBe("`base ${(tone()) ?? ''}`")
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('const-bound arrow interleave tag also resolves (findLocalConst path)', () => {
|
|
72
|
+
const source = `
|
|
73
|
+
'use client'
|
|
74
|
+
import { createSignal } from '@barefootjs/client'
|
|
75
|
+
const cn = (parts: TemplateStringsArray, ...args: unknown[]): string =>
|
|
76
|
+
parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
|
|
77
|
+
export function TagDemo() {
|
|
78
|
+
const [tone, setTone] = createSignal('primary')
|
|
79
|
+
return <div onClick={() => setTone('secondary')} className={cn\`base \${tone()}\`}>x</div>
|
|
80
|
+
}
|
|
81
|
+
`
|
|
82
|
+
const value = classAttrValue(source)
|
|
83
|
+
expect(value.kind).toBe('expression')
|
|
84
|
+
expect((value as ExpressionAttr).expr).toBe("`base ${(tone()) ?? ''}`")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('String(...)-wrapped span still matches the catalogue', () => {
|
|
88
|
+
const source = `
|
|
89
|
+
'use client'
|
|
90
|
+
import { createSignal } from '@barefootjs/client'
|
|
91
|
+
function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
|
|
92
|
+
return parts.reduce<string>((acc, p, i) => acc + p + String(args[i] ?? ''), '')
|
|
93
|
+
}
|
|
94
|
+
export function TagDemo() {
|
|
95
|
+
const [tone] = createSignal('primary')
|
|
96
|
+
return <div className={cn\`base \${tone()}\`}>x</div>
|
|
97
|
+
}
|
|
98
|
+
`
|
|
99
|
+
const value = classAttrValue(source)
|
|
100
|
+
expect(value.kind).toBe('expression')
|
|
101
|
+
expect((value as ExpressionAttr).expr).toBe("`base ${(tone()) ?? ''}`")
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('no-substitution template (no interpolation) desugars cleanly', () => {
|
|
105
|
+
const source = `
|
|
106
|
+
'use client'
|
|
107
|
+
function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
|
|
108
|
+
return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
|
|
109
|
+
}
|
|
110
|
+
export function TagDemo() {
|
|
111
|
+
return <div className={cn\`base\`}>x</div>
|
|
112
|
+
}
|
|
113
|
+
`
|
|
114
|
+
const value = classAttrValue(source)
|
|
115
|
+
// No spans at all — the rewrite produces a plain
|
|
116
|
+
// NoSubstitutionTemplateLiteral, not an "expression" carrying a call.
|
|
117
|
+
expect(value.kind).toBe('expression')
|
|
118
|
+
expect((value as ExpressionAttr).expr).toBe('`base`')
|
|
119
|
+
expect((value as ExpressionAttr).expr).not.toMatch(/\bcn`/)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('escaped backtick / literal ${...} / backslash chunk survives the rewrite verbatim', () => {
|
|
123
|
+
// Chunk raw text (as written in source): `pre\` mid \${lit} end\\stop `
|
|
124
|
+
// — a literal backtick, a literal (non-substituting) `${lit}`, and a
|
|
125
|
+
// literal backslash, followed by the ONE real span `${tone()}`.
|
|
126
|
+
const source = `
|
|
127
|
+
'use client'
|
|
128
|
+
import { createSignal } from '@barefootjs/client'
|
|
129
|
+
function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
|
|
130
|
+
return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
|
|
131
|
+
}
|
|
132
|
+
export function TagDemo() {
|
|
133
|
+
const [tone] = createSignal('primary')
|
|
134
|
+
return <div className={cn\`pre\\\` mid \\\${lit} end\\\\stop \${tone()} post\`}>x</div>
|
|
135
|
+
}
|
|
136
|
+
`
|
|
137
|
+
const value = classAttrValue(source)
|
|
138
|
+
expect(value.kind).toBe('expression')
|
|
139
|
+
const expr = (value as ExpressionAttr).expr
|
|
140
|
+
// The escape sequences (backtick, `${`, backslash) must survive
|
|
141
|
+
// byte-for-byte in the rewritten source — using the COOKED text
|
|
142
|
+
// instead of `rawText` would either mis-parse the reconstructed
|
|
143
|
+
// template or silently turn `${lit}` into a real (broken) substitution.
|
|
144
|
+
expect(expr).toBe("`pre\\` mid \\${lit} end\\\\stop ${(tone()) ?? ''} post`")
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('imported tag identifier is NOT resolved — left untouched (today\'s BF101 path)', () => {
|
|
148
|
+
const source = `
|
|
149
|
+
'use client'
|
|
150
|
+
import { createSignal } from '@barefootjs/client'
|
|
151
|
+
import { cn } from './cn-helper'
|
|
152
|
+
export function TagDemo() {
|
|
153
|
+
const [tone] = createSignal('primary')
|
|
154
|
+
return <div className={cn\`base \${tone()}\`}>x</div>
|
|
155
|
+
}
|
|
156
|
+
`
|
|
157
|
+
const value = classAttrValue(source)
|
|
158
|
+
expect(value.kind).toBe('expression')
|
|
159
|
+
// Unresolved (no same-file binding) — the tag call is preserved verbatim.
|
|
160
|
+
expect((value as ExpressionAttr).expr).toContain('cn`base')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('cross-kind name collision (const + function binding) is NOT resolved', () => {
|
|
164
|
+
// Same ambiguity rule as resolveSortComparatorIdentifier (#2091 review):
|
|
165
|
+
// a name bound both as a const and as a `function` declaration can only
|
|
166
|
+
// occur across scopes, and FunctionInfo carries emission placement (not
|
|
167
|
+
// lexical position), so resolution refuses rather than guessing which
|
|
168
|
+
// tag the call site actually sees. The node stays opaque (BF101 path).
|
|
169
|
+
const source = `
|
|
170
|
+
'use client'
|
|
171
|
+
import { createSignal } from '@barefootjs/client'
|
|
172
|
+
function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
|
|
173
|
+
return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
|
|
174
|
+
}
|
|
175
|
+
export function TagDemo() {
|
|
176
|
+
const [tone] = createSignal('primary')
|
|
177
|
+
const cn = (parts: TemplateStringsArray, ...args: unknown[]) =>
|
|
178
|
+
parts.reduce<string>((acc, p, i) => acc + p + '-' + (args[i] ?? ''), '')
|
|
179
|
+
return <div className={cn\`base \${tone()}\`}>x</div>
|
|
180
|
+
}
|
|
181
|
+
`
|
|
182
|
+
const value = classAttrValue(source)
|
|
183
|
+
expect(value.kind).toBe('expression')
|
|
184
|
+
expect((value as ExpressionAttr).expr).toContain('cn`base')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('off-catalogue body (joins with a separator) is NOT recognized', () => {
|
|
188
|
+
const source = `
|
|
189
|
+
'use client'
|
|
190
|
+
import { createSignal } from '@barefootjs/client'
|
|
191
|
+
function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
|
|
192
|
+
return parts.reduce<string>((acc, p, i) => acc + p + '-' + (args[i] ?? ''), '')
|
|
193
|
+
}
|
|
194
|
+
export function TagDemo() {
|
|
195
|
+
const [tone] = createSignal('primary')
|
|
196
|
+
return <div className={cn\`base \${tone()}\`}>x</div>
|
|
197
|
+
}
|
|
198
|
+
`
|
|
199
|
+
const value = classAttrValue(source)
|
|
200
|
+
expect(value.kind).toBe('expression')
|
|
201
|
+
expect((value as ExpressionAttr).expr).toContain('cn`base')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('computed tag (member expression) is NOT recognized', () => {
|
|
205
|
+
const source = `
|
|
206
|
+
'use client'
|
|
207
|
+
import { createSignal } from '@barefootjs/client'
|
|
208
|
+
const helpers = {
|
|
209
|
+
cn(parts: TemplateStringsArray, ...args: unknown[]): string {
|
|
210
|
+
return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
export function TagDemo() {
|
|
214
|
+
const [tone] = createSignal('primary')
|
|
215
|
+
return <div className={helpers.cn\`base \${tone()}\`}>x</div>
|
|
216
|
+
}
|
|
217
|
+
`
|
|
218
|
+
const value = classAttrValue(source)
|
|
219
|
+
expect(value.kind).toBe('expression')
|
|
220
|
+
// Not even an Identifier tag — the recognizer only matches a bare
|
|
221
|
+
// identifier tag (`tryDesugarInterleaveTaggedTemplate` requires
|
|
222
|
+
// `ts.isIdentifier(expr.tag)`), so a computed tag is never attempted.
|
|
223
|
+
expect((value as ExpressionAttr).expr).toContain('helpers.cn`base')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('non-rest second parameter is NOT recognized', () => {
|
|
227
|
+
const source = `
|
|
228
|
+
'use client'
|
|
229
|
+
import { createSignal } from '@barefootjs/client'
|
|
230
|
+
function cn(parts: TemplateStringsArray, args: unknown[]): string {
|
|
231
|
+
return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
|
|
232
|
+
}
|
|
233
|
+
export function TagDemo() {
|
|
234
|
+
const [tone] = createSignal('primary')
|
|
235
|
+
return <div className={cn\`base \${tone()}\`}>x</div>
|
|
236
|
+
}
|
|
237
|
+
`
|
|
238
|
+
const value = classAttrValue(source)
|
|
239
|
+
expect(value.kind).toBe('expression')
|
|
240
|
+
expect((value as ExpressionAttr).expr).toContain('cn`base')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('/* @client */ still defers a recognized interleave tag to the client', () => {
|
|
244
|
+
const source = `
|
|
245
|
+
'use client'
|
|
246
|
+
import { createSignal } from '@barefootjs/client'
|
|
247
|
+
function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
|
|
248
|
+
return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
|
|
249
|
+
}
|
|
250
|
+
export function TagDemo() {
|
|
251
|
+
const [tone, setTone] = createSignal('primary')
|
|
252
|
+
return <div onClick={() => setTone('secondary')} className={/* @client */ cn\`base \${tone()}\`}>x</div>
|
|
253
|
+
}
|
|
254
|
+
`
|
|
255
|
+
const ctx = analyzeComponent(source, 'TagDemo.tsx')
|
|
256
|
+
const ir = jsxToIR(ctx)
|
|
257
|
+
expect(ir).not.toBeNull()
|
|
258
|
+
const el = ir as IRElement
|
|
259
|
+
const attr = el.attrs.find(a => a.name === 'className')
|
|
260
|
+
expect(attr).toBeDefined()
|
|
261
|
+
// The desugar still applies (the client-only directive doesn't block
|
|
262
|
+
// recognition — it's read independently from the original node), and
|
|
263
|
+
// the attribute is still flagged clientOnly so it defers to hydrate.
|
|
264
|
+
expect(attr!.clientOnly).toBe(true)
|
|
265
|
+
expect(attr!.value.kind).toBe('expression')
|
|
266
|
+
expect((attr!.value as ExpressionAttr).expr).toBe("`base ${(tone()) ?? ''}`")
|
|
267
|
+
})
|
|
268
|
+
})
|