@barefootjs/jsx 0.17.0 → 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/env-signal.d.ts +42 -7
- package/dist/adapters/env-signal.d.ts.map +1 -1
- 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 +48 -2
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +24 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1728 -969
- 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/ssr-seed-plan.d.ts +84 -0
- package/dist/ssr-seed-plan.d.ts.map +1 -0
- package/dist/types.d.ts +79 -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 +74 -3
- package/src/__tests__/free-identifiers.test.ts +55 -0
- 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__/materialize-getter-calls.test.ts +58 -0
- package/src/__tests__/props-destructuring.test.ts +110 -0
- package/src/__tests__/serialize-parsed-expr.test.ts +88 -2
- package/src/__tests__/ssr-defaults.test.ts +20 -0
- package/src/__tests__/ssr-seed-plan.test.ts +212 -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/env-signal.ts +57 -9
- 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 +10 -1
- package/src/expression-parser.ts +421 -50
- package/src/index.ts +30 -3
- 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/ssr-seed-plan.ts +146 -0
- package/src/types.ts +76 -0
|
@@ -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
|
+
})
|
|
@@ -136,12 +136,14 @@ describe('Unsupported Expression Error (BF021)', () => {
|
|
|
136
136
|
})
|
|
137
137
|
|
|
138
138
|
describe('Unsupported Sort Comparator (BF021)', () => {
|
|
139
|
-
test('
|
|
139
|
+
test('function-reference comparator resolves through scope — no BF021 (#2090)', () => {
|
|
140
140
|
// #1448 Tier B follow-up widened the catalogue to include
|
|
141
141
|
// multi-key (`a.x - b.x || a.y - b.y`), relational ternary, and
|
|
142
|
-
// single-`return` block bodies.
|
|
143
|
-
// (`arr.sort(cmp)`
|
|
144
|
-
//
|
|
142
|
+
// single-`return` block bodies. #2090 closes the remaining gap:
|
|
143
|
+
// a bare identifier callback (`arr.sort(cmp)`) is now resolved
|
|
144
|
+
// through the analyzer's scope machinery (one hop, same-file
|
|
145
|
+
// only) to the const-bound arrow, then fed through the same
|
|
146
|
+
// catalogue as an inline comparator.
|
|
145
147
|
const source = `
|
|
146
148
|
'use client'
|
|
147
149
|
import { createSignal } from '@barefootjs/client'
|
|
@@ -152,7 +154,73 @@ describe('Unsupported Sort Comparator (BF021)', () => {
|
|
|
152
154
|
return (
|
|
153
155
|
<ul>
|
|
154
156
|
{items().sort(cmp).map(t => (
|
|
155
|
-
<li>{t.name}</li>
|
|
157
|
+
<li key={t.name}>{t.name}</li>
|
|
158
|
+
))}
|
|
159
|
+
</ul>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
`
|
|
163
|
+
|
|
164
|
+
const { ir, errors } = compileToIR(source)
|
|
165
|
+
const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
|
|
166
|
+
|
|
167
|
+
expect(bf021).toHaveLength(0)
|
|
168
|
+
expect(ir).not.toBeNull()
|
|
169
|
+
if (ir!.type === 'element') {
|
|
170
|
+
const loop = ir!.children.find(c => c.type === 'loop')
|
|
171
|
+
expect(loop).toBeDefined()
|
|
172
|
+
if (loop?.type === 'loop') {
|
|
173
|
+
expect(loop.sortComparator).toBeDefined()
|
|
174
|
+
expect(loop.sortComparator!.raw).toBe('a.priority - b.priority')
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('function-declaration comparator reference resolves — no BF021 (#2090)', () => {
|
|
180
|
+
const source = `
|
|
181
|
+
'use client'
|
|
182
|
+
import { createSignal } from '@barefootjs/client'
|
|
183
|
+
|
|
184
|
+
function cmp(a, b) { return a.priority - b.priority }
|
|
185
|
+
|
|
186
|
+
export function TodoList() {
|
|
187
|
+
const [items, setItems] = createSignal<any[]>([])
|
|
188
|
+
return (
|
|
189
|
+
<ul>
|
|
190
|
+
{items().toSorted(cmp).map(t => (
|
|
191
|
+
<li key={t.name}>{t.name}</li>
|
|
192
|
+
))}
|
|
193
|
+
</ul>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
`
|
|
197
|
+
|
|
198
|
+
const { ir, errors } = compileToIR(source)
|
|
199
|
+
const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
|
|
200
|
+
|
|
201
|
+
expect(bf021).toHaveLength(0)
|
|
202
|
+
expect(ir).not.toBeNull()
|
|
203
|
+
if (ir!.type === 'element') {
|
|
204
|
+
const loop = ir!.children.find(c => c.type === 'loop')
|
|
205
|
+
if (loop?.type === 'loop') {
|
|
206
|
+
expect(loop.sortComparator).toBeDefined()
|
|
207
|
+
expect(loop.sortComparator!.raw).toBe('a.priority - b.priority')
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('identifier resolving to a non-function const emits BF021 (#2090)', () => {
|
|
213
|
+
const source = `
|
|
214
|
+
'use client'
|
|
215
|
+
import { createSignal } from '@barefootjs/client'
|
|
216
|
+
|
|
217
|
+
export function TodoList() {
|
|
218
|
+
const [items, setItems] = createSignal<any[]>([])
|
|
219
|
+
const cmp = 5
|
|
220
|
+
return (
|
|
221
|
+
<ul>
|
|
222
|
+
{items().sort(cmp).map(t => (
|
|
223
|
+
<li key={t.name}>{t.name}</li>
|
|
156
224
|
))}
|
|
157
225
|
</ul>
|
|
158
226
|
)
|
|
@@ -163,16 +231,135 @@ describe('Unsupported Sort Comparator (BF021)', () => {
|
|
|
163
231
|
const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
|
|
164
232
|
|
|
165
233
|
expect(bf021).toHaveLength(1)
|
|
234
|
+
expect(bf021[0].message).toContain('could not be resolved')
|
|
166
235
|
})
|
|
167
236
|
|
|
168
|
-
test('
|
|
237
|
+
test('cross-kind shadowing (component const over module function) emits BF021, not the shadowed function (#2090)', () => {
|
|
238
|
+
// A name bound both as a const and as a `function` declaration can
|
|
239
|
+
// only happen across scopes, and FunctionInfo does not carry lexical
|
|
240
|
+
// scope (component-body functions are hoisted for client emission).
|
|
241
|
+
// Resolution refuses the ambiguity rather than guessing — pre-fix
|
|
242
|
+
// this wrongly compiled against the shadowed module function
|
|
243
|
+
// (Copilot review on #2091).
|
|
169
244
|
const source = `
|
|
170
245
|
'use client'
|
|
171
246
|
import { createSignal } from '@barefootjs/client'
|
|
172
247
|
|
|
248
|
+
function cmp(a, b) { return a.priority - b.priority }
|
|
249
|
+
|
|
250
|
+
export function TodoList() {
|
|
251
|
+
const [items, setItems] = createSignal<any[]>([])
|
|
252
|
+
const cmp = 5
|
|
253
|
+
return (
|
|
254
|
+
<ul>
|
|
255
|
+
{items().sort(cmp).map(t => (
|
|
256
|
+
<li key={t.name}>{t.name}</li>
|
|
257
|
+
))}
|
|
258
|
+
</ul>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
`
|
|
262
|
+
|
|
263
|
+
const { errors } = compileToIR(source)
|
|
264
|
+
const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
|
|
265
|
+
|
|
266
|
+
expect(bf021).toHaveLength(1)
|
|
267
|
+
expect(bf021[0].message).toContain('could not be resolved')
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('cross-kind shadowing (component function over module const) also refuses with BF021 (#2090)', () => {
|
|
271
|
+
// The safe half of the same ambiguity: JS would use the component
|
|
272
|
+
// function here, but resolution cannot prove which binding the call
|
|
273
|
+
// site sees, so it refuses loudly instead of risking the WRONG
|
|
274
|
+
// (opposite-direction) comparator.
|
|
275
|
+
const source = `
|
|
276
|
+
'use client'
|
|
277
|
+
import { createSignal } from '@barefootjs/client'
|
|
278
|
+
|
|
279
|
+
const byPrice = (a, b) => a.price - b.price
|
|
280
|
+
|
|
281
|
+
export function ProductList() {
|
|
282
|
+
const [products, setProducts] = createSignal<any[]>([])
|
|
283
|
+
function byPrice(a, b) { return b.price - a.price }
|
|
284
|
+
return (
|
|
285
|
+
<ul>
|
|
286
|
+
{products().sort(byPrice).map(p => (
|
|
287
|
+
<li key={p.name}>{p.name}</li>
|
|
288
|
+
))}
|
|
289
|
+
</ul>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
`
|
|
293
|
+
|
|
294
|
+
const { errors } = compileToIR(source)
|
|
295
|
+
const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
|
|
296
|
+
|
|
297
|
+
expect(bf021).toHaveLength(1)
|
|
298
|
+
expect(bf021[0].message).toContain('could not be resolved')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('unresolved (imported) identifier comparator emits BF021 (#2090)', () => {
|
|
302
|
+
const source = `
|
|
303
|
+
'use client'
|
|
304
|
+
import { createSignal } from '@barefootjs/client'
|
|
305
|
+
import { cmp } from './cmp'
|
|
306
|
+
|
|
307
|
+
export function TodoList() {
|
|
308
|
+
const [items, setItems] = createSignal<any[]>([])
|
|
309
|
+
return (
|
|
310
|
+
<ul>
|
|
311
|
+
{items().sort(cmp).map(t => (
|
|
312
|
+
<li key={t.name}>{t.name}</li>
|
|
313
|
+
))}
|
|
314
|
+
</ul>
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
`
|
|
318
|
+
|
|
319
|
+
const { errors } = compileToIR(source)
|
|
320
|
+
const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
|
|
321
|
+
|
|
322
|
+
expect(bf021).toHaveLength(1)
|
|
323
|
+
expect(bf021[0].message).toContain('could not be resolved')
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('resolved-but-off-catalogue comparator body emits BF021 naming the comparator (#2090)', () => {
|
|
327
|
+
// `a.deep.x - b.deep.x` has operand depth > 1 — `classifySortOperand`
|
|
328
|
+
// only accepts the param itself or a single-level field access, so this
|
|
329
|
+
// stays refused even once the identifier resolves to the arrow.
|
|
330
|
+
const source = `
|
|
331
|
+
'use client'
|
|
332
|
+
import { createSignal } from '@barefootjs/client'
|
|
333
|
+
|
|
334
|
+
export function TodoList() {
|
|
335
|
+
const [items, setItems] = createSignal<any[]>([])
|
|
336
|
+
const cmp = (a, b) => a.deep.x - b.deep.x
|
|
337
|
+
return (
|
|
338
|
+
<ul>
|
|
339
|
+
{items().sort(cmp).map(t => (
|
|
340
|
+
<li key={t.name}>{t.name}</li>
|
|
341
|
+
))}
|
|
342
|
+
</ul>
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
`
|
|
346
|
+
|
|
347
|
+
const { errors } = compileToIR(source)
|
|
348
|
+
const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
|
|
349
|
+
|
|
350
|
+
expect(bf021).toHaveLength(1)
|
|
351
|
+
expect(bf021[0].message).toContain("'cmp'")
|
|
352
|
+
expect(bf021[0].message).toContain('not a supported shape')
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
test('@client suppresses BF021 for an unresolved sort comparator identifier', () => {
|
|
356
|
+
const source = `
|
|
357
|
+
'use client'
|
|
358
|
+
import { createSignal } from '@barefootjs/client'
|
|
359
|
+
import { cmp } from './cmp'
|
|
360
|
+
|
|
173
361
|
export function TodoList() {
|
|
174
362
|
const [items, setItems] = createSignal<any[]>([])
|
|
175
|
-
const cmp = (a, b) => a.priority - b.priority
|
|
176
363
|
return (
|
|
177
364
|
<ul>
|
|
178
365
|
{/* @client */ items().sort(cmp).map(t => (
|
|
@@ -22,28 +22,76 @@ export const ENV_SIGNAL_CLIENT_FACTORY: Record<string, string> = {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
25
|
+
* One env signal's SSR-reader surface — the single place a future env signal
|
|
26
|
+
* registers itself so the adapter seed / memo paths stay open-closed:
|
|
27
|
+
* registering a new env signal is an analyzer factory entry + one registry
|
|
28
|
+
* entry here; adapter seed/memo paths consume the registry and need no edits.
|
|
29
|
+
*/
|
|
30
|
+
export interface EnvSignalReader {
|
|
31
|
+
/** The analyzer's `envReader` key (`'search'`). */
|
|
32
|
+
key: string
|
|
33
|
+
/**
|
|
34
|
+
* Canonical per-request reader binding every adapter's lowering
|
|
35
|
+
* canonicalises to (`searchParams` → Perl `$searchParams`, Go
|
|
36
|
+
* `in.SearchParams` via capitalisation).
|
|
37
|
+
*/
|
|
38
|
+
canonicalName: string
|
|
39
|
+
/** Reader method names the SSR lowerings recognise (`.get(key)`). */
|
|
40
|
+
methods: ReadonlySet<string>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Env-signal key → its {@link EnvSignalReader} descriptor. The open-closed
|
|
45
|
+
* contract: adding a new env signal is an analyzer factory entry
|
|
46
|
+
* (`ENV_SIGNAL_FACTORIES`, #2057) + one entry here — the adapter seed / memo
|
|
47
|
+
* paths consume this registry (via {@link envSignalReaderFor} /
|
|
48
|
+
* {@link envSignalLocalNames}) and need no edits.
|
|
49
|
+
*/
|
|
50
|
+
export const ENV_SIGNAL_READERS: ReadonlyMap<string, EnvSignalReader> = new Map([
|
|
51
|
+
['search', { key: 'search', canonicalName: 'searchParams', methods: new Set(['get']) }],
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
/** Look up an env signal's reader descriptor by its `envReader` key, or `null` when unregistered/absent. */
|
|
55
|
+
export function envSignalReaderFor(key: string | undefined): EnvSignalReader | null {
|
|
56
|
+
if (key === undefined) return null
|
|
57
|
+
return ENV_SIGNAL_READERS.get(key) ?? null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The getter name(s) of env signal(s) in this component, optionally filtered
|
|
62
|
+
* to one `envReader` key.
|
|
26
63
|
*
|
|
27
|
-
* Recognised **structurally** (#2057):
|
|
64
|
+
* Recognised **structurally** (#2057): an env signal is declared as a
|
|
28
65
|
* `createSignal`-shaped `const [searchParams, setSearchParams] =
|
|
29
66
|
* createSearchParams()`, so the analyzer collects it into `metadata.signals`
|
|
30
|
-
* with `envReader: '
|
|
67
|
+
* with `envReader: '<key>'` — exactly like any other signal, but tagged. This
|
|
31
68
|
* function returns those getters (whatever the destructured name is —
|
|
32
69
|
* `searchParams`, or an alias), so adapters match the reader `.get()` call
|
|
33
|
-
* against the binding actually used, with **no
|
|
34
|
-
*
|
|
70
|
+
* against the binding actually used, with **no name allow-list** (this
|
|
71
|
+
* supersedes the import-name matching, and the closed #2055).
|
|
35
72
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
73
|
+
* With `key` omitted, collects every env signal's getters regardless of
|
|
74
|
+
* which reader they belong to; with `key` given, only that reader's
|
|
75
|
+
* (`searchParamsLocalNames` is the `'search'`-filtered convenience below).
|
|
76
|
+
*
|
|
77
|
+
* Empty when the component declares no matching env signal (the component
|
|
78
|
+
* keeps the generic signal lowering).
|
|
38
79
|
*/
|
|
39
|
-
export function
|
|
80
|
+
export function envSignalLocalNames(metadata: IRMetadata, key?: string): Set<string> {
|
|
40
81
|
const names = new Set<string>()
|
|
41
82
|
for (const s of metadata.signals) {
|
|
42
|
-
if (s.envReader ===
|
|
83
|
+
if (s.envReader !== undefined && (key === undefined || s.envReader === key)) {
|
|
84
|
+
names.add(s.getter)
|
|
85
|
+
}
|
|
43
86
|
}
|
|
44
87
|
return names
|
|
45
88
|
}
|
|
46
89
|
|
|
90
|
+
/** The getter name(s) of the `searchParams` env signal in this component. See {@link envSignalLocalNames}. */
|
|
91
|
+
export function searchParamsLocalNames(metadata: IRMetadata): Set<string> {
|
|
92
|
+
return envSignalLocalNames(metadata, 'search')
|
|
93
|
+
}
|
|
94
|
+
|
|
47
95
|
/**
|
|
48
96
|
* True when the component declares the `searchParams` env signal. Convenience
|
|
49
97
|
* for adapters/harnesses that only need to gate on presence (the lowering
|
|
@@ -96,9 +96,23 @@ export type TemplatePrimitiveEmit = (args: string[]) => string
|
|
|
96
96
|
* Keys are the textual callee path as it appears in the JSX expression
|
|
97
97
|
* (`JSON.stringify`, `Math.floor`, `String`).
|
|
98
98
|
*
|
|
99
|
-
* V1 scope: identifier-path callees only
|
|
100
|
-
*
|
|
101
|
-
*
|
|
99
|
+
* V1 scope (#1187): identifier-path callees only, and only names the adapter
|
|
100
|
+
* knows about AHEAD OF TIME — this map is fixed at adapter-construction
|
|
101
|
+
* time, so it can never contain a name from a component's own (unknown in
|
|
102
|
+
* advance) imports. Method calls on values whose type the analyzer must
|
|
103
|
+
* resolve (`props.name.toUpperCase()`) are ALSO out of scope — see #1187 R1.
|
|
104
|
+
* Users can fall back to `/* @client *\/` for either limitation.
|
|
105
|
+
*
|
|
106
|
+
* V2 (#2069) does not widen this map — it adds a separate, orthogonal
|
|
107
|
+
* acceptance path instead: `RelocateEnv.loweringMatchers`, bound once per
|
|
108
|
+
* component from the global `LoweringPlugin` registry
|
|
109
|
+
* (`prepareLoweringMatchers`, `packages/jsx/src/lowering-registry.ts`). A
|
|
110
|
+
* plugin's `prepare(metadata)` resolves the component's ACTUAL import list,
|
|
111
|
+
* so it can recognise a call this string-keyed map structurally never could
|
|
112
|
+
* (a bespoke user import, unknown until compile time). `templatePrimitives`
|
|
113
|
+
* remains the right home for well-known JS builtins (`JSON.stringify`,
|
|
114
|
+
* `Math.floor`) that every component might reasonably use, with no import
|
|
115
|
+
* to key a plugin against.
|
|
102
116
|
*/
|
|
103
117
|
export type TemplatePrimitiveRegistry = Record<string, TemplatePrimitiveEmit>
|
|
104
118
|
|
|
@@ -110,7 +124,9 @@ export type TemplatePrimitiveRegistry = Record<string, TemplatePrimitiveEmit>
|
|
|
110
124
|
*
|
|
111
125
|
* Adapters whose template runtime can't execute arbitrary JS (Go, Perl,
|
|
112
126
|
* other server-side template languages) should leave this undefined and
|
|
113
|
-
* rely on the explicit `templatePrimitives` map alone
|
|
127
|
+
* rely on the explicit `templatePrimitives` map alone (plus, since #2069,
|
|
128
|
+
* whatever `LoweringPlugin`s are registered — see `TemplatePrimitiveRegistry`
|
|
129
|
+
* above).
|
|
114
130
|
*/
|
|
115
131
|
export type TemplateCallAcceptor = (calleeName: string) => boolean
|
|
116
132
|
|
|
@@ -177,9 +177,22 @@ export interface ParsedExprEmitter {
|
|
|
177
177
|
// a structured `FlatDepth` (the validated literal / `'infinity'`) rather
|
|
178
178
|
// than a `ParsedExpr[]` args list. Non-callback, so it is NOT routed
|
|
179
179
|
// through `callbackMethod`.
|
|
180
|
+
//
|
|
181
|
+
// `depth` is `FlatDepth` for the literal path (unchanged: adapters keep
|
|
182
|
+
// their existing `bf_flat`-family emit exactly as before) or
|
|
183
|
+
// `{ expr: ParsedExpr }` for a DYNAMIC depth (#2094) — an adapter renders
|
|
184
|
+
// `expr` with the `emit` callback and passes the result to a SEPARATE
|
|
185
|
+
// runtime helper that coerces it at render time (JS `ToIntegerOrInfinity`;
|
|
186
|
+
// see the `depthExpr` doc on `ParsedExpr`'s `array-method`/`flat` variant
|
|
187
|
+
// for why this must NOT be the same helper as the literal path — the
|
|
188
|
+
// literal path's `-1` sentinel means "flatten fully", but a genuinely
|
|
189
|
+
// dynamic `-1` means the opposite per JS). Every adapter emits its
|
|
190
|
+
// `flat_dynamic`-family helper for the dynamic form, and every runtime
|
|
191
|
+
// implements the coercion; parity is pinned by the `flat_dynamic`
|
|
192
|
+
// golden helper vectors.
|
|
180
193
|
flatMethod(
|
|
181
194
|
object: ParsedExpr,
|
|
182
|
-
depth: FlatDepth,
|
|
195
|
+
depth: FlatDepth | { expr: ParsedExpr },
|
|
183
196
|
emit: (e: ParsedExpr) => string,
|
|
184
197
|
): string
|
|
185
198
|
unsupported(raw: string, reason: string): string
|
|
@@ -232,7 +245,11 @@ export function emitParsedExpr(expr: ParsedExpr, emitter: ParsedExprEmitter): st
|
|
|
232
245
|
return emitter.objectLiteral(expr.properties, expr.raw, emit)
|
|
233
246
|
case 'array-method':
|
|
234
247
|
if (expr.method === 'flat') {
|
|
235
|
-
return emitter.flatMethod(
|
|
248
|
+
return emitter.flatMethod(
|
|
249
|
+
expr.object,
|
|
250
|
+
expr.depthExpr ? { expr: expr.depthExpr } : expr.flatDepth,
|
|
251
|
+
emit,
|
|
252
|
+
)
|
|
236
253
|
}
|
|
237
254
|
return emitter.arrayMethod(expr.method, expr.object, expr.args, emit)
|
|
238
255
|
case 'unsupported':
|