@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.
Files changed (88) hide show
  1. package/dist/adapters/env-signal.d.ts +42 -7
  2. package/dist/adapters/env-signal.d.ts.map +1 -1
  3. package/dist/adapters/interface.d.ts +20 -4
  4. package/dist/adapters/interface.d.ts.map +1 -1
  5. package/dist/adapters/parsed-expr-emitter.d.ts +3 -1
  6. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  7. package/dist/analyzer-context.d.ts.map +1 -1
  8. package/dist/analyzer.d.ts.map +1 -1
  9. package/dist/augment-inherited-props.d.ts +19 -0
  10. package/dist/augment-inherited-props.d.ts.map +1 -1
  11. package/dist/compiler.d.ts.map +1 -1
  12. package/dist/expression-parser.d.ts +48 -2
  13. package/dist/expression-parser.d.ts.map +1 -1
  14. package/dist/index.d.ts +24 -5
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1728 -969
  17. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  21. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  24. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/emit-registration.d.ts +4 -2
  26. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/html-template.d.ts +41 -0
  28. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/types.d.ts +22 -1
  30. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  31. package/dist/jsx-to-ir.d.ts.map +1 -1
  32. package/dist/loop-destructure.d.ts +55 -18
  33. package/dist/loop-destructure.d.ts.map +1 -1
  34. package/dist/lowering-registry.d.ts +13 -0
  35. package/dist/lowering-registry.d.ts.map +1 -1
  36. package/dist/relocate.d.ts +28 -0
  37. package/dist/relocate.d.ts.map +1 -1
  38. package/dist/ssr-defaults.d.ts.map +1 -1
  39. package/dist/ssr-seed-plan.d.ts +84 -0
  40. package/dist/ssr-seed-plan.d.ts.map +1 -0
  41. package/dist/types.d.ts +79 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/package.json +2 -2
  44. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +284 -12
  45. package/src/__tests__/augment-inherited-props.test.ts +96 -0
  46. package/src/__tests__/compiler-runtime-contract.test.ts +11 -1
  47. package/src/__tests__/compiler-stress-1244.test.ts +13 -4
  48. package/src/__tests__/csr-substitution-safety-divergence.test.ts +137 -0
  49. package/src/__tests__/destructured-map-params.test.ts +11 -1
  50. package/src/__tests__/expression-parser.test.ts +74 -3
  51. package/src/__tests__/free-identifiers.test.ts +55 -0
  52. package/src/__tests__/ir-sort-comparator.test.ts +261 -0
  53. package/src/__tests__/loop-destructure.test.ts +313 -0
  54. package/src/__tests__/loop-hoisted-template.test.ts +235 -0
  55. package/src/__tests__/materialize-getter-calls.test.ts +58 -0
  56. package/src/__tests__/props-destructuring.test.ts +110 -0
  57. package/src/__tests__/serialize-parsed-expr.test.ts +88 -2
  58. package/src/__tests__/ssr-defaults.test.ts +20 -0
  59. package/src/__tests__/ssr-seed-plan.test.ts +212 -0
  60. package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +231 -1
  61. package/src/__tests__/tagged-template-interleave.test.ts +268 -0
  62. package/src/__tests__/unsupported-expression.test.ts +194 -7
  63. package/src/adapters/env-signal.ts +57 -9
  64. package/src/adapters/interface.ts +20 -4
  65. package/src/adapters/parsed-expr-emitter.ts +19 -2
  66. package/src/analyzer-context.ts +20 -0
  67. package/src/analyzer.ts +74 -1
  68. package/src/augment-inherited-props.ts +139 -9
  69. package/src/compiler.ts +10 -1
  70. package/src/expression-parser.ts +421 -50
  71. package/src/index.ts +30 -3
  72. package/src/ir-to-client-js/collect-elements.ts +15 -1
  73. package/src/ir-to-client-js/compute-inlinability.ts +6 -1
  74. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  75. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  76. package/src/ir-to-client-js/control-flow/stringify/loop.ts +30 -8
  77. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +30 -0
  78. package/src/ir-to-client-js/emit-registration.ts +4 -2
  79. package/src/ir-to-client-js/html-template.ts +198 -1
  80. package/src/ir-to-client-js/index.ts +1 -0
  81. package/src/ir-to-client-js/types.ts +22 -0
  82. package/src/jsx-to-ir.ts +409 -24
  83. package/src/loop-destructure.ts +89 -36
  84. package/src/lowering-registry.ts +16 -0
  85. package/src/relocate.ts +201 -14
  86. package/src/ssr-defaults.ts +34 -32
  87. package/src/ssr-seed-plan.ts +146 -0
  88. package/src/types.ts +76 -0
@@ -76,6 +76,102 @@ describe('augmentInheritedPropAccesses', () => {
76
76
  }
77
77
  })
78
78
 
79
+ test('sees text-expression, condition, loop-array, and component-prop reads (#2126)', () => {
80
+ // These carriers all lower `props.X` to a bare template scalar, so a
81
+ // missed read means the emitted template references a var the props
82
+ // type / ssrDefaults never declared — a strict-mode 500 on the
83
+ // Perl-family adapters, a missing struct field on Go.
84
+ const ir = makeIR({
85
+ propsObjectName: 'props',
86
+ propsParams: [],
87
+ root: {
88
+ type: 'element',
89
+ tag: 'div',
90
+ attrs: [],
91
+ children: [
92
+ // dynamic text child → string (default classification)
93
+ { type: 'expression', expr: 'props.label' },
94
+ // conditional condition → nillable (truth-tested only)
95
+ {
96
+ type: 'conditional',
97
+ condition: 'props.show',
98
+ whenTrue: { type: 'element', tag: 'span', attrs: [], children: [] },
99
+ whenFalse: { type: 'text', value: '' },
100
+ },
101
+ // loop array → nillable (iterated only)
102
+ {
103
+ type: 'loop',
104
+ array: '(props.items ?? [])',
105
+ param: 'it',
106
+ children: [{ type: 'expression', expr: 'it' }],
107
+ },
108
+ // component prop values → scanned, but NOT as HTML attributes:
109
+ // no boolean-attr-by-name inference; nillable unless a
110
+ // `?? <literal>` pins a concrete type
111
+ {
112
+ type: 'component',
113
+ name: 'Child',
114
+ props: [
115
+ { name: 'value', value: { kind: 'expression', expr: 'props.childValue ?? 1' } },
116
+ { name: 'ref', value: { kind: 'expression', expr: 'props.childRef' } },
117
+ // `disabled` is a boolean HTML attr name, but on a component
118
+ // prop that inference must not fire — stays nillable.
119
+ { name: 'disabled', value: { kind: 'expression', expr: 'props.childFlag' } },
120
+ ],
121
+ children: [],
122
+ },
123
+ ],
124
+ },
125
+ })
126
+
127
+ augmentInheritedPropAccesses(ir)
128
+
129
+ const byName = new Map(ir.metadata.propsParams.map(p => [p.name, p]))
130
+ expect(byName.get('label')?.type).toMatchObject({ kind: 'primitive', primitive: 'string' })
131
+ expect(byName.get('show')?.type).toMatchObject({ kind: 'unknown' })
132
+ expect(byName.get('items')?.type).toMatchObject({ kind: 'unknown' })
133
+ // Component-prop reads: `?? 1` pins number; bare reads stay nillable,
134
+ // including on boolean-HTML-attr names (`disabled`).
135
+ expect(byName.get('childValue')?.type).toMatchObject({ kind: 'primitive', primitive: 'number' })
136
+ expect(byName.get('childRef')?.type).toMatchObject({ kind: 'unknown' })
137
+ expect(byName.get('childFlag')?.type).toMatchObject({ kind: 'unknown' })
138
+ for (const name of ['label', 'show', 'items', 'childValue', 'childRef', 'childFlag']) {
139
+ expect(byName.get(name)?.optional).toBe(true)
140
+ }
141
+ })
142
+
143
+ test('pins a synthetic param to the `?? <literal>` fallback type', () => {
144
+ // The Go adapter seeds a prop-derived signal from the synthetic field
145
+ // (`Count: in.Initial` where `Count int` came from evaluating
146
+ // `props.initial ?? 0`), so the field type must match the literal —
147
+ // the old string default produced `cannot use in.Initial (variable
148
+ // of type string) as int value`.
149
+ const ir = makeIR({
150
+ propsObjectName: 'props',
151
+ propsParams: [],
152
+ memos: [{ computation: 'props.step ?? 1' }],
153
+ root: {
154
+ type: 'element',
155
+ tag: 'div',
156
+ attrs: [],
157
+ children: [{ type: 'expression', expr: 'props.title ?? "untitled"' }],
158
+ },
159
+ })
160
+ ir.metadata.signals = [
161
+ { getter: 'count', initialValue: 'props.initial ?? 0' },
162
+ { getter: 'on', initialValue: 'props.defaultOn ?? false' },
163
+ ] as never
164
+
165
+ augmentInheritedPropAccesses(ir)
166
+
167
+ const byName = new Map(ir.metadata.propsParams.map(p => [p.name, p]))
168
+ expect(byName.get('initial')?.type).toMatchObject({ kind: 'primitive', primitive: 'number' })
169
+ expect(byName.get('defaultOn')?.type).toMatchObject({ kind: 'primitive', primitive: 'boolean' })
170
+ expect(byName.get('step')?.type).toMatchObject({ kind: 'primitive', primitive: 'number' })
171
+ // Text-expression coalesce reads keep their string classification.
172
+ expect(byName.get('title')?.type).toMatchObject({ kind: 'primitive', primitive: 'string' })
173
+ })
174
+
79
175
  test('is idempotent — re-running adds nothing', () => {
80
176
  const ir = makeIR({
81
177
  propsObjectName: 'props',
@@ -261,11 +261,21 @@ describe('Compiler-Runtime Contract', () => {
261
261
  })
262
262
 
263
263
  test('issue #1321: rest spread on typed module-level function param survives emit', () => {
264
+ // The tag body joins with a `-` separator between `p` and the
265
+ // interleaved arg — deliberately OFF the #2092 interleave-tag
266
+ // catalogue (which requires the exact `(acc + p) + X` two-term
267
+ // chain), so `cn` stays an opaque tagged-template callee that must
268
+ // still be shipped to the client. This keeps the #1321 regression
269
+ // (rest token survives type-strip + module-level emission) pinned
270
+ // against a real "helper still needed" case; #2092's own desugar
271
+ // coverage lives in `tagged-template-interleave.test.ts` and the
272
+ // `compiler-stress-1244.test.ts` "tagged template literal for
273
+ // className" describe block.
264
274
  const js = compileClient(`
265
275
  "use client"
266
276
  import { createSignal } from '@barefootjs/client'
267
277
  function cn(parts: TemplateStringsArray, ...args: unknown[]): string {
268
- return parts.reduce<string>((acc, p, i) => acc + p + (args[i] ?? ''), '')
278
+ return parts.reduce<string>((acc, p, i) => acc + p + '-' + (args[i] ?? ''), '')
269
279
  }
270
280
  export function TagDemo() {
271
281
  const [tone, setTone] = createSignal('primary')
@@ -1554,9 +1554,11 @@ describe('JSX spread of a reactive object', () => {
1554
1554
 
1555
1555
  describe('tagged template literal for className', () => {
1556
1556
  // `cn\`base ${signal()}\`` is the most common alternative to `cva` in
1557
- // small codebases. The compiler currently treats a TaggedTemplate as
1558
- // an opaque call; reactive deps inside the placeholders may not be
1559
- // analysed.
1557
+ // small codebases. Resolved by #2092: `cn`'s body structurally matches
1558
+ // the interleave-tag catalogue (`parts.reduce((acc, p, i) => acc + p +
1559
+ // (args[i] ?? ''), '')`), so the tagged template desugars to the
1560
+ // equivalent untagged template literal — the signal dependency inside
1561
+ // the placeholder IS analysed, producing a reactive className binding.
1560
1562
  test('cn`base ${signal()}` produces the className binding', () => {
1561
1563
  const src = `
1562
1564
  'use client'
@@ -1569,7 +1571,14 @@ describe('tagged template literal for className', () => {
1569
1571
  return <div onClick={() => setTone('secondary')} className={cn\`base \${tone()}\`} />
1570
1572
  }
1571
1573
  `
1572
- expectNoFatalErrors(compile(src))
1574
+ const c = compile(src)
1575
+ expectNoFatalErrors(c)
1576
+ // The tag call is gone from the emitted client JS — replaced by the
1577
+ // desugared untagged template literal — and the className update
1578
+ // effect reads the signal getter reactively.
1579
+ expect(c.clientJs).not.toMatch(/\bcn`/)
1580
+ expect(c.clientJs).toContain('`base ${(tone()) ?? \'\'}`')
1581
+ expect(c.clientJs).toMatch(/setAttribute\('class',\s*String\(__v\)\)/)
1573
1582
  })
1574
1583
  })
1575
1584
 
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Regression tests for #2106: the templatePrimitives V2 widening (#2069)
3
+ * let a call whose RAW callee is an identifier path
4
+ * (`someModuleArray.includes(prop)`) pass `computeInlinability`'s Stage-2
5
+ * classification (`classifyConstantInitial`, compute-inlinability.ts) —
6
+ * e.g. via an adapter with a broad `acceptsTemplateCall` (Hono's SSR
7
+ * runtime is a full JS engine, so it accepts any identifier-path callee).
8
+ * That check runs on the RAW initializer text so it can still see
9
+ * bridged prop args for the #1138 rejection.
10
+ *
11
+ * `populateCsrInlinable` (compute-inlinability.ts) re-runs the same
12
+ * `isInlinableInTemplate` check on the CSR-*substituted* form, where the
13
+ * module-scope array has already been literal-inlined
14
+ * (`['plus','minus'].includes(name)`). The callee is no longer an
15
+ * identifier path at all, so the adapter can't vouch for it, and the
16
+ * bridged-arg rejection correctly fires — recorded as
17
+ * `ctx.csrInlinable.get(name) === null`.
18
+ *
19
+ * Pre-fix, `unsafeLocalNames` (Stage-2-derived) didn't know about this
20
+ * second, stricter refusal, so the CSR template emitter
21
+ * (`generateCsrTemplate` / `transformExpr` in html-template.ts) found
22
+ * neither a substitution (excluded by `csrInlinableConstantsFromCtx`,
23
+ * which skips null entries) NOR an unsafe flag — the bare,
24
+ * module-scope-invisible identifier leaked straight into the emitted
25
+ * template text, throwing `ReferenceError` when the template function
26
+ * actually ran. This is the same bug class (and same shape) as the real
27
+ * repro in `ui/components/ui/icon`'s `linecap` constant.
28
+ *
29
+ * Fix: `generateCsrTemplate` (html-template.ts) folds `ctx.csrInlinable`'s
30
+ * null verdicts into the unsafe-name set it uses internally
31
+ * (`mergeCsrNullUnsafe`), so a name `populateCsrInlinable` refused is
32
+ * always treated as unsafe at the CSR template layer, regardless of what
33
+ * the looser Stage-2 check concluded elsewhere.
34
+ */
35
+ import { describe, test, expect } from 'bun:test'
36
+ import { compileJSX } from '../compiler'
37
+ import { HonoAdapter } from '../../../adapter-hono/src/adapter/hono-adapter'
38
+ import { extractTemplateBody } from './staged-ir/helpers'
39
+ import { RUNTIME_IMPORT_CANDIDATES } from '../ir-to-client-js/imports'
40
+
41
+ const onlyErrors = (errors: { severity?: string }[]) =>
42
+ errors.filter((e) => e.severity === 'error')
43
+
44
+ /**
45
+ * Compile `source` with `HonoAdapter` (broad `acceptsTemplateCall`, the
46
+ * ingredient the real bug needs) and extract the CSR template's
47
+ * back-tick body.
48
+ */
49
+ function compileTemplateBody(source: string, fileName: string): { templateBody: string; clientJs: string; errors: string[] } {
50
+ const result = compileJSX(source, fileName, { adapter: new HonoAdapter() })
51
+ expect(onlyErrors(result.errors)).toHaveLength(0)
52
+ const clientJs = result.files.find((f) => f.type === 'clientJs')!.content
53
+ return { templateBody: extractTemplateBody(clientJs), clientJs, errors: result.errors.map((e) => e.code) }
54
+ }
55
+
56
+ /**
57
+ * Evaluate an extracted CSR template body as a real template-literal
58
+ * function, with every runtime helper the compiler might reference
59
+ * stubbed out. Mirrors what `hydrate()` does at runtime, minus the DOM.
60
+ */
61
+ function evalTemplate(templateBody: string, props: Record<string, unknown>): string {
62
+ const helperNames = [...RUNTIME_IMPORT_CANDIDATES]
63
+ const stubs = helperNames.map((name) => {
64
+ if (name === 'escapeAttr' || name === 'escapeText' || name === 'styleToCss') {
65
+ return (v: unknown) => String(v)
66
+ }
67
+ return (..._args: unknown[]) => ''
68
+ })
69
+ // eslint-disable-next-line no-new-func
70
+ const fn = new Function('_p', ...helperNames, `return \`${templateBody}\``)
71
+ return fn(props, ...stubs)
72
+ }
73
+
74
+ describe('CSR template: Stage-2 / post-substitution inline-safety divergence (#2106)', () => {
75
+ test('a const whose RAW form is adapter-accepted but whose CSR-substituted form is refused falls back to undefined, not a bare identifier', () => {
76
+ const source = `
77
+ 'use client'
78
+ import { createSignal } from '@barefootjs/client'
79
+
80
+ const buttLinecapIcons = ['plus', 'minus']
81
+
82
+ export function CsrNullDivergence({ name }: { name: string }) {
83
+ const [count] = createSignal(0)
84
+ const linecap = (buttLinecapIcons).includes(name) ? 'butt' : 'round'
85
+ return <div data-linecap={linecap}>{count()}</div>
86
+ }
87
+ `
88
+ const { templateBody } = compileTemplateBody(source, 'CsrNullDivergence.tsx')
89
+
90
+ // (a) No bare `linecap` identifier anywhere in the template body.
91
+ // Excludes the HTML attribute name text `data-linecap="` (preceded by
92
+ // `-`, not a real identifier reference) from the check.
93
+ expect(templateBody).not.toMatch(/(?<![-.\w$])linecap(?![\w$])/)
94
+
95
+ // (c) The fallback is the spec'd null-guarded `undefined` shape
96
+ // (`(undefined) != null ? '...' : ''`), the same fallback every other
97
+ // unsafe-template-scope reference degrades to (html-template.ts's
98
+ // `templateAttrExpr` generic path).
99
+ expect(templateBody).toMatch(/\(undefined\)\s*!=\s*null/)
100
+
101
+ // (b) Evaluating the template with stub runtime helpers must not
102
+ // throw `ReferenceError: linecap is not defined` — the pre-fix
103
+ // manifestation of this bug.
104
+ let rendered = ''
105
+ expect(() => { rendered = evalTemplate(templateBody, { name: 'plus', size: 'md', className: '' }) }).not.toThrow()
106
+
107
+ // The dropped-attribute fallback: SSR/CSR-initial HTML omits the
108
+ // attribute entirely rather than emitting a bogus value.
109
+ expect(rendered).not.toContain('linecap=')
110
+ })
111
+
112
+ test('control: a plain object-literal lookup (no call involved) still inlines through the same CSR path', () => {
113
+ // Guards against over-suppression: the fix must only affect names
114
+ // `ctx.csrInlinable` actually refused (`=== null`), not every const
115
+ // whose value happens to reference a module-scope constant.
116
+ const source = `
117
+ 'use client'
118
+ import { createSignal } from '@barefootjs/client'
119
+
120
+ const sizeMap: Record<string, string> = { sm: 'h-4', md: 'h-6' }
121
+
122
+ export function CsrStillInlines({ size }: { size: string }) {
123
+ const [count] = createSignal(0)
124
+ const cls = sizeMap[size]
125
+ return <div data-cls={cls}>{count()}</div>
126
+ }
127
+ `
128
+ const { templateBody } = compileTemplateBody(source, 'CsrStillInlines.tsx')
129
+
130
+ // No bare `cls` identifier left in the template — it was inlined.
131
+ expect(templateBody).not.toMatch(/(?<![-.\w$])cls(?![\w$])/)
132
+ // The module-scope object literal was substituted in place of `sizeMap`,
133
+ // and the prop was correctly bridged to `_p.size` — not marked unsafe.
134
+ expect(templateBody).toContain(`{ sm: 'h-4', md: 'h-6' })[_p.size]`)
135
+ expect(templateBody).not.toContain('undefined')
136
+ })
137
+ })
@@ -51,7 +51,17 @@ describe('destructured .map() param rewriting (#951)', () => {
51
51
  // Binding references rewritten to accessor paths
52
52
  expect(js).toContain('__bfItem().label')
53
53
  expect(js).toContain('__bfItem().done')
54
- expect(js).toContain('__bfItem().id')
54
+ // `id` is referenced ONLY via `key={id}` in this fixture. The loop's
55
+ // `keyFn` argument to `mapArray` evaluates against the raw destructured
56
+ // item (before per-item signal wrapping) — `({ id, label, done }) =>
57
+ // String(id)` — so `id` never needs the `__bfItem()` accessor rewrite at
58
+ // all; `mapArray` stamps the real `data-key` from that keyFn onto every
59
+ // freshly created element itself (see `map-array.ts`). The hoisted
60
+ // shared-template fast path (perf) therefore omits the key interpolation
61
+ // from the once-per-loop skeleton (`data-key=""` placeholder) rather than
62
+ // baking a `__bfItem().id` reference into it — no `__bfItem().id`
63
+ // occurrence anywhere is the CORRECT, not incomplete, output here.
64
+ expect(js).toContain('({ id, label, done }) => String(id)')
55
65
  })
56
66
 
57
67
  test('tuple destructure with hole: references become __bfItem()[index]', () => {
@@ -1325,6 +1325,45 @@ describe('expression-parser', () => {
1325
1325
  expect(result.level).toBe('L4')
1326
1326
  })
1327
1327
 
1328
+ // #2087: `x ?? {}` — the chart component's `<Ctx.Provider value={{
1329
+ // config: props.config ?? {} }}>` needs an EMPTY object-literal fallback
1330
+ // operand admitted, narrowly, as the right side of `??` only.
1331
+ test('L4: nullish coalescing with an EMPTY object-literal fallback is supported (#2087)', () => {
1332
+ const expr = parseExpression('props.config ?? {}')
1333
+ expect(expr.kind).toBe('logical')
1334
+ const result = isSupported(expr)
1335
+ expect(result.supported).toBe(true)
1336
+ expect(result.level).toBe('L4')
1337
+ })
1338
+
1339
+ test('a NON-EMPTY object-literal fallback of `??` is still refused (#2087 scope)', () => {
1340
+ const expr = parseExpression('props.config ?? ({ a: 1 })')
1341
+ expect(expr.kind).toBe('logical')
1342
+ const result = isSupported(expr)
1343
+ expect(result.supported).toBe(false)
1344
+ })
1345
+
1346
+ test('an empty object-literal fallback of `&&` / `||` is still refused — #2087 scopes the relaxation to `??` only', () => {
1347
+ expect(isSupported(parseExpression('props.config && ({})')).supported).toBe(false)
1348
+ expect(isSupported(parseExpression('props.config || ({})')).supported).toBe(false)
1349
+ })
1350
+
1351
+ test('a standalone (non-fallback) object literal is still refused', () => {
1352
+ const expr = parseExpression('({})')
1353
+ const result = isSupported(expr)
1354
+ expect(result.supported).toBe(false)
1355
+ expect(result.reason).toBe('Unsupported syntax: ObjectLiteralExpression')
1356
+ })
1357
+
1358
+ test('an empty object-literal on the LEFT of `??` does not trigger the fallback relaxation', () => {
1359
+ // The relaxation only inspects `expr.right`; a left-hand object
1360
+ // literal has no fallback role and stays refused via the general
1361
+ // `checkSupport(expr.left)` refusal.
1362
+ const expr = parseExpression('({}) ?? props.config')
1363
+ const result = isSupported(expr)
1364
+ expect(result.supported).toBe(false)
1365
+ })
1366
+
1328
1367
  test('L5: filter() with simple predicate IS supported', () => {
1329
1368
  const expr = parseExpression('items().filter(x => x.done)')
1330
1369
  const result = isSupported(expr)
@@ -1383,9 +1422,19 @@ describe('expression-parser', () => {
1383
1422
  expect(result.level).toBe('L2')
1384
1423
  })
1385
1424
 
1386
- test('L5: map() is NOT supported', () => {
1425
+ test('L5: value-producing map() with an arrow IS supported (evaluator lowering, #2073)', () => {
1387
1426
  const expr = parseExpression('items().map(x => x.name)')
1388
1427
  const result = isSupported(expr)
1428
+ expect(result.supported).toBe(true)
1429
+ expect(result.level).toBe('L5')
1430
+ })
1431
+
1432
+ test('L5: map() with a function-reference callback is NOT supported', () => {
1433
+ // No arrow argument → not a CALLBACK_METHODS shape; falls through to
1434
+ // the UNSUPPORTED_METHODS gate and refuses loudly (like reduce's
1435
+ // no-init fall-through).
1436
+ const expr = parseExpression('items().map(format)')
1437
+ const result = isSupported(expr)
1389
1438
  expect(result.supported).toBe(false)
1390
1439
  expect(result.level).toBe('L5_UNSUPPORTED')
1391
1440
  expect(result.reason).toContain('map')
@@ -1690,14 +1739,30 @@ describe('expression-parser — .flat(depth?) lowering (#1448 Tier C)', () => {
1690
1739
  })
1691
1740
  }
1692
1741
 
1693
- test('non-literal depth refuses (must be resolvable at template time)', () => {
1742
+ test('a non-literal depth that itself resolves becomes a DYNAMIC depth (#2094)', () => {
1743
+ // `n` parses to a supported `identifier` — the depth isn't known until
1744
+ // render time, but it's still an EXPRESSIBLE one, so it's accepted as a
1745
+ // dynamic depth (`depthExpr`) instead of refusing.
1694
1746
  const result = parseExpression('arr.flat(n)')
1747
+ expect(result.kind).toBe('array-method')
1748
+ if (result.kind === 'array-method' && result.method === 'flat') {
1749
+ expect(result.depthExpr).toEqual({ kind: 'identifier', name: 'n' })
1750
+ } else {
1751
+ throw new Error(`expected a flat array-method, got ${result.kind}`)
1752
+ }
1753
+ // An arithmetic / member-access depth resolves too.
1754
+ expect(parseExpression('arr.flat(depth + 1)').kind).toBe('array-method')
1755
+ expect(parseExpression('arr.flat(props.depth)').kind).toBe('array-method')
1756
+ })
1757
+
1758
+ test('a non-literal depth that itself does NOT resolve still refuses', () => {
1759
+ const result = parseExpression('arr.flat(a instanceof B)')
1695
1760
  expect(result.kind).toBe('unsupported')
1696
1761
  if (result.kind === 'unsupported') {
1697
1762
  // Wrong remedy `@client` must not be suggested (doesn't work in
1698
1763
  // attribute / condition position).
1699
1764
  expect(result.reason).not.toContain('@client')
1700
- expect(result.reason).toContain('literal')
1765
+ expect(result.reason).toContain('resolved')
1701
1766
  }
1702
1767
  })
1703
1768
 
@@ -1709,6 +1774,12 @@ describe('expression-parser — .flat(depth?) lowering (#1448 Tier C)', () => {
1709
1774
  expect(exprToString(parseExpression('arr.flat(Infinity)'))).toBe('arr.flat(Infinity)')
1710
1775
  expect(exprToString(parseExpression('arr.flat(0)'))).toBe('arr.flat(0)')
1711
1776
  })
1777
+
1778
+ test('exprToString / stringifyParsedExpr round-trip a dynamic depth (#2094)', () => {
1779
+ expect(exprToString(parseExpression('arr.flat(n)'))).toBe('arr.flat(n)')
1780
+ expect(stringifyParsedExpr(parseExpression('arr.flat(n)'))).toBe('arr.flat(n)')
1781
+ expect(stringifyParsedExpr(parseExpression('arr.flat(depth + 1)'))).toBe('arr.flat(depth + 1)')
1782
+ })
1712
1783
  })
1713
1784
 
1714
1785
  describe('expression-parser — .flatMap(fn) projection (#1448 Tier C)', () => {
@@ -0,0 +1,55 @@
1
+ import { parseExpression, freeIdentifiers } from '../expression-parser'
2
+
3
+ /**
4
+ * `freeIdentifiers` pins the LEXICALLY-SCOPED free-identifier walk used by the
5
+ * Perl-family seed-scope guard (Mojo / Xslate `tryLowerToPerl` / `tryLowerToKolon`,
6
+ *): a name bound by an arrow's own params is free
7
+ * OUTSIDE that arrow's body but bound WITHIN it, and an `unsupported` node
8
+ * makes the whole result unanalyzable (`null`) so callers fail safe.
9
+ */
10
+
11
+ describe('freeIdentifiers', () => {
12
+ test('simple identifiers are free', () => {
13
+ const expr = parseExpression('a + b.c')
14
+ expect(freeIdentifiers(expr)).toEqual(new Set(['a', 'b']))
15
+ })
16
+
17
+ test('an arrow param is bound inside its own body but free outside it', () => {
18
+ // The `filter((p) => p.ok) && p` shape : the outer `p` is a
19
+ // DIFFERENT (unbound) reference from the callback's own `p` param.
20
+ const expr = parseExpression('props.items.filter((p) => p.ok) && p')
21
+ expect(freeIdentifiers(expr)).toEqual(new Set(['props', 'p']))
22
+ })
23
+
24
+ test('an arrow param fully shadowed inside its body reports no free callback ref', () => {
25
+ const expr = parseExpression('items.filter((p) => p.ok)')
26
+ expect(freeIdentifiers(expr)).toEqual(new Set(['items']))
27
+ })
28
+
29
+ test('nested arrows accumulate params from every enclosing scope', () => {
30
+ // `a` and `b` are both bound within the innermost body; only `outer` is free.
31
+ const expr = parseExpression('items.filter((a) => other.filter((b) => a + b + outer))')
32
+ expect(freeIdentifiers(expr)).toEqual(new Set(['items', 'other', 'outer']))
33
+ })
34
+
35
+ test('member property names are not references; object-literal keys are not either', () => {
36
+ const expr = parseExpression('({ total: item.price, tax: rate })')
37
+ expect(freeIdentifiers(expr)).toEqual(new Set(['item', 'rate']))
38
+ })
39
+
40
+ test('a builtin callee (Math.floor) is not reported as free; its args are', () => {
41
+ const expr = parseExpression('Math.floor(n)')
42
+ expect(freeIdentifiers(expr)).toEqual(new Set(['n']))
43
+ })
44
+
45
+ test('a non-builtin call callee IS a free reference', () => {
46
+ const expr = parseExpression('someFn(x)')
47
+ expect(freeIdentifiers(expr)).toEqual(new Set(['someFn', 'x']))
48
+ })
49
+
50
+ test('an unsupported node makes the whole tree unanalyzable', () => {
51
+ // A block-bodied arrow with imperative statements parses as `unsupported`.
52
+ const expr = parseExpression('(() => { for (;;) {} })()')
53
+ expect(freeIdentifiers(expr)).toBeNull()
54
+ })
55
+ })