@barefootjs/jsx 0.31.4 → 0.31.6
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/jsx-adapter.d.ts.map +1 -1
- package/dist/adapters/loop-bound-names.d.ts +18 -0
- package/dist/adapters/loop-bound-names.d.ts.map +1 -1
- package/dist/adapters/test-adapter.d.ts.map +1 -1
- package/dist/augment-inherited-props.d.ts +12 -2
- package/dist/augment-inherited-props.d.ts.map +1 -1
- package/dist/compiler.d.ts.map +1 -1
- package/dist/debug.d.ts.map +1 -1
- package/dist/free-refs.d.ts +11 -2
- package/dist/free-refs.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +773 -649
- package/dist/ir-to-client-js/client-only-elision.d.ts +11 -5
- package/dist/ir-to-client-js/client-only-elision.d.ts.map +1 -1
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts +7 -0
- package/dist/ir-to-client-js/control-flow/plan/reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts +16 -0
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +16 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +15 -0
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/module-exports.d.ts +64 -0
- package/dist/module-exports.d.ts.map +1 -1
- package/dist/scope/binding-scope.d.ts +1 -1
- package/dist/types.d.ts +112 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +117 -17
- package/src/__tests__/binding-scope-ratchet.test.ts +146 -21
- package/src/__tests__/component-type-parameters.test.ts +70 -0
- package/src/__tests__/csr-materialize-loop-preamble-shadow.test.ts +10 -1
- package/src/__tests__/doc-examples.test.ts +1 -0
- package/src/__tests__/free-refs.test.ts +1 -1
- package/src/__tests__/mutable-binding-writers.test.ts +133 -0
- package/src/__tests__/preamble-conditional-reactivity.test.ts +191 -0
- package/src/__tests__/signal-setter-updater-type.test.ts +81 -0
- package/src/adapters/jsx-adapter.ts +29 -3
- package/src/adapters/loop-bound-names.ts +18 -0
- package/src/adapters/test-adapter.ts +4 -1
- package/src/augment-inherited-props.ts +13 -1
- package/src/compiler.ts +18 -0
- package/src/debug.ts +34 -21
- package/src/free-refs.ts +14 -5
- package/src/index.ts +6 -0
- package/src/ir-to-client-js/client-only-elision.ts +11 -5
- package/src/ir-to-client-js/collect-elements.ts +17 -2
- package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +1 -0
- package/src/ir-to-client-js/control-flow/plan/reactive-effects.ts +7 -0
- package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +12 -2
- package/src/ir-to-client-js/html-template.ts +51 -0
- package/src/ir-to-client-js/reactivity.ts +4 -2
- package/src/ir-to-client-js/types.ts +16 -0
- package/src/ir-to-client-js/utils.ts +15 -0
- package/src/jsx-to-ir.ts +117 -1
- package/src/module-exports.ts +137 -0
- package/src/scope/binding-scope.ts +1 -1
- package/src/types.ts +118 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit coverage for `findAssignedNames` / `closeOverWritersOfMutableBindings`
|
|
3
|
+
* (#2598).
|
|
4
|
+
*
|
|
5
|
+
* The e2e half lives in `packages/vite/src/__tests__/mutable-binding-writers.test.ts`
|
|
6
|
+
* (real `vite build`, emitted template type-checked). This half pins what an
|
|
7
|
+
* e2e fixture can't isolate: which syntactic forms count as a WRITE, and the
|
|
8
|
+
* reads/false matches that must not. Those negative cases are why this uses
|
|
9
|
+
* the AST — a regex for `name\s*=` would match every one of them.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, test, expect } from 'bun:test'
|
|
12
|
+
import { findAssignedNames, closeOverWritersOfMutableBindings } from '../module-exports.ts'
|
|
13
|
+
|
|
14
|
+
const candidates = (...names: string[]) => new Set(names)
|
|
15
|
+
|
|
16
|
+
describe('findAssignedNames', () => {
|
|
17
|
+
test('plain assignment', () => {
|
|
18
|
+
expect([...findAssignedNames(`el = node`, candidates('el'))]).toEqual(['el'])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('compound assignment', () => {
|
|
22
|
+
expect([...findAssignedNames(`seq += 1`, candidates('seq'))]).toEqual(['seq'])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('logical assignment', () => {
|
|
26
|
+
expect([...findAssignedNames(`cached ??= build()`, candidates('cached'))]).toEqual(['cached'])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('postfix and prefix update', () => {
|
|
30
|
+
expect([...findAssignedNames(`a++`, candidates('a'))]).toEqual(['a'])
|
|
31
|
+
expect([...findAssignedNames(`--b`, candidates('b'))]).toEqual(['b'])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('assignment nested inside a function body', () => {
|
|
35
|
+
const body = `(el) => { if (el) { highlightEl = el } }`
|
|
36
|
+
expect([...findAssignedNames(body, candidates('highlightEl'))]).toEqual(['highlightEl'])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('reports only the candidates asked about', () => {
|
|
40
|
+
const body = `a = 1; b = 2; c = 3`
|
|
41
|
+
expect([...findAssignedNames(body, candidates('b'))]).toEqual(['b'])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('a read is not a write', () => {
|
|
45
|
+
expect([...findAssignedNames(`const x = el`, candidates('el'))]).toEqual([])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('a comparison is not a write', () => {
|
|
49
|
+
expect([...findAssignedNames(`if (el == null) {}`, candidates('el'))]).toEqual([])
|
|
50
|
+
expect([...findAssignedNames(`if (el === other) {}`, candidates('el'))]).toEqual([])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('a property write through the binding is not a write TO it', () => {
|
|
54
|
+
// `el` keeps whatever it already held — this cannot be what gives a
|
|
55
|
+
// never-narrowed binding its value.
|
|
56
|
+
expect([...findAssignedNames(`el.scrollTop = 0`, candidates('el'))]).toEqual([])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('a same-named property key is not a write', () => {
|
|
60
|
+
expect([...findAssignedNames(`const o = { el: 1 }`, candidates('el'))]).toEqual([])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('`name=` inside a string or JSX attribute is not a write', () => {
|
|
64
|
+
expect([...findAssignedNames(`const s = "el = node"`, candidates('el'))]).toEqual([])
|
|
65
|
+
expect([...findAssignedNames(`<div data-x="el = node" />`, candidates('el'))]).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('no candidates means no parse and no results', () => {
|
|
69
|
+
expect([...findAssignedNames(`el = node`, candidates())]).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('closeOverWritersOfMutableBindings', () => {
|
|
74
|
+
// `declarations` carries the local `let` bindings themselves alongside the
|
|
75
|
+
// functions, exactly as the adapter builds it (localConstants +
|
|
76
|
+
// localFunctions) — a binding only counts as "surviving" if it is in this
|
|
77
|
+
// list and reachable.
|
|
78
|
+
const decls = [
|
|
79
|
+
{ name: 'binding', body: `null` },
|
|
80
|
+
// Reachable from the rendered JSX below.
|
|
81
|
+
{ name: 'reader', body: `() => { if (binding) return binding.x; return 0 }` },
|
|
82
|
+
// Reachable ONLY through a stripped attribute — the writer.
|
|
83
|
+
{ name: 'writer', body: `(el) => { binding = el }` },
|
|
84
|
+
// Reachable from nothing at all; must stay pruned.
|
|
85
|
+
{ name: 'orphan', body: `() => { unrelated = 1 }` },
|
|
86
|
+
]
|
|
87
|
+
const rendered = `<div onScroll={reader} />`
|
|
88
|
+
|
|
89
|
+
test('pulls in the writer of a surviving mutable binding', () => {
|
|
90
|
+
const out = closeOverWritersOfMutableBindings(rendered, decls, new Set(['binding']))
|
|
91
|
+
expect(out.has('reader')).toBe(true)
|
|
92
|
+
expect(out.has('writer')).toBe(true)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('leaves declarations that write nothing reachable alone', () => {
|
|
96
|
+
const out = closeOverWritersOfMutableBindings(rendered, decls, new Set(['binding']))
|
|
97
|
+
expect(out.has('orphan')).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('does not retain a writer when the binding itself was pruned', () => {
|
|
101
|
+
// Nothing references `binding`, so it is not emitted and its writer is
|
|
102
|
+
// dead client-only code — the pruning this feature must not undo.
|
|
103
|
+
const out = closeOverWritersOfMutableBindings(`<div />`, decls, new Set(['binding']))
|
|
104
|
+
expect(out.has('writer')).toBe(false)
|
|
105
|
+
expect(out.has('reader')).toBe(false)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('is a no-op when nothing is mutable', () => {
|
|
109
|
+
const withMutables = closeOverWritersOfMutableBindings(rendered, decls, new Set(['binding']))
|
|
110
|
+
const withoutMutables = closeOverWritersOfMutableBindings(rendered, decls, new Set())
|
|
111
|
+
expect(withoutMutables.has('writer')).toBe(false)
|
|
112
|
+
expect(withMutables.has('writer')).toBe(true)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('reaches a writer that is only retained transitively', () => {
|
|
116
|
+
// `outerWriter` writes `a`; retaining it makes `b` reachable, whose own
|
|
117
|
+
// writer must then come along too — one round is not enough.
|
|
118
|
+
const chained = [
|
|
119
|
+
{ name: 'a', body: `null` },
|
|
120
|
+
{ name: 'b', body: `null` },
|
|
121
|
+
{ name: 'reader', body: `() => (a ? a.x : 0)` },
|
|
122
|
+
{ name: 'outerWriter', body: `(el) => { a = el; touch(b) }` },
|
|
123
|
+
{ name: 'innerWriter', body: `(el) => { b = el }` },
|
|
124
|
+
]
|
|
125
|
+
const out = closeOverWritersOfMutableBindings(
|
|
126
|
+
`<div onScroll={reader} />`,
|
|
127
|
+
chained,
|
|
128
|
+
new Set(['a', 'b']),
|
|
129
|
+
)
|
|
130
|
+
expect(out.has('outerWriter')).toBe(true)
|
|
131
|
+
expect(out.has('innerWriter')).toBe(true)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2596 — inside a `.map()` callback, a conditional whose condition is a
|
|
3
|
+
* bare reference to a preamble-declared local (a pre-return `const` in the
|
|
4
|
+
* callback body) never got its IR `reactive` flag set, even when the
|
|
5
|
+
* preamble local reads a signal:
|
|
6
|
+
*
|
|
7
|
+
* {items().map((item) => {
|
|
8
|
+
* const label = item.title || fallback() // preamble local reading a signal
|
|
9
|
+
* return <li>{label ? <Badge/> : <Placeholder/>}</li> // never re-evaluated
|
|
10
|
+
* })}
|
|
11
|
+
*
|
|
12
|
+
* Root cause: Phase 1's conditional-reactivity classifiers
|
|
13
|
+
* (`isReactiveExpression` for the condition's own text, `referencesLoopParam`
|
|
14
|
+
* for item/index/destructure names via `BindingScope.valueBoundNames()`) only
|
|
15
|
+
* ever see the token `label` — never its declaration — so a bare preamble-
|
|
16
|
+
* local reference tripped neither.
|
|
17
|
+
*
|
|
18
|
+
* Fix (`jsx-to-ir.ts`):
|
|
19
|
+
* - `computePreambleReactiveNames` determines which preamble-declared names
|
|
20
|
+
* are THEMSELVES reactive — read a signal/memo/reactive prop, directly or
|
|
21
|
+
* transitively through an earlier preamble declaration — by re-running
|
|
22
|
+
* the SAME `isReactiveExpression` classifier already used for ordinary
|
|
23
|
+
* conditions, against each declaration's initializer.
|
|
24
|
+
* - `markPreambleConditionalReactivity` is a new post-hoc pass (same shape
|
|
25
|
+
* as `collectPreambleRegions`/`markPreambleAttrSlots`, #2447) that grants
|
|
26
|
+
* `reactive: true` + a slot id to a loop-body conditional whose condition
|
|
27
|
+
* bare-references one of those names.
|
|
28
|
+
*
|
|
29
|
+
* This alone isn't sufficient — Phase 2's `collectLoopChildConditionals`
|
|
30
|
+
* independently re-derives reactivity from the condition's EXPANDED text via
|
|
31
|
+
* `classifyReactivity`, and `expandConstantForReactivity`'s shadow guard
|
|
32
|
+
* (#2482 Stage 1b) deliberately leaves a preamble-bound identifier like
|
|
33
|
+
* `label` unexpanded, so `classifyReactivity('label', …)` always reads
|
|
34
|
+
* 'none'. `collectLoopChildConditionals` and `emitOuterConditional` (in
|
|
35
|
+
* `ir-to-client-js/`) got the same `readsPreamble` bypass +
|
|
36
|
+
* preamble-re-run-in-getter treatment `collectLoopChildReactiveAttrs`
|
|
37
|
+
* already had (#2447) — the condition-position twin.
|
|
38
|
+
*
|
|
39
|
+
* These tests assert the COMPILED OUTPUT shape (mirrors
|
|
40
|
+
* `csr-materialize-loop-preamble-shadow.test.ts` and
|
|
41
|
+
* `preamble-region-patch.test.ts`'s convention: compiler-internals
|
|
42
|
+
* correctness is a generated-code-shape pin, not a live-DOM test — DOM
|
|
43
|
+
* execution of `insert()`/`mapArrayLazy`'s own reactive-tracking contract is
|
|
44
|
+
* covered by their own runtime unit tests).
|
|
45
|
+
*
|
|
46
|
+
* Depending on loop-row shape, the fix surfaces through one of two Phase 2
|
|
47
|
+
* plans — both draw from the SAME `collectLoopChildConditionals` fix:
|
|
48
|
+
* - Both branches "wiring-free static elements" (`analyzeLazyConditional`,
|
|
49
|
+
* §9.4 L3): the lazy-row plan (`mapArrayLazy`), which recomputes the
|
|
50
|
+
* preamble in `applyItem` (per-row) AND `applyOuter` (outer-signal
|
|
51
|
+
* effect, primed by reading the signal unconditionally).
|
|
52
|
+
* - Otherwise: the eager `mapArray` + `insert()` plan, which recomputes the
|
|
53
|
+
* preamble inside the getter passed to `insert()`.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { describe, test, expect } from 'bun:test'
|
|
57
|
+
import { compileJSX } from '../compiler'
|
|
58
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
59
|
+
|
|
60
|
+
const adapter = new TestAdapter()
|
|
61
|
+
|
|
62
|
+
function clientJsFor(source: string): string {
|
|
63
|
+
const result = compileJSX(source, 'Repro.tsx', { adapter })
|
|
64
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
65
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
66
|
+
expect(clientJs).toBeDefined()
|
|
67
|
+
return clientJs!.content
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('preamble-conditional-reactivity (#2596)', () => {
|
|
71
|
+
test('(a) a conditional condition reading a signal-derived preamble local is reactive (lazy-row plan)', () => {
|
|
72
|
+
const js = clientJsFor(`
|
|
73
|
+
'use client'
|
|
74
|
+
import { createSignal } from '@barefootjs/client'
|
|
75
|
+
export function Widget({ items }: { items: { id: number; title: string }[] }) {
|
|
76
|
+
const [fallback, setFallback] = createSignal('x')
|
|
77
|
+
return (
|
|
78
|
+
<ul>
|
|
79
|
+
{items.map((item) => {
|
|
80
|
+
const label = item.title || fallback()
|
|
81
|
+
return <li key={item.id}>{label ? <span>yes</span> : <span>no</span>}</li>
|
|
82
|
+
})}
|
|
83
|
+
</ul>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
`)
|
|
87
|
+
// Both arms are wiring-free static elements — eligible for the lazy-row
|
|
88
|
+
// plan (§9.4 L3), which carries no per-row insert()/createEffect.
|
|
89
|
+
expect(js).toContain('mapArrayLazy(')
|
|
90
|
+
// The preamble re-runs (loop-param accessor form, `item()`) inside BOTH
|
|
91
|
+
// apply bodies — `applyItem` (per-row, same-key update) and `applyOuter`
|
|
92
|
+
// (the outer-signal effect) — never the plain (`item.title`) form.
|
|
93
|
+
const preambleRerun = /const label = item\(\)\.title \|\| fallback\(\);/g
|
|
94
|
+
expect(js.match(preambleRerun)?.length).toBeGreaterThanOrEqual(2)
|
|
95
|
+
// `applyOuter` primes the signal read unconditionally (§9.3(3)) so the
|
|
96
|
+
// effect subscribes even when the row list is momentarily empty — this
|
|
97
|
+
// IS the fix: before it, nothing in the emitted plan read `fallback()`
|
|
98
|
+
// outside the one-time row-construction line, so the effect never
|
|
99
|
+
// subscribed and the branch froze at its SSR-time value.
|
|
100
|
+
expect(js).toMatch(/applyOuter:\s*\(__es, __seed\) => \{\s*fallback\(\)/)
|
|
101
|
+
// The conditional actually got wired — not the empty no-op body a
|
|
102
|
+
// non-reactive conditional gets (see negative-case test below).
|
|
103
|
+
expect(js).not.toContain('applyItem: () => {}')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('(b) the same shape through the eager insert() plan when a branch carries its own reactive text', () => {
|
|
107
|
+
const js = clientJsFor(`
|
|
108
|
+
'use client'
|
|
109
|
+
import { createSignal } from '@barefootjs/client'
|
|
110
|
+
export function Widget({ items }: { items: { id: number; title: string }[] }) {
|
|
111
|
+
const [fallback, setFallback] = createSignal('x')
|
|
112
|
+
return (
|
|
113
|
+
<ul>
|
|
114
|
+
{items.map((item) => {
|
|
115
|
+
const label = item.title || fallback()
|
|
116
|
+
return <li key={item.id}>{label ? <span>{item.title} yes</span> : <span>no</span>}</li>
|
|
117
|
+
})}
|
|
118
|
+
</ul>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
`)
|
|
122
|
+
// A branch with its own reactive text disqualifies the lazy-row plan
|
|
123
|
+
// (`analyzeLazyConditional` requires wiring-free arms) — this exercises
|
|
124
|
+
// the OTHER Phase 2 path the fix threads through.
|
|
125
|
+
expect(js).toContain('mapArray(')
|
|
126
|
+
expect(js).not.toContain('mapArrayLazy(')
|
|
127
|
+
// `insert()`'s condition getter re-runs the preamble before reading
|
|
128
|
+
// `label` — the local isn't otherwise in scope inside that closure.
|
|
129
|
+
expect(js).toMatch(
|
|
130
|
+
/insert\(__el, 's1', \(\) => \{ const label = item\(\)\.title \|\| fallback\(\);; return \(label\) \}/,
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('(c) a conditional reading a NON-reactive preamble local stays non-reactive', () => {
|
|
135
|
+
const js = clientJsFor(`
|
|
136
|
+
'use client'
|
|
137
|
+
import { createSignal } from '@barefootjs/client'
|
|
138
|
+
export function Widget({ items }: { items: { id: number; title: string }[] }) {
|
|
139
|
+
const [count, setCount] = createSignal(0)
|
|
140
|
+
return (
|
|
141
|
+
<ul>
|
|
142
|
+
{items.map((item) => {
|
|
143
|
+
const label = item.title
|
|
144
|
+
return <li key={item.id}>{label ? <span>yes</span> : <span>no</span>}</li>
|
|
145
|
+
})}
|
|
146
|
+
</ul>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
`)
|
|
150
|
+
// Still lazy-eligible (both arms are wiring-free statics), but the
|
|
151
|
+
// conditional itself never got a slot id / reactive flag — no per-row
|
|
152
|
+
// OR per-outer wiring for it at all.
|
|
153
|
+
expect(js).toContain('mapArrayLazy(')
|
|
154
|
+
expect(js).toContain('applyItem: () => {}')
|
|
155
|
+
expect(js).not.toContain('applyOuter')
|
|
156
|
+
expect(js).not.toContain('insert(')
|
|
157
|
+
// The condition's static, row-construction-time value is still baked
|
|
158
|
+
// into the row template (correct — it's genuinely never going to
|
|
159
|
+
// change without a fresh row).
|
|
160
|
+
expect(js).toMatch(/label \? `<span>yes<\/span>` : `<span>no<\/span>`/)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('(d) transitive: a preamble local reading another preamble local that reads a signal', () => {
|
|
164
|
+
const js = clientJsFor(`
|
|
165
|
+
'use client'
|
|
166
|
+
import { createSignal } from '@barefootjs/client'
|
|
167
|
+
export function Widget({ items }: { items: { id: number; title: string }[] }) {
|
|
168
|
+
const [fallback, setFallback] = createSignal('x')
|
|
169
|
+
return (
|
|
170
|
+
<ul>
|
|
171
|
+
{items.map((item) => {
|
|
172
|
+
const base = fallback()
|
|
173
|
+
const label = item.title || base
|
|
174
|
+
return <li key={item.id}>{label ? <span>yes</span> : <span>no</span>}</li>
|
|
175
|
+
})}
|
|
176
|
+
</ul>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
`)
|
|
180
|
+
// `computePreambleReactiveNames` walks preamble declarations in source
|
|
181
|
+
// order, folding each name into the reactive set when its initializer
|
|
182
|
+
// reads a signal/memo/prop directly OR references an earlier name
|
|
183
|
+
// already in that set — `label` qualifies via `base`, not via any
|
|
184
|
+
// signal call of its own.
|
|
185
|
+
expect(js).toContain('mapArrayLazy(')
|
|
186
|
+
expect(js).not.toContain('applyItem: () => {}')
|
|
187
|
+
expect(js).toMatch(/applyOuter:\s*\(__es, __seed\) => \{\s*fallback\(\)/)
|
|
188
|
+
const preambleRerun = /const base = fallback\(\); const label = item\(\)\.title \|\| base;/g
|
|
189
|
+
expect(js.match(preambleRerun)?.length).toBeGreaterThanOrEqual(2)
|
|
190
|
+
})
|
|
191
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for #2573 (chart TS7006 family): the SSR no-op signal
|
|
3
|
+
* setter stub was declared as `(..._args: any[]) => {}`. Calling it with
|
|
4
|
+
* an updater function — `setBars((prev) => [...prev, bar])` — puts that
|
|
5
|
+
* arrow in a rest-`any[]` argument position, not a function-typed one, so
|
|
6
|
+
* TypeScript has no contextual signature to infer the arrow's own
|
|
7
|
+
* parameter from and flags it implicit-any (TS7006). Runtime output
|
|
8
|
+
* (client JS) was always correct; this is a type-level emission defect in
|
|
9
|
+
* the SSR template only.
|
|
10
|
+
*
|
|
11
|
+
* The real `createSignal<T>` setter accepts `T | ((prev: T) => T)`
|
|
12
|
+
* (`packages/client/src/reactive.ts`'s `Signal<T>`). The stub now mirrors
|
|
13
|
+
* that signature whenever the signal's type is known (`SignalInfo.type`,
|
|
14
|
+
* the same field `needsTypeAssertion` already reads for the getter), so
|
|
15
|
+
* the updater arrow's parameter infers from the real element type.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, test, expect } from 'bun:test'
|
|
19
|
+
import { compileJSX } from '../compiler'
|
|
20
|
+
import { HonoAdapter } from '../../../../packages/adapter-hono/src/adapter/hono-adapter'
|
|
21
|
+
|
|
22
|
+
describe('signal setter updater-function typing in emitted templates (#2573)', () => {
|
|
23
|
+
test('a typed signal gets an updater-aware setter stub', () => {
|
|
24
|
+
const honoAdapter = new HonoAdapter()
|
|
25
|
+
const source = `
|
|
26
|
+
'use client'
|
|
27
|
+
import { createSignal } from '@barefootjs/client'
|
|
28
|
+
|
|
29
|
+
interface Bar { id: string; height: number }
|
|
30
|
+
|
|
31
|
+
export function Chart() {
|
|
32
|
+
const [bars, setBars] = createSignal<Bar[]>([])
|
|
33
|
+
|
|
34
|
+
// Called directly from the returned JSX (not from an onXxx handler
|
|
35
|
+
// prop, which SSR stubs to a no-op) so it — and the setter call
|
|
36
|
+
// inside it — stays reachable into the emitted template.
|
|
37
|
+
const addBar = (bar: Bar) => {
|
|
38
|
+
setBars((prev) => [...prev, bar])
|
|
39
|
+
return bars().length
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return <div>{addBar({ id: 'a', height: 1 })}</div>
|
|
43
|
+
}
|
|
44
|
+
`
|
|
45
|
+
|
|
46
|
+
const result = compileJSX(source, 'Chart.tsx', { adapter: honoAdapter })
|
|
47
|
+
expect(result.errors).toHaveLength(0)
|
|
48
|
+
|
|
49
|
+
const template = result.files.find((f) => f.type === 'markedTemplate')!
|
|
50
|
+
expect(template).toBeDefined()
|
|
51
|
+
expect(template.content).toContain(
|
|
52
|
+
'const setBars: (valueOrFn: Bar[] | ((prev: Bar[]) => Bar[])) => void = () => {}',
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('a signal with no resolvable type keeps the untyped rest-args stub', () => {
|
|
57
|
+
const honoAdapter = new HonoAdapter()
|
|
58
|
+
const source = `
|
|
59
|
+
'use client'
|
|
60
|
+
import { createSignal } from '@barefootjs/client'
|
|
61
|
+
|
|
62
|
+
export function Widget(props: { initial: unknown }) {
|
|
63
|
+
const [value, setValue] = createSignal(props.initial)
|
|
64
|
+
|
|
65
|
+
const reset = () => {
|
|
66
|
+
setValue(props.initial)
|
|
67
|
+
return value()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return <div>{String(reset())}</div>
|
|
71
|
+
}
|
|
72
|
+
`
|
|
73
|
+
|
|
74
|
+
const result = compileJSX(source, 'Widget.tsx', { adapter: honoAdapter })
|
|
75
|
+
expect(result.errors).toHaveLength(0)
|
|
76
|
+
|
|
77
|
+
const template = result.files.find((f) => f.type === 'markedTemplate')!
|
|
78
|
+
expect(template).toBeDefined()
|
|
79
|
+
expect(template.content).toContain('const setValue = (..._args: any[]) => {}')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -17,7 +17,7 @@ import { BF_SCOPE, BF_SLOT, BF_COND } from '@barefootjs/shared'
|
|
|
17
17
|
import { BaseAdapter } from './interface.ts'
|
|
18
18
|
import type { CallbackBodyAcceptor } from './interface.ts'
|
|
19
19
|
import { ENV_SIGNAL_CLIENT_FACTORY } from './env-signal.ts'
|
|
20
|
-
import { formatParamWithType,
|
|
20
|
+
import { formatParamWithType, closeOverWritersOfMutableBindings } from '../module-exports.ts'
|
|
21
21
|
import { extractFreeIdentifiersFromText } from '../ir-to-client-js/csr-substitute.ts'
|
|
22
22
|
import { identifierPattern } from '../identifier-pattern.ts'
|
|
23
23
|
|
|
@@ -113,7 +113,15 @@ export abstract class JsxAdapter extends BaseAdapter {
|
|
|
113
113
|
]
|
|
114
114
|
|
|
115
115
|
// Find reachable declarations via transitive dependency analysis
|
|
116
|
-
const reachable =
|
|
116
|
+
const reachable = closeOverWritersOfMutableBindings(
|
|
117
|
+
primaryRefText,
|
|
118
|
+
declarations,
|
|
119
|
+
new Set(
|
|
120
|
+
ir.metadata.localConstants
|
|
121
|
+
.filter(c => (c.declarationKind ?? 'const') !== 'const')
|
|
122
|
+
.map(c => c.name),
|
|
123
|
+
),
|
|
124
|
+
)
|
|
117
125
|
|
|
118
126
|
// Also check which signal setters are referenced
|
|
119
127
|
const reachableBodies = [...reachable].map(name => {
|
|
@@ -168,7 +176,25 @@ export abstract class JsxAdapter extends BaseAdapter {
|
|
|
168
176
|
if (signal.setter) {
|
|
169
177
|
const setterUsed = identifierPattern(signal.setter).test(setterRefText)
|
|
170
178
|
if (setterUsed) {
|
|
171
|
-
|
|
179
|
+
// The real `createSignal<T>` setter accepts `T | ((prev: T) =>
|
|
180
|
+
// T)` (`packages/client/src/reactive.ts`'s `Signal<T>`). The
|
|
181
|
+
// untyped `(..._args: any[]) => {}` stub gave every `setX((prev)
|
|
182
|
+
// => ...)` updater-function call site an `any[]`-typed argument
|
|
183
|
+
// position — not a function-typed one — so the arrow's own
|
|
184
|
+
// `prev` parameter had no contextual type to infer from and
|
|
185
|
+
// `tsc` flagged it implicit-any (TS7006, #2573 chart family).
|
|
186
|
+
// Mirror the real setter's signature whenever the signal's type
|
|
187
|
+
// is known, so updater-function callers keep their inference;
|
|
188
|
+
// fall back to the untyped stub only when it isn't (matches
|
|
189
|
+
// `needsTypeAssertion`'s `'unknown'` guard just above).
|
|
190
|
+
const setterType = preserveTypes && signal.type.kind !== 'unknown'
|
|
191
|
+
? `(valueOrFn: ${signal.type.raw} | ((prev: ${signal.type.raw}) => ${signal.type.raw})) => void`
|
|
192
|
+
: null
|
|
193
|
+
lines.push(
|
|
194
|
+
setterType
|
|
195
|
+
? ` const ${signal.setter}: ${setterType} = () => {}`
|
|
196
|
+
: ` const ${signal.setter} = (..._args: any[]) => {}`,
|
|
197
|
+
)
|
|
172
198
|
}
|
|
173
199
|
}
|
|
174
200
|
}
|
|
@@ -16,6 +16,24 @@
|
|
|
16
16
|
* degrades to the ALREADY-accepted residual (`+` falls back to numeric,
|
|
17
17
|
* same as before #2212 for an unresolvable operand) rather than ever
|
|
18
18
|
* producing silently-wrong output.
|
|
19
|
+
*
|
|
20
|
+
* #2482 (BindingScope) does NOT replace this: it answers "is NAME bound
|
|
21
|
+
* AT THIS POSITION", a live, position-accurate query built by walking
|
|
22
|
+
* INTO scopes during a render-time (or render-shaped) tree walk. This
|
|
23
|
+
* function answers a deliberately different, coarser question —
|
|
24
|
+
* "is NAME EVER a loop-bound name ANYWHERE in the component" — a single
|
|
25
|
+
* whole-component prepass run once at `generate()` entry, before any
|
|
26
|
+
* loop scope exists to thread. The two are complementary, not
|
|
27
|
+
* duplicative: swapping this for `BindingScope` would require re-deriving
|
|
28
|
+
* a position-accurate answer at every string-typed-operand call site,
|
|
29
|
+
* which is exactly the coarse-but-safe trade-off this function exists to
|
|
30
|
+
* avoid. Stays outside the `binding-scope-ratchet.test.ts` ledger for an
|
|
31
|
+
* incidental reason too — a lowercase-`l`-led spelling of this file's own
|
|
32
|
+
* export once lived as a per-adapter ref-counted-map field name (fully
|
|
33
|
+
* migrated away in an earlier #2482 stage) that the ledger's scan tracked;
|
|
34
|
+
* this function's own `collectLoopBoundNames` name is capitalized
|
|
35
|
+
* differently (a capital `L`) and so was never inside that scan's reach in
|
|
36
|
+
* the first place.
|
|
19
37
|
*/
|
|
20
38
|
|
|
21
39
|
import type { ComponentIR, IRNode } from '../types.ts'
|
|
@@ -164,7 +164,10 @@ export class TestAdapter extends JsxAdapter {
|
|
|
164
164
|
// Module-export keyword belongs to the adapter: it knows the target language
|
|
165
165
|
// and whether the source declared the component as exported.
|
|
166
166
|
const exportPrefix = ir.metadata.isExported === false ? '' : 'export '
|
|
167
|
-
|
|
167
|
+
// Carry the source component's own generic type parameters, if any
|
|
168
|
+
// (mirrors `HonoAdapter` — see `IRMetadata.typeParameters`'s docstring).
|
|
169
|
+
const typeParameters = ir.metadata.typeParameters ?? ''
|
|
170
|
+
lines.push(`${exportPrefix}function ${name}${typeParameters}(${fullPropsDestructure}${typeAnnotation}${noArgDefault}) {`)
|
|
168
171
|
|
|
169
172
|
// Generate scope ID
|
|
170
173
|
if (hasClientInteractivity) {
|
|
@@ -464,14 +464,26 @@ export function collectModuleStringConsts(
|
|
|
464
464
|
* compile-time lookup (the icon registry's `strokePaths['chevron-down']`,
|
|
465
465
|
* pagination's `variantClasses.ghost`; #1896 / #1897). Returns the
|
|
466
466
|
* looked-up scalar, or `null` for any other shape so callers fall back
|
|
467
|
-
* to their generic lowering. Shared by all
|
|
467
|
+
* to their generic lowering. Shared by all seven template-string adapters;
|
|
468
468
|
* the prop-KEYED variant of the pattern lives in `parseRecordIndexAccess`.
|
|
469
|
+
*
|
|
470
|
+
* `isShadowed` is REQUIRED (#2482 Stage 2) rather than left to caller
|
|
471
|
+
* discipline: an enclosing loop callback's own param/index/destructure/
|
|
472
|
+
* preamble binding of the same name as `objectName` (`.map((cfg) =>
|
|
473
|
+
* <li>{cfg.x}</li>)` shadowing a module `const cfg = {…}`) must resolve to
|
|
474
|
+
* the ROW value, not the outer const's member — every call site used to
|
|
475
|
+
* check this itself, against its own ad-hoc per-adapter shadow-name
|
|
476
|
+
* bookkeeping, with no static guarantee a new caller wouldn't forget it.
|
|
477
|
+
* Pass `scope.isBound` (or `scope.asShadowPredicate()`) from the caller's
|
|
478
|
+
* threaded `BindingScope`.
|
|
469
479
|
*/
|
|
470
480
|
export function lookupStaticRecordLiteral(
|
|
471
481
|
objectName: string,
|
|
472
482
|
key: string,
|
|
473
483
|
constants: IRMetadata['localConstants'] | undefined,
|
|
484
|
+
isShadowed: (name: string) => boolean,
|
|
474
485
|
): { kind: 'string' | 'number'; text: string } | null {
|
|
486
|
+
if (isShadowed(objectName)) return null
|
|
475
487
|
const constInfo = (constants ?? []).find(c => c.name === objectName && c.isModule)
|
|
476
488
|
if (constInfo?.value === undefined) return null
|
|
477
489
|
const sf = ts.createSourceFile(
|
package/src/compiler.ts
CHANGED
|
@@ -624,6 +624,23 @@ function compileMultipleComponents(
|
|
|
624
624
|
// Helpers
|
|
625
625
|
// =============================================================================
|
|
626
626
|
|
|
627
|
+
/**
|
|
628
|
+
* Verbatim text of the component function's own generic type parameter
|
|
629
|
+
* list (`<NodeType extends NodeBase = NodeBase, EdgeType extends EdgeBase
|
|
630
|
+
* = EdgeBase>`), or `null` when the component isn't generic. Source text
|
|
631
|
+
* per parameter (`node.getText(sourceFile)`), not a re-printed AST, so
|
|
632
|
+
* constraints/defaults/comments round-trip exactly like `ConstantInfo.
|
|
633
|
+
* typeAnnotation` does for `let` (#2589) — see `IRMetadata.typeParameters`.
|
|
634
|
+
*/
|
|
635
|
+
function componentTypeParametersText(
|
|
636
|
+
componentNode: ts.FunctionDeclaration | ts.ArrowFunction | null,
|
|
637
|
+
sourceFile: ts.SourceFile,
|
|
638
|
+
): string | null {
|
|
639
|
+
const typeParameters = componentNode?.typeParameters
|
|
640
|
+
if (!typeParameters || typeParameters.length === 0) return null
|
|
641
|
+
return `<${typeParameters.map(p => p.getText(sourceFile)).join(', ')}>`
|
|
642
|
+
}
|
|
643
|
+
|
|
627
644
|
export function buildMetadata(
|
|
628
645
|
ctx: ReturnType<typeof analyzeComponent>,
|
|
629
646
|
): IRMetadata {
|
|
@@ -634,6 +651,7 @@ export function buildMetadata(
|
|
|
634
651
|
isClientComponent: ctx.hasUseClientDirective,
|
|
635
652
|
typeDefinitions: ctx.typeDefinitions,
|
|
636
653
|
propsType: ctx.propsType,
|
|
654
|
+
typeParameters: componentTypeParametersText(ctx.componentNode, ctx.sourceFile),
|
|
637
655
|
propsParams: ctx.propsParams,
|
|
638
656
|
propsObjectName: ctx.propsObjectName,
|
|
639
657
|
restPropsName: ctx.restPropsName,
|