@barefootjs/jsx 0.18.4 → 0.18.7
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/dangerous-inner-html.d.ts +77 -0
- package/dist/adapters/dangerous-inner-html.d.ts.map +1 -0
- package/dist/adapters/loop-bound-names.d.ts +22 -0
- package/dist/adapters/loop-bound-names.d.ts.map +1 -0
- package/dist/adapters/parsed-expr-emitter.d.ts +2 -2
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +2 -1
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1108 -307
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.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/event-delegation.d.ts +10 -0
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +9 -0
- package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.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/shared.d.ts +45 -3
- package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +1 -0
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +10 -0
- package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts +38 -0
- package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts.map +1 -0
- 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/csr-substitute.d.ts +24 -0
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +42 -0
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +20 -1
- package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/stringify/static-array-child-init.d.ts +1 -1
- package/dist/ir-to-client-js/types.d.ts +30 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +25 -0
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/signal-init-eval.d.ts +82 -0
- package/dist/signal-init-eval.d.ts.map +1 -0
- package/dist/static-literal.d.ts +45 -0
- package/dist/static-literal.d.ts.map +1 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +19 -13
- package/src/__tests__/create-selector.test.ts +110 -0
- package/src/__tests__/csr-template-loop-shadowing.test.ts +214 -0
- package/src/__tests__/dangerous-inner-html-resolver.test.ts +197 -0
- package/src/__tests__/event-delegation-index-param.test.ts +130 -0
- package/src/__tests__/expression-parser.test.ts +38 -0
- package/src/__tests__/inner-loop-svg-namespace.test.ts +245 -0
- package/src/__tests__/ir-walker.test.ts +1 -0
- package/src/__tests__/loop-bound-names.test.ts +102 -0
- package/src/__tests__/loop-plan-classification.test.ts +2 -0
- package/src/__tests__/map-function-reference.test.ts +303 -0
- package/src/__tests__/materialize-getter-calls.test.ts +1 -0
- package/src/__tests__/nested-loop-index-param.test.ts +285 -0
- package/src/__tests__/signal-init-eval.test.ts +138 -0
- package/src/__tests__/static-array-inner-loop-index-param.test.ts +180 -0
- package/src/__tests__/static-literal.test.ts +126 -0
- package/src/__tests__/string-concat-identifier.test.ts +55 -0
- package/src/adapters/dangerous-inner-html.ts +186 -0
- package/src/adapters/loop-bound-names.ts +80 -0
- package/src/adapters/parsed-expr-emitter.ts +19 -1
- package/src/analyzer.ts +6 -0
- package/src/expression-parser.ts +59 -25
- package/src/index.ts +19 -3
- package/src/ir-to-client-js/collect-elements.ts +18 -3
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +6 -0
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +21 -4
- package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +13 -1
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
- package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +10 -0
- package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +9 -0
- package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
- package/src/ir-to-client-js/control-flow/shared.ts +103 -6
- package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +31 -7
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +11 -2
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -0
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +53 -10
- package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +25 -4
- package/src/ir-to-client-js/control-flow/stringify/skeleton-paths.ts +70 -0
- package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +25 -1
- package/src/ir-to-client-js/csr-substitute.ts +54 -1
- package/src/ir-to-client-js/html-template.ts +292 -13
- package/src/ir-to-client-js/imports.ts +1 -0
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +12 -1
- package/src/ir-to-client-js/plan/static-array-child-init.ts +20 -1
- package/src/ir-to-client-js/reactivity.ts +6 -0
- package/src/ir-to-client-js/stringify/static-array-child-init.ts +11 -6
- package/src/ir-to-client-js/types.ts +31 -0
- package/src/ir-to-client-js/utils.ts +34 -1
- package/src/jsx-to-ir.ts +322 -48
- package/src/signal-init-eval.ts +165 -0
- package/src/static-literal.ts +128 -0
- package/src/types.ts +68 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { evaluateSignalInit, tryEvaluateSignalInit } from '../signal-init-eval'
|
|
3
|
+
|
|
4
|
+
// #2209: replaces 7 near-duplicate regex-based `evaluateSignalInit`
|
|
5
|
+
// evaluators (one per template-string adapter's test-render.ts harness)
|
|
6
|
+
// with a single sandboxed real-JS evaluator. Absorbs and supersedes
|
|
7
|
+
// `packages/adapter-mojolicious/src/__tests__/evaluate-signal-init.test.ts`
|
|
8
|
+
// (#1672's pins) plus the actual #2209 repro shape.
|
|
9
|
+
describe('evaluateSignalInit (#2209)', () => {
|
|
10
|
+
test('the #2209 repro: (props.x ?? []).map(t => ({ ...t, editing: false }))', () => {
|
|
11
|
+
const expr = `(props.initialTodos ?? []).map(t => ({ ...t, editing: false }))`
|
|
12
|
+
expect(evaluateSignalInit(expr, { initialTodos: [{ id: 1, done: false }] })).toEqual([
|
|
13
|
+
{ id: 1, done: false, editing: false },
|
|
14
|
+
])
|
|
15
|
+
expect(evaluateSignalInit(expr, undefined)).toEqual([])
|
|
16
|
+
expect(evaluateSignalInit(expr, {})).toEqual([])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('an object-spread override applies after the spread (matches JS semantics)', () => {
|
|
20
|
+
const expr = `(props.items ?? []).map(t => ({ ...t, done: true }))`
|
|
21
|
+
expect(evaluateSignalInit(expr, { items: [{ id: 1, done: false }] })).toEqual([
|
|
22
|
+
{ id: 1, done: true },
|
|
23
|
+
])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// #1672 pins, absorbed from the deleted mojolicious-local test file.
|
|
27
|
+
test('parses an inline object-array initial value', () => {
|
|
28
|
+
expect(evaluateSignalInit(`[{ id: 'a' }, { id: 'b' }, { id: 'c' }]`)).toEqual([
|
|
29
|
+
{ id: 'a' },
|
|
30
|
+
{ id: 'b' },
|
|
31
|
+
{ id: 'c' },
|
|
32
|
+
])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('parses scalar and mixed arrays, including nested objects', () => {
|
|
36
|
+
expect(evaluateSignalInit(`['x', 'y']`)).toEqual(['x', 'y'])
|
|
37
|
+
expect(evaluateSignalInit(`[1, 2, 3]`)).toEqual([1, 2, 3])
|
|
38
|
+
expect(evaluateSignalInit(`[{ id: 'a', n: 1, ok: true }]`)).toEqual([
|
|
39
|
+
{ id: 'a', n: 1, ok: true },
|
|
40
|
+
])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('still parses scalars, empty array, and props passthrough', () => {
|
|
44
|
+
expect(evaluateSignalInit(`'b'`)).toBe('b')
|
|
45
|
+
expect(evaluateSignalInit(`5`)).toBe(5)
|
|
46
|
+
expect(evaluateSignalInit(`[]`)).toEqual([])
|
|
47
|
+
expect(evaluateSignalInit(`props.value`, { value: 42 })).toBe(42)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('bails to null for arrays with non-literal, unbound elements', () => {
|
|
51
|
+
expect(evaluateSignalInit(`[foo(), bar]`)).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Additional shapes the old regex evaluators supported.
|
|
55
|
+
test('props.x ?? default with x present, absent, or explicitly null', () => {
|
|
56
|
+
expect(evaluateSignalInit(`props.x ?? 7`, { x: 3 })).toBe(3)
|
|
57
|
+
expect(evaluateSignalInit(`props.x ?? 7`, {})).toBe(7)
|
|
58
|
+
expect(evaluateSignalInit(`props.x ?? 7`, undefined)).toBe(7)
|
|
59
|
+
// Fix over the old regex evaluators: JS `??` also falls through on an
|
|
60
|
+
// explicit `null`/`undefined` prop value — the old regex only checked
|
|
61
|
+
// `propName in props` and returned the raw (null) value, which was
|
|
62
|
+
// actually a bug relative to real JS `??` semantics.
|
|
63
|
+
expect(evaluateSignalInit(`props.x ?? 7`, { x: null })).toBe(7)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('negative and decimal number literals', () => {
|
|
67
|
+
expect(evaluateSignalInit(`-7.6`)).toBe(-7.6)
|
|
68
|
+
expect(evaluateSignalInit(`42`)).toBe(42)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('booleans', () => {
|
|
72
|
+
expect(evaluateSignalInit(`true`)).toBe(true)
|
|
73
|
+
expect(evaluateSignalInit(`false`)).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('an explicit null initializer maps to null (the "skip" outcome)', () => {
|
|
77
|
+
expect(evaluateSignalInit(`null`)).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// New coverage for #2209's sandboxing contract.
|
|
81
|
+
test('blocked globals fail deterministically and fall back to null', () => {
|
|
82
|
+
expect(evaluateSignalInit(`Date.now()`)).toBeNull()
|
|
83
|
+
expect(evaluateSignalInit(`Math.random()`)).toBeNull()
|
|
84
|
+
expect(evaluateSignalInit(`typeof window`)).not.toBe('object')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('a TS-only remnant that fails to parse as an expression falls back to null', () => {
|
|
88
|
+
expect(evaluateSignalInit(`foo as Bar`)).toBeNull()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('a non-transportable value (class instance) falls back to null', () => {
|
|
92
|
+
expect(evaluateSignalInit(`new Set([1, 2])`)).toBeNull()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('a bare destructured-prop identifier (not props.x) is unbound and falls back to null', () => {
|
|
96
|
+
expect(evaluateSignalInit(`count`, { count: 5 })).toBeNull()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('tryEvaluateSignalInit distinguishes an explicit undefined from a real ok:false', () => {
|
|
100
|
+
expect(tryEvaluateSignalInit(`undefined`)).toEqual({ ok: true, value: undefined })
|
|
101
|
+
expect(tryEvaluateSignalInit(`foo()`)).toEqual({ ok: false })
|
|
102
|
+
// evaluateSignalInit's wrapper collapses both to null:
|
|
103
|
+
expect(evaluateSignalInit(`undefined`)).toBeNull()
|
|
104
|
+
expect(evaluateSignalInit(`foo()`)).toBeNull()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Copilot review (#2229): `Function` is now shadowed too — closes the
|
|
108
|
+
// most direct bypass of the blocked-globals list.
|
|
109
|
+
test('the Function constructor is shadowed', () => {
|
|
110
|
+
expect(evaluateSignalInit(`Function('return 1')()`)).toBeNull()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// Copilot review (#2229): `isTransportable` must reject a sparse array
|
|
114
|
+
// (a hole is not the same as an element that's explicitly `undefined` —
|
|
115
|
+
// `Array.prototype.every` silently skips holes, which previously let
|
|
116
|
+
// one through undetected).
|
|
117
|
+
test('a sparse array (a hole, not an explicit undefined) falls back to null', () => {
|
|
118
|
+
// biome-ignore lint: intentional sparse-array literal for the hole test
|
|
119
|
+
expect(evaluateSignalInit(`[1, , 3]`)).toBeNull()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Copilot review (#2229): the same object appearing twice in a
|
|
123
|
+
// non-cyclic shape (shared by reference, not by cycle) is transportable
|
|
124
|
+
// — JSON-equivalent behavior would just duplicate it — and must NOT be
|
|
125
|
+
// rejected by an overly-global "seen" set.
|
|
126
|
+
test('a shared (non-cyclic) reference is transportable', () => {
|
|
127
|
+
expect(evaluateSignalInit(`(() => { const shared = { id: 1 }; return [shared, shared] })()`)).toEqual([
|
|
128
|
+
{ id: 1 },
|
|
129
|
+
{ id: 1 },
|
|
130
|
+
])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('a genuine cycle still falls back to null', () => {
|
|
134
|
+
expect(
|
|
135
|
+
evaluateSignalInit(`(() => { const o = { id: 1 }; o.self = o; return o })()`),
|
|
136
|
+
).toBeNull()
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A FULLY-STATIC nested loop's inner `.map((item, i) => ...)` index param
|
|
3
|
+
* referenced in a child-component prop (#2231). Follow-up to #2218, which
|
|
4
|
+
* fixed the reactive (`mapArray`) and branch-arm paths — this covers the
|
|
5
|
+
* static-array child-init family (`plan/build-static-array-child-init.ts` /
|
|
6
|
+
* `stringify/static-array-child-init.ts`), which those fixes didn't reach
|
|
7
|
+
* because it never goes through `loopKeyFn` or the renderItem alias.
|
|
8
|
+
*
|
|
9
|
+
* Root cause: the `inner-loop-nested` shape hardcoded the synthetic
|
|
10
|
+
* `__innerIdx` as the inner `forEach`'s index param, and the
|
|
11
|
+
* `component-rooted-inner-loop` shape (#1725) declared no index params at
|
|
12
|
+
* all — so a prop getter like `get label() { return i }` threw
|
|
13
|
+
* `ReferenceError: i is not defined` on `initChild`'s first prop read at
|
|
14
|
+
* hydration.
|
|
15
|
+
*
|
|
16
|
+
* Fix: emit the user's declared index name directly as the `forEach` index
|
|
17
|
+
* param — the same idiom the outer loop has always used (`elem.index ||
|
|
18
|
+
* '__idx'`). No alias binding is needed because `forEach` supplies the real
|
|
19
|
+
* array index positionally. Loops that declare no index keep byte-identical
|
|
20
|
+
* output (`__innerIdx` fallback for `inner-loop-nested`, bare single-param
|
|
21
|
+
* heads for `component-rooted-inner-loop`).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, test, expect } from 'bun:test'
|
|
25
|
+
import { compileJSX } from '../compiler'
|
|
26
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
27
|
+
|
|
28
|
+
const adapter = new TestAdapter()
|
|
29
|
+
|
|
30
|
+
function clientJsFor(source: string): string {
|
|
31
|
+
const result = compileJSX(source, 'Repro.tsx', { adapter })
|
|
32
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
33
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
34
|
+
expect(clientJs).toBeDefined()
|
|
35
|
+
return clientJs!.content
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('static inner-loop index param in child-component props (#2231)', () => {
|
|
39
|
+
test('inner-loop-nested: the inner forEach binds the user index name', () => {
|
|
40
|
+
const content = clientJsFor(`
|
|
41
|
+
'use client'
|
|
42
|
+
import { Cell } from './cell'
|
|
43
|
+
const OUTER = [
|
|
44
|
+
{ id: 1, subs: [{ id: 11 }, { id: 12 }] },
|
|
45
|
+
{ id: 2, subs: [{ id: 21 }] },
|
|
46
|
+
]
|
|
47
|
+
export function Repro() {
|
|
48
|
+
return (
|
|
49
|
+
<div>
|
|
50
|
+
{OUTER.map((o, oi) => (
|
|
51
|
+
<div key={o.id}>
|
|
52
|
+
{o.subs.map((sub, i) => (
|
|
53
|
+
<Cell key={sub.id} label={i} outerPos={oi} />
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
`)
|
|
61
|
+
|
|
62
|
+
// The inner forEach's index param is the user's name, so the prop
|
|
63
|
+
// getters `return i` / `return oi` resolve.
|
|
64
|
+
expect(content).toContain('o.subs.forEach((sub, i) => {')
|
|
65
|
+
// The inner element offset indexes with the same (renamed) param.
|
|
66
|
+
expect(content).toContain('__ic.children[i]')
|
|
67
|
+
expect(content).toContain('get label() { return i }')
|
|
68
|
+
expect(content).toContain('get outerPos() { return oi }')
|
|
69
|
+
expect(content).not.toContain('__innerIdx')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('inner-loop-nested: no declared index keeps the synthetic __innerIdx (byte stability)', () => {
|
|
73
|
+
const content = clientJsFor(`
|
|
74
|
+
'use client'
|
|
75
|
+
import { Cell } from './cell'
|
|
76
|
+
const OUTER = [
|
|
77
|
+
{ id: 1, subs: [{ id: 11 }, { id: 12 }] },
|
|
78
|
+
]
|
|
79
|
+
export function Repro() {
|
|
80
|
+
return (
|
|
81
|
+
<div>
|
|
82
|
+
{OUTER.map((o) => (
|
|
83
|
+
<div key={o.id}>
|
|
84
|
+
{o.subs.map((sub) => (
|
|
85
|
+
<Cell key={sub.id} label={sub.id} />
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
`)
|
|
93
|
+
|
|
94
|
+
expect(content).toContain('o.subs.forEach((sub, __innerIdx) => {')
|
|
95
|
+
expect(content).toContain('__ic.children[__innerIdx]')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('component-rooted-inner-loop: outer AND inner index params are bound', () => {
|
|
99
|
+
const content = clientJsFor(`
|
|
100
|
+
'use client'
|
|
101
|
+
import { Card, Cell } from './cell'
|
|
102
|
+
const OUTER = [
|
|
103
|
+
{ id: 1, subs: [{ id: 11 }, { id: 12 }] },
|
|
104
|
+
]
|
|
105
|
+
export function Repro2() {
|
|
106
|
+
return (
|
|
107
|
+
<div>
|
|
108
|
+
{OUTER.map((o, oi) => (
|
|
109
|
+
<Card key={o.id}>
|
|
110
|
+
{o.subs.map((sub, i) => (
|
|
111
|
+
<Cell key={sub.id} label={i} outerPos={oi} />
|
|
112
|
+
))}
|
|
113
|
+
</Card>
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
`)
|
|
119
|
+
|
|
120
|
+
// Document-order zip: both forEach heads carry the declared index names.
|
|
121
|
+
expect(content).toContain('OUTER.forEach((o, oi) => {')
|
|
122
|
+
expect(content).toContain('o.subs.forEach((sub, i) => {')
|
|
123
|
+
expect(content).toContain('get label() { return i }')
|
|
124
|
+
expect(content).toContain('get outerPos() { return oi }')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('component-rooted-inner-loop: no declared index keeps bare single-param heads (byte stability)', () => {
|
|
128
|
+
const content = clientJsFor(`
|
|
129
|
+
'use client'
|
|
130
|
+
import { Card, Cell } from './cell'
|
|
131
|
+
const OUTER = [
|
|
132
|
+
{ id: 1, subs: [{ id: 11 }] },
|
|
133
|
+
]
|
|
134
|
+
export function Repro2() {
|
|
135
|
+
return (
|
|
136
|
+
<div>
|
|
137
|
+
{OUTER.map((o) => (
|
|
138
|
+
<Card key={o.id}>
|
|
139
|
+
{o.subs.map((sub) => (
|
|
140
|
+
<Cell key={sub.id} label={sub.id} />
|
|
141
|
+
))}
|
|
142
|
+
</Card>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
`)
|
|
148
|
+
|
|
149
|
+
expect(content).toContain('OUTER.forEach((o) => {')
|
|
150
|
+
expect(content).toContain('o.subs.forEach((sub) => {')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('index named like a property access is not confused (item.i stays untouched)', () => {
|
|
154
|
+
const content = clientJsFor(`
|
|
155
|
+
'use client'
|
|
156
|
+
import { Cell } from './cell'
|
|
157
|
+
const OUTER = [
|
|
158
|
+
{ id: 1, subs: [{ id: 11, i: 'a' }] },
|
|
159
|
+
]
|
|
160
|
+
export function Repro() {
|
|
161
|
+
return (
|
|
162
|
+
<div>
|
|
163
|
+
{OUTER.map((o) => (
|
|
164
|
+
<div key={o.id}>
|
|
165
|
+
{o.subs.map((sub) => (
|
|
166
|
+
<Cell key={sub.id} label={sub.i} />
|
|
167
|
+
))}
|
|
168
|
+
</div>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
`)
|
|
174
|
+
|
|
175
|
+
// No index declared → synthetic param; the `sub.i` property access is
|
|
176
|
+
// untouched (this fix renames the forEach param, never rewrites bodies).
|
|
177
|
+
expect(content).toContain('o.subs.forEach((sub, __innerIdx) => {')
|
|
178
|
+
expect(content).toContain('get label() { return sub.i }')
|
|
179
|
+
})
|
|
180
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { parseExpression } from '../expression-parser'
|
|
3
|
+
import { evaluateStaticLiteral, isFullyStaticLiteral, resolveStaticLoopSource } from '../static-literal'
|
|
4
|
+
import type { ConstantInfo } from '../types'
|
|
5
|
+
|
|
6
|
+
// #2208: a function-scope local const whose initializer is fully known at
|
|
7
|
+
// compile time (no prop/signal/function-call dependency) should bind as a
|
|
8
|
+
// loop source the same way a module-scope const already does.
|
|
9
|
+
describe('evaluateStaticLiteral (#2208)', () => {
|
|
10
|
+
test('scalars', () => {
|
|
11
|
+
expect(evaluateStaticLiteral(parseExpression("'Alpha'"))).toEqual({ value: 'Alpha' })
|
|
12
|
+
expect(evaluateStaticLiteral(parseExpression('42'))).toEqual({ value: 42 })
|
|
13
|
+
expect(evaluateStaticLiteral(parseExpression('true'))).toEqual({ value: true })
|
|
14
|
+
expect(evaluateStaticLiteral(parseExpression('null'))).toEqual({ value: null })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('a no-substitution template literal', () => {
|
|
18
|
+
expect(evaluateStaticLiteral(parseExpression('`hello`'))).toEqual({ value: 'hello' })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('a template literal whose substitutions are all static', () => {
|
|
22
|
+
expect(evaluateStaticLiteral(parseExpression("`a${1}b${'c'}`"))).toEqual({ value: 'a1bc' })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('a template literal with a non-static substitution is not static', () => {
|
|
26
|
+
expect(evaluateStaticLiteral(parseExpression('`a${x}`'))).toBeNull()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('nested array and object literals', () => {
|
|
30
|
+
expect(evaluateStaticLiteral(parseExpression("[{ label: 'Alpha' }, { label: 'Beta' }]"))).toEqual({
|
|
31
|
+
value: [{ label: 'Alpha' }, { label: 'Beta' }],
|
|
32
|
+
})
|
|
33
|
+
expect(evaluateStaticLiteral(parseExpression("({ a: [1, 2, { b: 'c' }] })"))).toEqual({
|
|
34
|
+
value: { a: [1, 2, { b: 'c' }] },
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('unary minus/plus/not over a static operand', () => {
|
|
39
|
+
expect(evaluateStaticLiteral(parseExpression('-5'))).toEqual({ value: -5 })
|
|
40
|
+
expect(evaluateStaticLiteral(parseExpression('+5'))).toEqual({ value: 5 })
|
|
41
|
+
expect(evaluateStaticLiteral(parseExpression('!true'))).toEqual({ value: false })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('an empty array literal is static', () => {
|
|
45
|
+
expect(evaluateStaticLiteral(parseExpression('[]'))).toEqual({ value: [] })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('object shorthand property is not static without bindings', () => {
|
|
49
|
+
// `{ label }` parses `label`'s value as an `identifier` — only
|
|
50
|
+
// resolvable when a caller supplies bindings for it.
|
|
51
|
+
expect(evaluateStaticLiteral(parseExpression('({ label })'))).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('a call expression is not static', () => {
|
|
55
|
+
expect(evaluateStaticLiteral(parseExpression("Object.entries({ a: 1 })"))).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('a props member access is not static', () => {
|
|
59
|
+
expect(evaluateStaticLiteral(parseExpression('props.tags'))).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('a bare identifier resolves only through bindings', () => {
|
|
63
|
+
const expr = parseExpression('item')
|
|
64
|
+
expect(evaluateStaticLiteral(expr)).toBeNull()
|
|
65
|
+
expect(evaluateStaticLiteral(expr, new Map([['item', 'Alpha']]))).toEqual({ value: 'Alpha' })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('a member access resolves against a bound object', () => {
|
|
69
|
+
const expr = parseExpression('item.label')
|
|
70
|
+
expect(evaluateStaticLiteral(expr, new Map([['item', { label: 'Alpha' }]]))).toEqual({ value: 'Alpha' })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('isFullyStaticLiteral mirrors evaluateStaticLiteral', () => {
|
|
74
|
+
expect(isFullyStaticLiteral(parseExpression("[{ label: 'Alpha' }]"))).toBe(true)
|
|
75
|
+
expect(isFullyStaticLiteral(parseExpression('props.tags'))).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('resolveStaticLoopSource (#2208)', () => {
|
|
80
|
+
function constant(overrides: Partial<ConstantInfo>): ConstantInfo {
|
|
81
|
+
return {
|
|
82
|
+
name: 'items',
|
|
83
|
+
declarationKind: 'const',
|
|
84
|
+
type: null,
|
|
85
|
+
loc: { line: 0, column: 0 },
|
|
86
|
+
...overrides,
|
|
87
|
+
} as ConstantInfo
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test('an inline array-literal loop source resolves directly (no named local needed)', () => {
|
|
91
|
+
const result = resolveStaticLoopSource(parseExpression("[{ label: 'Alpha' }, { label: 'Beta' }]"), [])
|
|
92
|
+
expect(result).toEqual([{ label: 'Alpha' }, { label: 'Beta' }])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('a bare identifier resolves against a FUNCTION-scope local const with a static initializer', () => {
|
|
96
|
+
const locals = [constant({ name: 'items', parsed: parseExpression("[{ label: 'Alpha' }]") })]
|
|
97
|
+
expect(resolveStaticLoopSource(parseExpression('items'), locals)).toEqual([{ label: 'Alpha' }])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('a MODULE-scope const is excluded (handled by the existing seeding path instead)', () => {
|
|
101
|
+
const locals = [constant({ name: 'items', parsed: parseExpression("[{ label: 'Alpha' }]"), isModule: true })]
|
|
102
|
+
expect(resolveStaticLoopSource(parseExpression('items'), locals)).toBeNull()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('a runtime-computed local const (#2069) still refuses', () => {
|
|
106
|
+
const locals = [constant({ name: 'entries', parsed: parseExpression('Object.entries(props.tags)') })]
|
|
107
|
+
expect(resolveStaticLoopSource(parseExpression('entries'), locals)).toBeNull()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('an identifier not naming any local const refuses', () => {
|
|
111
|
+
expect(resolveStaticLoopSource(parseExpression('items'), [])).toBeNull()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('a shadowed name is excluded via isNameShadowed', () => {
|
|
115
|
+
const locals = [constant({ name: 'items', parsed: parseExpression("[{ label: 'Alpha' }]") })]
|
|
116
|
+
const result = resolveStaticLoopSource(parseExpression('items'), locals, {
|
|
117
|
+
isNameShadowed: name => name === 'items',
|
|
118
|
+
})
|
|
119
|
+
expect(result).toBeNull()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('a static value that is not an array refuses', () => {
|
|
123
|
+
const locals = [constant({ name: 'obj', parsed: parseExpression("{ a: 1 }") })]
|
|
124
|
+
expect(resolveStaticLoopSource(parseExpression('obj'), locals)).toBeNull()
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { isStringTypedOperand, isStringConcatBinary } from '../adapters/parsed-expr-emitter'
|
|
3
|
+
import { parseExpression } from '../expression-parser'
|
|
4
|
+
|
|
5
|
+
// #2212: `isStringTypedOperand` gained a bare-`identifier` arm — previously
|
|
6
|
+
// `a + b` where BOTH operands are plain identifiers (destructured props,
|
|
7
|
+
// same-file consts) fell through every existing arm (literal, template
|
|
8
|
+
// literal, zero-arg getter, `props.x` member) and was treated as numeric
|
|
9
|
+
// `+`, even when both operands are string-typed. Twig/Blade/Mojolicious/
|
|
10
|
+
// Xslate route a detected string-concat through their own concat operator
|
|
11
|
+
// instead of native `+` (numeric-only on all four); this pins the shared
|
|
12
|
+
// detection function itself, independent of any one adapter's emitter.
|
|
13
|
+
describe('isStringTypedOperand identifier arm (#2212)', () => {
|
|
14
|
+
const isStr = (n: string) => n === 'first' || n === 'last'
|
|
15
|
+
|
|
16
|
+
test('a bare identifier known to be string-typed is detected', () => {
|
|
17
|
+
const expr = parseExpression('first')
|
|
18
|
+
expect(isStringTypedOperand(expr, isStr)).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('a bare identifier NOT in the known-string set is not detected', () => {
|
|
22
|
+
const expr = parseExpression('count')
|
|
23
|
+
expect(isStringTypedOperand(expr, isStr)).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('isStringConcatBinary(+) is true when only ONE identifier operand is string-typed', () => {
|
|
27
|
+
const parsed = parseExpression('first + count')
|
|
28
|
+
if (parsed.kind !== 'binary') throw new Error('expected binary')
|
|
29
|
+
expect(isStringConcatBinary(parsed.op, parsed.left, parsed.right, isStr)).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('isStringConcatBinary(+) is false when NEITHER identifier operand is known string-typed', () => {
|
|
33
|
+
const parsed = parseExpression('count + total')
|
|
34
|
+
if (parsed.kind !== 'binary') throw new Error('expected binary')
|
|
35
|
+
expect(isStringConcatBinary(parsed.op, parsed.left, parsed.right, isStr)).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('two bare-identifier string operands: first + last', () => {
|
|
39
|
+
const parsed = parseExpression('first + last')
|
|
40
|
+
if (parsed.kind !== 'binary') throw new Error('expected binary')
|
|
41
|
+
expect(isStringConcatBinary(parsed.op, parsed.left, parsed.right, isStr)).toBe(true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('a nested + chain of bare identifiers still resolves via the existing binary-recursion arm', () => {
|
|
45
|
+
const parsed = parseExpression('first + count + last')
|
|
46
|
+
if (parsed.kind !== 'binary') throw new Error('expected binary')
|
|
47
|
+
expect(isStringConcatBinary(parsed.op, parsed.left, parsed.right, isStr)).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('a non-+ binary op is never treated as string concat regardless of operand names', () => {
|
|
51
|
+
const parsed = parseExpression('first - last')
|
|
52
|
+
if (parsed.kind !== 'binary') throw new Error('expected binary')
|
|
53
|
+
expect(isStringConcatBinary(parsed.op, parsed.left, parsed.right, isStr)).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
})
|