@barefootjs/jsx 0.31.2 → 0.31.4
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/template-imports.d.ts +27 -15
- package/dist/adapters/template-imports.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/debug.d.ts.map +1 -1
- package/dist/identifier-pattern.d.ts +62 -0
- package/dist/identifier-pattern.d.ts.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +280 -119
- package/dist/ir-to-client-js/collect-elements.d.ts +23 -2
- 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/stringify/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/csr-substitute.d.ts +12 -1
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +20 -12
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/prop-handling.d.ts +25 -2
- package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts +30 -2
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/module-exports.d.ts.map +1 -1
- package/dist/prop-rewrite.d.ts +1 -1
- package/dist/relocate.d.ts.map +1 -1
- package/dist/scope/binding-scope.d.ts +179 -0
- package/dist/scope/binding-scope.d.ts.map +1 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/binding-scope-preamble-shadowing.test.ts +115 -0
- package/src/__tests__/binding-scope-ratchet.test.ts +194 -0
- package/src/__tests__/binding-scope.test.ts +200 -0
- package/src/__tests__/csr-materialize-loop-preamble-shadow.test.ts +76 -0
- package/src/__tests__/csr-substitute-enclosing-scope.test.ts +77 -0
- package/src/__tests__/identifier-pattern.test.ts +170 -0
- package/src/__tests__/let-type-annotation.test.ts +208 -0
- package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +107 -0
- package/src/__tests__/rewrite-dynamic-imports.test.ts +98 -0
- package/src/adapters/jsx-adapter.ts +34 -5
- package/src/adapters/template-imports.ts +93 -0
- package/src/analyzer.ts +20 -3
- package/src/debug.ts +4 -3
- package/src/identifier-pattern.ts +79 -0
- package/src/index.ts +5 -1
- package/src/ir-to-client-js/collect-elements.ts +44 -15
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +2 -1
- package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +2 -1
- package/src/ir-to-client-js/csr-substitute.ts +19 -2
- package/src/ir-to-client-js/html-template.ts +37 -31
- package/src/ir-to-client-js/imports.ts +2 -1
- package/src/ir-to-client-js/prop-handling.ts +28 -1
- package/src/ir-to-client-js/reactivity.ts +54 -4
- package/src/ir-to-client-js/rewrite-props-object.ts +2 -1
- package/src/ir-to-client-js/utils.ts +9 -8
- package/src/jsx-to-ir.ts +187 -73
- package/src/module-exports.ts +3 -2
- package/src/prop-rewrite.ts +1 -1
- package/src/relocate.ts +2 -1
- package/src/scope/binding-scope.ts +238 -0
- package/src/types.ts +8 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2482 Stage 1b — `generateCsrTemplateWithOpts`'s (`html-template.ts`)
|
|
3
|
+
* CSR "materialize" template lambda (the `hydrate(..., { template })`
|
|
4
|
+
* function used to render a loop's rows client-side with no SSR markup to
|
|
5
|
+
* hydrate against) const-folded a `.map()` callback preamble local (#2447)
|
|
6
|
+
* into an outer, same-named module-level const, instead of leaving the
|
|
7
|
+
* row-local binding unresolved.
|
|
8
|
+
*
|
|
9
|
+
* Root cause: the `loop` case's `opts.loopBoundNames` — a flat
|
|
10
|
+
* `Set<string>` re-unioned per recursion level — only ever accumulated
|
|
11
|
+
* the loop's item / index / destructured-binding names, never the
|
|
12
|
+
* callback's preamble-declared locals (#2447 postdates the original
|
|
13
|
+
* `loopBoundNames` shadow fix, #2222). Migrating onto `BindingScope`'s
|
|
14
|
+
* `enterLoopRow` (which binds item ∪ index ∪ destructure ∪ preamble
|
|
15
|
+
* `declaredNames` uniformly) closes the gap.
|
|
16
|
+
*
|
|
17
|
+
* Observable failure mode before the fix: every row's branch condition
|
|
18
|
+
* evaluated the constant's fixed truthiness instead of the row's own
|
|
19
|
+
* preamble-computed value — e.g. every `<li>` rendered its "true" branch
|
|
20
|
+
* regardless of the actual per-item condition.
|
|
21
|
+
*
|
|
22
|
+
* Modeled on `csr-template-loop-shadowing.test.ts` (the pre-#2447
|
|
23
|
+
* item/index/destructured-param shadowing pins for this same template
|
|
24
|
+
* lambda) and `binding-scope-preamble-shadowing.test.ts` (the IR-level
|
|
25
|
+
* preamble-shadowing analogue this test is the CSR-codegen-level sibling
|
|
26
|
+
* of).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, test, expect } from 'bun:test'
|
|
30
|
+
import { compileJSX } from '../compiler'
|
|
31
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
32
|
+
|
|
33
|
+
const adapter = new TestAdapter()
|
|
34
|
+
|
|
35
|
+
function clientJsFor(source: string): string {
|
|
36
|
+
const result = compileJSX(source, 'Repro.tsx', { adapter })
|
|
37
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
38
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
39
|
+
expect(clientJs).toBeDefined()
|
|
40
|
+
return clientJs!.content
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function templateLambda(content: string): string {
|
|
44
|
+
const line = content.split('\n').find(l => l.includes('hydrate('))
|
|
45
|
+
expect(line).toBeDefined()
|
|
46
|
+
return line!
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('CSR materialize template vs .map() preamble-local shadowing a module const (#2482)', () => {
|
|
50
|
+
test('a preamble local shadowing a module const stays row-local in the hydrate template lambda', () => {
|
|
51
|
+
const tpl = templateLambda(clientJsFor(`
|
|
52
|
+
'use client'
|
|
53
|
+
import { createSignal } from '@barefootjs/client'
|
|
54
|
+
export function Widget({ items }: { items: { name: string; active: boolean }[] }) {
|
|
55
|
+
const label = 'MODULE_CONST'
|
|
56
|
+
const [on, setOn] = createSignal(true)
|
|
57
|
+
return (
|
|
58
|
+
<div onClick={() => setOn(false)}>
|
|
59
|
+
<ul>
|
|
60
|
+
{items.map((item) => {
|
|
61
|
+
const label = item.active && on()
|
|
62
|
+
return <li key={item.name}>{label ? <span>yes</span> : <span>no</span>}</li>
|
|
63
|
+
})}
|
|
64
|
+
</ul>
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
`))
|
|
69
|
+
|
|
70
|
+
// The row-local `label` (the preamble's OWN computed value) must
|
|
71
|
+
// drive the branch — never the outer module const's literal.
|
|
72
|
+
expect(tpl).toContain('label ? `<span>yes</span>`')
|
|
73
|
+
expect(tpl).not.toContain("('MODULE_CONST')")
|
|
74
|
+
expect(tpl).not.toContain('MODULE_CONST')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2482 Stage 1b — `csrSubstitute`'s `enclosingScope` parameter.
|
|
3
|
+
*
|
|
4
|
+
* `csrSubstitute`'s `boundStack` only ever learns about bindings found
|
|
5
|
+
* while walking the substituted expression's OWN AST (nested arrow/
|
|
6
|
+
* function parameters and block-scoped locals inside `value` itself) — it
|
|
7
|
+
* has no way to see a binding introduced OUTSIDE that expression, e.g. an
|
|
8
|
+
* enclosing `.map()` row's item/index/destructured/preamble binding.
|
|
9
|
+
* `enclosingScope` (a `BindingScope`) seeds those outer frames so
|
|
10
|
+
* `isBound` treats them identically to an in-expression arrow param.
|
|
11
|
+
*
|
|
12
|
+
* Direct unit coverage, not a full-pipeline conformance fixture: every
|
|
13
|
+
* current call site (`html-template.ts`'s `generateCsrTemplateWithOpts`)
|
|
14
|
+
* already pre-filters `env.substitutions` to remove loop-shadowed names
|
|
15
|
+
* BEFORE calling in (see the `loop` case's `childEnv` construction, itself
|
|
16
|
+
* migrated onto `BindingScope.enterLoopRow` in this same stage) — so by
|
|
17
|
+
* the time `csrSubstitute` runs inside a loop body today, the shadowed
|
|
18
|
+
* name is already absent from `env.substitutions` and `enclosingScope`
|
|
19
|
+
* never gets a chance to matter. This test exercises the module function
|
|
20
|
+
* directly, bypassing that pre-filtering, to pin the parameter's own
|
|
21
|
+
* contract in isolation: SHOULD a future call site ever hand `csrSubstitute`
|
|
22
|
+
* an unfiltered env alongside a loop's `BindingScope` (e.g. a caller that
|
|
23
|
+
* wants substitution and shadow-guarding as one step instead of two), the
|
|
24
|
+
* shadow guard already works correctly.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, test, expect } from 'bun:test'
|
|
28
|
+
import { csrSubstitute, type CsrEnv } from '../ir-to-client-js/csr-substitute.ts'
|
|
29
|
+
import { BindingScope } from '../scope/binding-scope.ts'
|
|
30
|
+
|
|
31
|
+
describe('csrSubstitute enclosingScope (#2482)', () => {
|
|
32
|
+
test('a name bound by the enclosing loop scope is left unsubstituted, even though the env still has an entry for it', () => {
|
|
33
|
+
// Deliberately UNFILTERED env — in real call sites this would already
|
|
34
|
+
// have `label` removed by the caller's own loop-scope filtering; here
|
|
35
|
+
// we keep it to isolate what `enclosingScope` alone contributes.
|
|
36
|
+
const env: CsrEnv = {
|
|
37
|
+
substitutions: new Map([
|
|
38
|
+
['label', { kind: 'identifier', replacement: "'MODULE_CONST'", freeIdentifiers: new Set() }],
|
|
39
|
+
]),
|
|
40
|
+
propsObjectName: null,
|
|
41
|
+
}
|
|
42
|
+
const scope = BindingScope.EMPTY.enterLoopRow({ param: 'label' })
|
|
43
|
+
|
|
44
|
+
const withoutScope = csrSubstitute('label', env)
|
|
45
|
+
const withScope = csrSubstitute('label', env, scope)
|
|
46
|
+
|
|
47
|
+
expect(withoutScope.rewritten).toBe("('MODULE_CONST')")
|
|
48
|
+
expect(withScope.rewritten).toBe('label')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('a name NOT bound by the enclosing scope still substitutes normally', () => {
|
|
52
|
+
const env: CsrEnv = {
|
|
53
|
+
substitutions: new Map([
|
|
54
|
+
['label', { kind: 'identifier', replacement: "'MODULE_CONST'", freeIdentifiers: new Set() }],
|
|
55
|
+
]),
|
|
56
|
+
propsObjectName: null,
|
|
57
|
+
}
|
|
58
|
+
// Scope binds a DIFFERENT name (`item`) — `label` stays substitutable.
|
|
59
|
+
const scope = BindingScope.EMPTY.enterLoopRow({ param: 'item' })
|
|
60
|
+
|
|
61
|
+
const { rewritten } = csrSubstitute('label', env, scope)
|
|
62
|
+
expect(rewritten).toBe("('MODULE_CONST')")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('a preamble-bound name (not item/index/destructure) is also guarded', () => {
|
|
66
|
+
const env: CsrEnv = {
|
|
67
|
+
substitutions: new Map([
|
|
68
|
+
['label', { kind: 'identifier', replacement: "'MODULE_CONST'", freeIdentifiers: new Set() }],
|
|
69
|
+
]),
|
|
70
|
+
propsObjectName: null,
|
|
71
|
+
}
|
|
72
|
+
const scope = BindingScope.EMPTY.enterLoopRow({ param: 'item', preamble: { declaredNames: ['label'] } })
|
|
73
|
+
|
|
74
|
+
const { rewritten } = csrSubstitute('label', env, scope)
|
|
75
|
+
expect(rewritten).toBe('label')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2592 — `$`-containing identifiers broke the compiler's
|
|
3
|
+
* `new RegExp(`\\b${name}\\b`)` "does this expression reference identifier
|
|
4
|
+
* X?" heuristic in two ways: an unescaped `$` acts as a regex end anchor
|
|
5
|
+
* (false negative anywhere but the very end of the pattern), and even once
|
|
6
|
+
* escaped, `\b` treats `$` as a non-word character, so it fails to find a
|
|
7
|
+
* boundary between e.g. `(` and a leading `$` (both non-word — no
|
|
8
|
+
* transition). See `../identifier-pattern.ts` for the fix.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, test, expect } from 'bun:test'
|
|
11
|
+
import { identifierPattern, identifierCallPattern } from '../identifier-pattern.ts'
|
|
12
|
+
import { compileJSX } from '../compiler.ts'
|
|
13
|
+
import { TestAdapter } from '../adapters/test-adapter.ts'
|
|
14
|
+
|
|
15
|
+
describe('identifierPattern (#2592)', () => {
|
|
16
|
+
describe.each(['$item', 'item$', 'a$b', 'item'])('name = %p', (name) => {
|
|
17
|
+
test('matches a standalone parenthesized reference', () => {
|
|
18
|
+
expect(identifierPattern(name).test(`(${name})`)).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('matches a reference after a binary operator', () => {
|
|
22
|
+
expect(identifierPattern(name).test(`x + ${name}`)).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('matches as the base of a member-access expression', () => {
|
|
26
|
+
expect(identifierPattern(name).test(`${name}.foo`)).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('does not match when it is a substring of a longer identifier (suffix)', () => {
|
|
30
|
+
expect(identifierPattern(name).test(`my${name}`)).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('does not match when it is a substring of a longer identifier (prefix)', () => {
|
|
34
|
+
expect(identifierPattern(name).test(`${name}s`)).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('does not match when it appears as a substring inside another identifier', () => {
|
|
38
|
+
// e.g. name="item" inside "xitem" / name="$item" inside "x$item"
|
|
39
|
+
expect(identifierPattern(name).test(`x${name}`)).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// Literal spellings from the issue, so the fixture reads without having
|
|
44
|
+
// to mentally substitute the parameterised %p name above.
|
|
45
|
+
test('$item: matches ($item), x + $item, $item.foo', () => {
|
|
46
|
+
const re = identifierPattern('$item')
|
|
47
|
+
expect(re.test('($item)')).toBe(true)
|
|
48
|
+
expect(re.test('x + $item')).toBe(true)
|
|
49
|
+
expect(re.test('$item.foo')).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('$item: does not match my$item, $items, item$s, aitem, xitem', () => {
|
|
53
|
+
const re = identifierPattern('$item')
|
|
54
|
+
expect(re.test('my$item')).toBe(false)
|
|
55
|
+
expect(re.test('$items')).toBe(false)
|
|
56
|
+
expect(re.test('item$s')).toBe(false)
|
|
57
|
+
expect(re.test('aitem')).toBe(false)
|
|
58
|
+
expect(re.test('xitem')).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('item (plain, no $) still matches only standalone references', () => {
|
|
62
|
+
const re = identifierPattern('item')
|
|
63
|
+
expect(re.test('(item)')).toBe(true)
|
|
64
|
+
expect(re.test('x + item')).toBe(true)
|
|
65
|
+
expect(re.test('item.foo')).toBe(true)
|
|
66
|
+
expect(re.test('my$item')).toBe(false)
|
|
67
|
+
expect(re.test('$items')).toBe(false)
|
|
68
|
+
expect(re.test('item$s')).toBe(false)
|
|
69
|
+
expect(re.test('aitem')).toBe(false)
|
|
70
|
+
expect(re.test('xitem')).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('the `g` flag supports substitution scanning across multiple matches', () => {
|
|
74
|
+
const re = identifierPattern('$x', 'g')
|
|
75
|
+
expect('$x + $x'.replace(re, () => 'Y')).toBe('Y + Y')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('regex-metacharacter identifiers are escaped, not interpreted', () => {
|
|
79
|
+
// Not a realistic JS identifier, but guards the escape step directly:
|
|
80
|
+
// an unescaped '.' would match any character.
|
|
81
|
+
const re = identifierPattern('a.b')
|
|
82
|
+
expect(re.test('a.b')).toBe(true)
|
|
83
|
+
expect(re.test('axb')).toBe(false)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('identifierCallPattern (#2592)', () => {
|
|
88
|
+
test('matches call syntax for a $-prefixed getter, with or without whitespace', () => {
|
|
89
|
+
expect(identifierCallPattern('$count').test('$count()')).toBe(true)
|
|
90
|
+
expect(identifierCallPattern('$count').test('$count ()')).toBe(true)
|
|
91
|
+
expect(identifierCallPattern('$count').test('1 + $count()')).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('does not match a bare (non-call) reference', () => {
|
|
95
|
+
expect(identifierCallPattern('$count').test('$count')).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('does not match when the name is a substring of a longer call', () => {
|
|
99
|
+
expect(identifierCallPattern('$count').test('my$count()')).toBe(false)
|
|
100
|
+
expect(identifierCallPattern('$count').test('$counter()')).toBe(false)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('plain (non-$) getter names keep matching call syntax as before', () => {
|
|
104
|
+
expect(identifierCallPattern('count').test('count()')).toBe(true)
|
|
105
|
+
expect(identifierCallPattern('count').test('acount()')).toBe(false)
|
|
106
|
+
expect(identifierCallPattern('count').test('counter()')).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// -----------------------------------------------------------------------------
|
|
111
|
+
// End-to-end: a `.map()` callback whose param is `$item` must classify
|
|
112
|
+
// identically to one named `item` — same slotId allocation, same
|
|
113
|
+
// `$item() ` accessor-wrapping in the emitted client JS. Before the fix,
|
|
114
|
+
// `referencesLoopParam` / `wrapLoopParamAsAccessor`'s `\b$item\b` pattern
|
|
115
|
+
// never matched, so the loop body silently lost slotId/reactive
|
|
116
|
+
// classification for the `$item`-named param (wrong-but-silent: `bun test`
|
|
117
|
+
// still passed because nothing asserted the accessor wrap for a `$`-param
|
|
118
|
+
// until now). Verified red-without/green-with: reverting `identifier-
|
|
119
|
+
// pattern.ts` to a plain `` new RegExp(`\\b${name}\\b`) ``-style
|
|
120
|
+
// implementation fails this describe block while leaving the plain-`item`
|
|
121
|
+
// sibling test green — see PR description for the local repro.
|
|
122
|
+
// -----------------------------------------------------------------------------
|
|
123
|
+
describe('$-prefixed loop param compiles identically to a plain-named one (#2592)', () => {
|
|
124
|
+
const adapter = new TestAdapter()
|
|
125
|
+
|
|
126
|
+
function compileMapBody(param: string): string {
|
|
127
|
+
const source = `
|
|
128
|
+
'use client'
|
|
129
|
+
import { createSignal } from '@barefootjs/client'
|
|
130
|
+
type Row = { id: number; label: string }
|
|
131
|
+
export function List() {
|
|
132
|
+
const [rows] = createSignal<Row[]>([])
|
|
133
|
+
return (
|
|
134
|
+
<ul>
|
|
135
|
+
{rows().map((${param}) => (
|
|
136
|
+
<Card key={${param}.id}>
|
|
137
|
+
<CardHeader>{${param}.label}</CardHeader>
|
|
138
|
+
</Card>
|
|
139
|
+
))}
|
|
140
|
+
</ul>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
`
|
|
144
|
+
const result = compileJSX(source, 'List.tsx', { adapter })
|
|
145
|
+
const errors = result.errors.filter(e => e.severity === 'error')
|
|
146
|
+
if (errors.length > 0) throw new Error(errors.map(e => e.message).join('\n'))
|
|
147
|
+
return result.files.find(f => f.type === 'clientJs')!.content
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
test('a plain `item` param is wrapped as an accessor in the child component prop', () => {
|
|
151
|
+
const js = compileMapBody('item')
|
|
152
|
+
expect(js).toContain('item().label')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('a `$item` param is wrapped as an accessor identically (was previously left bare)', () => {
|
|
156
|
+
const js = compileMapBody('$item')
|
|
157
|
+
expect(js).toContain('$item().label')
|
|
158
|
+
expect(js).toContain('$item().id')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('flags handling', () => {
|
|
163
|
+
test("passing flags that already include 'u' does not throw (no duplicate flag)", () => {
|
|
164
|
+
const p = identifierPattern('item', 'gu')
|
|
165
|
+
expect(p.flags).toBe('gu')
|
|
166
|
+
const c = identifierCallPattern('item', 'u')
|
|
167
|
+
expect(c.flags).toBe('u')
|
|
168
|
+
expect('a item b item'.replace(identifierPattern('item', 'gu'), 'x')).toBe('a x b x')
|
|
169
|
+
})
|
|
170
|
+
})
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for #2589: a `let` declaration's explicit type
|
|
3
|
+
* annotation was dropped in emitted `.tsx` SSR templates —
|
|
4
|
+
* `let x: HTMLTextAreaElement | null = null` emitted as `let x = null`,
|
|
5
|
+
* which TypeScript then infers as `null`/`never`, producing
|
|
6
|
+
* TS7034/TS7005 (and TS2339 via `never` narrowing) under strict mode.
|
|
7
|
+
* Runtime output (client JS) was always correct — this is a type-level
|
|
8
|
+
* emission defect in the SSR template only.
|
|
9
|
+
*
|
|
10
|
+
* `ConstantInfo.typeAnnotation` (verbatim `node.type.getText()`, only
|
|
11
|
+
* present when the author wrote an explicit annotation) is now threaded
|
|
12
|
+
* through and printed by the `HonoAdapter` (`JsxAdapter` base) for `let`
|
|
13
|
+
* declarations, both function-scope and module-scope, initialized and
|
|
14
|
+
* uninitialized. `const` declarations are deliberately left unchanged:
|
|
15
|
+
* their type always infers correctly from the (immutable) initializer,
|
|
16
|
+
* so emitting an annotation there would only churn output for no
|
|
17
|
+
* typecheck gain (see the design note in the class docstring / #2589).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, test, expect } from 'bun:test'
|
|
21
|
+
import { compileJSX } from '../compiler'
|
|
22
|
+
import { HonoAdapter } from '../../../../packages/adapter-hono/src/adapter/hono-adapter'
|
|
23
|
+
|
|
24
|
+
describe('let type annotation preservation in emitted templates (#2589)', () => {
|
|
25
|
+
test('function-scope initialized let keeps its explicit type annotation', () => {
|
|
26
|
+
const honoAdapter = new HonoAdapter()
|
|
27
|
+
// `status()` is called directly from the returned JSX (unlike an
|
|
28
|
+
// `onXxx` event-handler prop, which the SSR template stubs to a
|
|
29
|
+
// no-op), so its body — and transitively `textareaEl`, which the
|
|
30
|
+
// effect-guard shape (`syncScroll`, mirroring the issue) also reads —
|
|
31
|
+
// stays reachable and survives into the emitted template.
|
|
32
|
+
const source = `
|
|
33
|
+
'use client'
|
|
34
|
+
import { createSignal, createEffect } from '@barefootjs/client'
|
|
35
|
+
|
|
36
|
+
export function Textarea() {
|
|
37
|
+
let textareaEl: HTMLTextAreaElement | null = null
|
|
38
|
+
const [value, setValue] = createSignal('')
|
|
39
|
+
|
|
40
|
+
const syncScroll = () => {
|
|
41
|
+
if (textareaEl) {
|
|
42
|
+
textareaEl.scrollTop = textareaEl.scrollHeight
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const status = () => (textareaEl ? 'ready' : 'idle')
|
|
47
|
+
|
|
48
|
+
createEffect(() => {
|
|
49
|
+
value()
|
|
50
|
+
syncScroll()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
return <div>{status()}</div>
|
|
54
|
+
}
|
|
55
|
+
`
|
|
56
|
+
|
|
57
|
+
const result = compileJSX(source, 'Textarea.tsx', { adapter: honoAdapter })
|
|
58
|
+
expect(result.errors).toHaveLength(0)
|
|
59
|
+
|
|
60
|
+
const template = result.files.find((f) => f.type === 'markedTemplate')!
|
|
61
|
+
expect(template).toBeDefined()
|
|
62
|
+
expect(template.content).toContain('let textareaEl: HTMLTextAreaElement | null = null')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('module-scope initialized let keeps its explicit type annotation', () => {
|
|
66
|
+
const honoAdapter = new HonoAdapter()
|
|
67
|
+
const source = `
|
|
68
|
+
'use client'
|
|
69
|
+
import { createSignal } from '@barefootjs/client'
|
|
70
|
+
|
|
71
|
+
let exportModulePromise: Promise<{ x: number }> | null = null
|
|
72
|
+
|
|
73
|
+
export function Loader() {
|
|
74
|
+
const [status, setStatus] = createSignal('idle')
|
|
75
|
+
|
|
76
|
+
const load = () => {
|
|
77
|
+
exportModulePromise = Promise.resolve({ x: 1 })
|
|
78
|
+
setStatus('loaded')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return <button onClick={load}>{status()}</button>
|
|
82
|
+
}
|
|
83
|
+
`
|
|
84
|
+
|
|
85
|
+
const result = compileJSX(source, 'Loader.tsx', { adapter: honoAdapter })
|
|
86
|
+
expect(result.errors).toHaveLength(0)
|
|
87
|
+
|
|
88
|
+
const template = result.files.find((f) => f.type === 'markedTemplate')!
|
|
89
|
+
expect(template).toBeDefined()
|
|
90
|
+
expect(template.content).toContain(
|
|
91
|
+
'let exportModulePromise: Promise<{ x: number }> | null = null',
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('module-scope uninitialized let keeps its explicit type annotation', () => {
|
|
96
|
+
const honoAdapter = new HonoAdapter()
|
|
97
|
+
const source = `
|
|
98
|
+
'use client'
|
|
99
|
+
import { createSignal } from '@barefootjs/client'
|
|
100
|
+
|
|
101
|
+
let pending: number
|
|
102
|
+
|
|
103
|
+
export function Counter() {
|
|
104
|
+
const [count, setCount] = createSignal(0)
|
|
105
|
+
|
|
106
|
+
const bump = () => {
|
|
107
|
+
pending = count() + 1
|
|
108
|
+
setCount(pending)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return <button onClick={bump}>{count()}</button>
|
|
112
|
+
}
|
|
113
|
+
`
|
|
114
|
+
|
|
115
|
+
const result = compileJSX(source, 'Counter.tsx', { adapter: honoAdapter })
|
|
116
|
+
expect(result.errors).toHaveLength(0)
|
|
117
|
+
|
|
118
|
+
const template = result.files.find((f) => f.type === 'markedTemplate')!
|
|
119
|
+
expect(template).toBeDefined()
|
|
120
|
+
expect(template.content).toContain('let pending: number')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('unannotated let does NOT gain an inferred type annotation', () => {
|
|
124
|
+
const honoAdapter = new HonoAdapter()
|
|
125
|
+
const source = `
|
|
126
|
+
'use client'
|
|
127
|
+
import { createSignal } from '@barefootjs/client'
|
|
128
|
+
|
|
129
|
+
export function Toggle() {
|
|
130
|
+
let y = null
|
|
131
|
+
const [open, setOpen] = createSignal(false)
|
|
132
|
+
|
|
133
|
+
const flip = () => {
|
|
134
|
+
y = open() ? 1 : null
|
|
135
|
+
setOpen(!open())
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return <button onClick={flip}>{open() ? 'on' : 'off'}{String(y)}</button>
|
|
139
|
+
}
|
|
140
|
+
`
|
|
141
|
+
|
|
142
|
+
const result = compileJSX(source, 'Toggle.tsx', { adapter: honoAdapter })
|
|
143
|
+
expect(result.errors).toHaveLength(0)
|
|
144
|
+
|
|
145
|
+
const template = result.files.find((f) => f.type === 'markedTemplate')!
|
|
146
|
+
expect(template).toBeDefined()
|
|
147
|
+
// No annotation must be synthesized from inference — only an
|
|
148
|
+
// explicit source annotation is ever printed (`typeAnnotation`,
|
|
149
|
+
// never `type` for an initialized declaration).
|
|
150
|
+
expect(template.content).toContain('let y = null')
|
|
151
|
+
expect(template.content).not.toMatch(/let y\s*:/)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('const with an explicit annotation is unchanged (no annotation added at emit)', () => {
|
|
155
|
+
const honoAdapter = new HonoAdapter()
|
|
156
|
+
const source = `
|
|
157
|
+
'use client'
|
|
158
|
+
import { createSignal } from '@barefootjs/client'
|
|
159
|
+
|
|
160
|
+
export function Labelled() {
|
|
161
|
+
const label: string = 'hello'
|
|
162
|
+
const [count, setCount] = createSignal(0)
|
|
163
|
+
return <button onClick={() => setCount(count() + 1)}>{label}{count()}</button>
|
|
164
|
+
}
|
|
165
|
+
`
|
|
166
|
+
|
|
167
|
+
const result = compileJSX(source, 'Labelled.tsx', { adapter: honoAdapter })
|
|
168
|
+
expect(result.errors).toHaveLength(0)
|
|
169
|
+
|
|
170
|
+
const template = result.files.find((f) => f.type === 'markedTemplate')!
|
|
171
|
+
expect(template).toBeDefined()
|
|
172
|
+
// By design (#2589 scoping decision) only `let` gets its annotation
|
|
173
|
+
// re-emitted — `const` infers correctly from its initializer already,
|
|
174
|
+
// so this stays `const label = 'hello'` with no annotation added.
|
|
175
|
+
expect(template.content).toContain("const label = 'hello'")
|
|
176
|
+
expect(template.content).not.toContain('const label: string')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('ambient `declare let` is not re-emitted as a runtime binding', () => {
|
|
180
|
+
const honoAdapter = new HonoAdapter()
|
|
181
|
+
// `declare let` is a type-only contract: it has no initializer and
|
|
182
|
+
// carries NodeFlags.Let, so after the uninitialized-`let` collection
|
|
183
|
+
// fix it would match the module-scope collector unless ambient
|
|
184
|
+
// statements are excluded. Re-emitting it as a runtime `let` would
|
|
185
|
+
// shadow the real global with `undefined` in the SSR module.
|
|
186
|
+
const source = `
|
|
187
|
+
'use client'
|
|
188
|
+
import { createSignal } from '@barefootjs/client'
|
|
189
|
+
|
|
190
|
+
declare let __BF_AMBIENT__: string
|
|
191
|
+
|
|
192
|
+
export function Widget() {
|
|
193
|
+
const [n, setN] = createSignal(0)
|
|
194
|
+
const status = () => (__BF_AMBIENT__ ? 'set' : 'unset')
|
|
195
|
+
return <button onClick={() => setN(n() + 1)}>{status()}{n()}</button>
|
|
196
|
+
}
|
|
197
|
+
`
|
|
198
|
+
|
|
199
|
+
const result = compileJSX(source, 'Widget.tsx', { adapter: honoAdapter })
|
|
200
|
+
expect(result.errors).toHaveLength(0)
|
|
201
|
+
|
|
202
|
+
const template = result.files.find((f) => f.type === 'markedTemplate')!
|
|
203
|
+
expect(template).toBeDefined()
|
|
204
|
+
// The reference inside `status` may survive, but no runtime `let`
|
|
205
|
+
// declaration for the ambient name may be emitted.
|
|
206
|
+
expect(template.content).not.toMatch(/^\s*let __BF_AMBIENT__/m)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2482 Stage 1b — `prop-handling.ts`'s `expandConstantForReactivity` /
|
|
3
|
+
* `expandDynamicPropValue` run on `ClientJsContext`, a per-component object
|
|
4
|
+
* with no loop-scope field at all. Both do a flat
|
|
5
|
+
* `ctx.localConstants.find((c) => c.name === trimmedValue)` lookup with no
|
|
6
|
+
* awareness that the expression being expanded might be sitting inside a
|
|
7
|
+
* `.map()` row whose OWN item/index/destructured/preamble binding shadows
|
|
8
|
+
* that same-named module/component-level const — the lookup resolves to
|
|
9
|
+
* the OUTER const's value regardless.
|
|
10
|
+
*
|
|
11
|
+
* This test pins the reachable, currently-shipping instance: a per-item
|
|
12
|
+
* reactive DOM attribute (forced via `/* @client *\/`, so it bypasses the
|
|
13
|
+
* loop-param-only reactivity classifier and always reaches
|
|
14
|
+
* `collectLoopChildReactiveAttrs` → `expandConstantForReactivity`) whose
|
|
15
|
+
* value is a bare reference to the loop's OWN item parameter, which
|
|
16
|
+
* shares a name with a module-level const. Before the fix, the per-item
|
|
17
|
+
* `createEffect`-equivalent (`applyItem`/`createRow` in the lazy-row
|
|
18
|
+
* runtime) baked in the outer const's fixed literal for every row instead
|
|
19
|
+
* of reading the row's own item value — every row rendered the SAME
|
|
20
|
+
* `data-label`, and it never diverged from that one baked value.
|
|
21
|
+
*
|
|
22
|
+
* Modeled on `csr-template-loop-shadowing.test.ts` (the sibling shadowing
|
|
23
|
+
* fix for the CSR *template* lambda, #2222) — this is the analogous fix
|
|
24
|
+
* for the per-item *reactive attribute effect* body, a different codegen
|
|
25
|
+
* path (`reactivity.ts`'s `collectLoopChildReactiveAttrs`, not
|
|
26
|
+
* `html-template.ts`).
|
|
27
|
+
*
|
|
28
|
+
* A second describe block below pins the INDEX-param shadowing case
|
|
29
|
+
* (Copilot review on PR #2595): `buildLoopRowScope` initially omitted
|
|
30
|
+
* the loop's second callback param (`.map((item, i) => ...)`'s `i`) from
|
|
31
|
+
* the `BindingScope` it builds, so an `i`-shadows-a-module-const attr
|
|
32
|
+
* const-folded the outer value exactly like the item-param case above,
|
|
33
|
+
* empirically confirmed reachable through the same
|
|
34
|
+
* `collectLoopChildReactiveAttrs` → `expandConstantForReactivity` path.
|
|
35
|
+
* Fixed by threading the IR loop's `index` field through
|
|
36
|
+
* `collectLoopChildBindings` / `collectLoopChildConditionals` /
|
|
37
|
+
* `summarizeLoopChildBranch` / `collectLoopChildReactiveAttrs` /
|
|
38
|
+
* `collectLoopChildReactiveTexts` into `buildLoopRowScope`.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { describe, test, expect } from 'bun:test'
|
|
42
|
+
import { compileJSX } from '../compiler'
|
|
43
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
44
|
+
|
|
45
|
+
const adapter = new TestAdapter()
|
|
46
|
+
|
|
47
|
+
function clientJsFor(source: string): string {
|
|
48
|
+
const result = compileJSX(source, 'Repro.tsx', { adapter })
|
|
49
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
50
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
51
|
+
expect(clientJs).toBeDefined()
|
|
52
|
+
return clientJs!.content
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('loop-child reactive attr vs a loop-param shadowing a module const (#2482)', () => {
|
|
56
|
+
test('a /* @client */ attr reading the shadowing item param reads the row value, not the outer const', () => {
|
|
57
|
+
const js = clientJsFor(`
|
|
58
|
+
'use client'
|
|
59
|
+
import { createSignal } from '@barefootjs/client'
|
|
60
|
+
export function Widget({ items }: { items: string[] }) {
|
|
61
|
+
const label = 'MODULE_CONST'
|
|
62
|
+
const [n, setN] = createSignal(0)
|
|
63
|
+
return (
|
|
64
|
+
<div onClick={() => setN(n() + 1)}>
|
|
65
|
+
<ul>
|
|
66
|
+
{items.map((label) => (
|
|
67
|
+
<li key={label} data-label={/* @client */ label}>{n()}</li>
|
|
68
|
+
))}
|
|
69
|
+
</ul>
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
`)
|
|
74
|
+
|
|
75
|
+
// The per-item attribute effect must read the row's OWN item
|
|
76
|
+
// accessor — never the outer module const's baked literal.
|
|
77
|
+
expect(js).toContain('const __x = label()')
|
|
78
|
+
expect(js).not.toContain("const __x = 'MODULE_CONST'")
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('loop-child reactive attr vs a loop INDEX param shadowing a module const (#2482 / #2595)', () => {
|
|
83
|
+
test('a /* @client */ attr reading the shadowing index param reads the row index, not the outer const', () => {
|
|
84
|
+
const js = clientJsFor(`
|
|
85
|
+
'use client'
|
|
86
|
+
import { createSignal } from '@barefootjs/client'
|
|
87
|
+
export function Widget({ items }: { items: string[] }) {
|
|
88
|
+
const i = 'MODULE_CONST'
|
|
89
|
+
const [n, setN] = createSignal(0)
|
|
90
|
+
return (
|
|
91
|
+
<div onClick={() => setN(n() + 1)}>
|
|
92
|
+
<ul>
|
|
93
|
+
{items.map((item, i) => (
|
|
94
|
+
<li key={item} data-idx={/* @client */ i}>{n()}</li>
|
|
95
|
+
))}
|
|
96
|
+
</ul>
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
`)
|
|
101
|
+
|
|
102
|
+
// The per-item attribute effect must read the row's OWN index
|
|
103
|
+
// closure variable — never the outer module const's baked literal.
|
|
104
|
+
expect(js).toContain("const __v = i;")
|
|
105
|
+
expect(js).not.toContain("const __v = 'MODULE_CONST';")
|
|
106
|
+
})
|
|
107
|
+
})
|