@barefootjs/jsx 0.31.3 → 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/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 +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +226 -163
- 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/relocate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/binding-scope-ratchet.test.ts +1 -1
- 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__/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 +2 -1
- package/src/adapters/template-imports.ts +93 -0
- package/src/debug.ts +4 -3
- package/src/identifier-pattern.ts +79 -0
- package/src/index.ts +1 -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 +18 -9
- package/src/module-exports.ts +3 -2
- package/src/relocate.ts +2 -1
|
@@ -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,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
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit coverage for `rewriteDynamicImportsInSource` (#2588) — the source-text
|
|
3
|
+
* counterpart to `rewriteImportsForTemplate`.
|
|
4
|
+
*
|
|
5
|
+
* The e2e half lives in `packages/vite/src/__tests__/dynamic-import-rewrite.test.ts`
|
|
6
|
+
* (real `vite build`, real emitted template). This half pins the cases that
|
|
7
|
+
* an e2e fixture can't isolate: which AST nodes count, and the false matches
|
|
8
|
+
* a regex-based implementation would produce. Those false-match cases are
|
|
9
|
+
* the entire reason this parses (CLAUDE.md: never parse JS/TS with regex) —
|
|
10
|
+
* without them, a regex rewrite would pass every other assertion here.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, test, expect } from 'bun:test'
|
|
13
|
+
import { rewriteDynamicImportsInSource } from '../adapters/template-imports.ts'
|
|
14
|
+
|
|
15
|
+
/** Stand-in for `buildRelativeImportRewriter`: shifts one directory deeper. */
|
|
16
|
+
const deeper = (spec: string): string => `../${spec}`
|
|
17
|
+
|
|
18
|
+
describe('rewriteDynamicImportsInSource', () => {
|
|
19
|
+
test('rewrites a dynamic import call', () => {
|
|
20
|
+
expect(rewriteDynamicImportsInSource(`const m = import('./heavy')`, deeper))
|
|
21
|
+
.toBe(`const m = import('.././heavy')`)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('rewrites an import type node (`typeof import(...)`)', () => {
|
|
25
|
+
expect(rewriteDynamicImportsInSource(`let p: Promise<typeof import('../lib/x')> | null = null`, deeper))
|
|
26
|
+
.toBe(`let p: Promise<typeof import('../../lib/x')> | null = null`)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('rewrites a qualified import type (`import(...).Foo`)', () => {
|
|
30
|
+
expect(rewriteDynamicImportsInSource(`let v: import('../lib/x').Foo`, deeper))
|
|
31
|
+
.toBe(`let v: import('../../lib/x').Foo`)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('rewrites every occurrence, keeping earlier spans intact', () => {
|
|
35
|
+
const out = rewriteDynamicImportsInSource(
|
|
36
|
+
`const a = import('./one'); const b = import('./two'); const c = import('./three')`,
|
|
37
|
+
deeper,
|
|
38
|
+
)
|
|
39
|
+
expect(out).toBe(
|
|
40
|
+
`const a = import('.././one'); const b = import('.././two'); const c = import('.././three')`,
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('leaves bare specifiers alone', () => {
|
|
45
|
+
const src = `const m = import('hono/jsx')`
|
|
46
|
+
expect(rewriteDynamicImportsInSource(src, deeper)).toBe(src)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('leaves a non-literal specifier alone', () => {
|
|
50
|
+
const src = `const m = import(chunkPath)`
|
|
51
|
+
expect(rewriteDynamicImportsInSource(src, deeper)).toBe(src)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('leaves static import statements to rewriteImportsForTemplate', () => {
|
|
55
|
+
// The adapter rewrites those from the parsed `templateImports` list; if
|
|
56
|
+
// this touched them too they would be rewritten twice.
|
|
57
|
+
const src = `import { x } from './sibling'`
|
|
58
|
+
expect(rewriteDynamicImportsInSource(src, deeper)).toBe(src)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('does not touch `import(` inside a string literal', () => {
|
|
62
|
+
const src = `const code = "const m = import('./heavy')"`
|
|
63
|
+
expect(rewriteDynamicImportsInSource(src, deeper)).toBe(src)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('does not touch `import(` inside a template literal', () => {
|
|
67
|
+
const src = 'const code = `await import(\'./heavy\')`'
|
|
68
|
+
expect(rewriteDynamicImportsInSource(src, deeper)).toBe(src)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('does not touch `import(` inside a comment', () => {
|
|
72
|
+
const src = `// const m = import('./heavy')\nconst n = 1`
|
|
73
|
+
expect(rewriteDynamicImportsInSource(src, deeper)).toBe(src)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('rewrites inside a TSX component body without disturbing the JSX', () => {
|
|
77
|
+
const src = [
|
|
78
|
+
`export function Lazy() {`,
|
|
79
|
+
` const onClick = async () => { await import('./heavy') }`,
|
|
80
|
+
` return <button onClick={onClick} data-x="import('./nope')">go</button>`,
|
|
81
|
+
`}`,
|
|
82
|
+
].join('\n')
|
|
83
|
+
const out = rewriteDynamicImportsInSource(src, deeper)
|
|
84
|
+
expect(out).toContain(`await import('.././heavy')`)
|
|
85
|
+
// The attribute string is data, not a module reference.
|
|
86
|
+
expect(out).toContain(`data-x="import('./nope')"`)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('returns the input unchanged when the rewriter is a no-op', () => {
|
|
90
|
+
const src = `const m = import('./heavy')`
|
|
91
|
+
expect(rewriteDynamicImportsInSource(src, (s) => s)).toBe(src)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('returns the input unchanged when there is nothing to rewrite', () => {
|
|
95
|
+
const src = `export const answer = 42`
|
|
96
|
+
expect(rewriteDynamicImportsInSource(src, deeper)).toBe(src)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -19,6 +19,7 @@ import type { CallbackBodyAcceptor } from './interface.ts'
|
|
|
19
19
|
import { ENV_SIGNAL_CLIENT_FACTORY } from './env-signal.ts'
|
|
20
20
|
import { formatParamWithType, findReachableNames } from '../module-exports.ts'
|
|
21
21
|
import { extractFreeIdentifiersFromText } from '../ir-to-client-js/csr-substitute.ts'
|
|
22
|
+
import { identifierPattern } from '../identifier-pattern.ts'
|
|
22
23
|
|
|
23
24
|
export interface JsxAdapterConfig {
|
|
24
25
|
/** Use typed versions (typedInitialValue, etc.) for type-safe .tsx output */
|
|
@@ -165,7 +166,7 @@ export abstract class JsxAdapter extends BaseAdapter {
|
|
|
165
166
|
|
|
166
167
|
// Create a no-op setter for SSR — omit entirely if not referenced anywhere
|
|
167
168
|
if (signal.setter) {
|
|
168
|
-
const setterUsed =
|
|
169
|
+
const setterUsed = identifierPattern(signal.setter).test(setterRefText)
|
|
169
170
|
if (setterUsed) {
|
|
170
171
|
lines.push(` const ${signal.setter} = (..._args: any[]) => {}`)
|
|
171
172
|
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* Adapters are responsible for calling this themselves before emitting any
|
|
17
17
|
* import block. The compiler hands them `metadata.imports` unchanged.
|
|
18
18
|
*/
|
|
19
|
+
import ts from 'typescript'
|
|
19
20
|
import type { ImportInfo, ImportSpecifier } from '../types.ts'
|
|
20
21
|
|
|
21
22
|
const CLIENT_PACKAGE_SOURCES = new Set([
|
|
@@ -77,3 +78,95 @@ export function rewriteImportsForTemplate(
|
|
|
77
78
|
function specKey(s: ImportSpecifier): string {
|
|
78
79
|
return `${s.isDefault ? 'd' : ''}${s.isNamespace ? 'n' : ''}:${s.name}:${s.alias ?? ''}`
|
|
79
80
|
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Re-anchor relative specifiers carried inside emitted SOURCE TEXT — the
|
|
84
|
+
* counterpart to `rewriteImportsForTemplate`, which only sees the parsed
|
|
85
|
+
* static import list (`metadata.templateImports`).
|
|
86
|
+
*
|
|
87
|
+
* Declaration bodies re-emitted verbatim into a template
|
|
88
|
+
* (`generateModuleScopeDeclarations`' consts/functions, a component body's
|
|
89
|
+
* local handlers) can carry their own module references that never appear
|
|
90
|
+
* in that list:
|
|
91
|
+
*
|
|
92
|
+
* - `import('./x')` — a dynamic import expression
|
|
93
|
+
* - `typeof import('./x')` — an import TYPE node
|
|
94
|
+
*
|
|
95
|
+
* Those specifiers are written relative to the SOURCE file, so they break
|
|
96
|
+
* once the template is emitted to a directory at a different depth — the
|
|
97
|
+
* same depth shift `rewriteImportsForTemplate` already fixes for static
|
|
98
|
+
* imports (#1453, #2588).
|
|
99
|
+
*
|
|
100
|
+
* Only literal relative paths beginning with `.` are rewritten; bare
|
|
101
|
+
* specifiers pass through, matching `remap`'s guard above. A non-literal
|
|
102
|
+
* argument (`import(someVar)`) is left alone — there is no specifier to
|
|
103
|
+
* re-anchor, and guessing would be worse than leaving the source as-is.
|
|
104
|
+
*
|
|
105
|
+
* Parsed with the TS AST and applied by span splicing rather than by
|
|
106
|
+
* matching text: a regex would false-match `import(` inside a string or a
|
|
107
|
+
* comment, which is exactly the class of bug the repo-wide "never parse JS
|
|
108
|
+
* with regex" rule exists to prevent. Splices are applied back-to-front so
|
|
109
|
+
* earlier spans keep their offsets.
|
|
110
|
+
*/
|
|
111
|
+
export function rewriteDynamicImportsInSource(
|
|
112
|
+
sourceText: string,
|
|
113
|
+
rewriteRelative: (importPath: string) => string,
|
|
114
|
+
): string {
|
|
115
|
+
// Cheap pre-check: skip the parse entirely for the overwhelmingly common
|
|
116
|
+
// case of text with no dynamic import at all. Substring presence is not
|
|
117
|
+
// used to LOCATE anything — the AST still does that — so a false positive
|
|
118
|
+
// here costs one wasted parse and a false negative is impossible.
|
|
119
|
+
if (!sourceText.includes('import')) return sourceText
|
|
120
|
+
|
|
121
|
+
const sf = ts.createSourceFile(
|
|
122
|
+
'bf-template-fragment.tsx',
|
|
123
|
+
sourceText,
|
|
124
|
+
ts.ScriptTarget.Latest,
|
|
125
|
+
/* setParentNodes */ false,
|
|
126
|
+
ts.ScriptKind.TSX,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const edits: Array<{ start: number, end: number, text: string }> = []
|
|
130
|
+
|
|
131
|
+
const visit = (node: ts.Node): void => {
|
|
132
|
+
// `import('./x')` — the argument is the first (and only) call argument.
|
|
133
|
+
if (
|
|
134
|
+
ts.isCallExpression(node) &&
|
|
135
|
+
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
136
|
+
node.arguments.length > 0 &&
|
|
137
|
+
ts.isStringLiteralLike(node.arguments[0])
|
|
138
|
+
) {
|
|
139
|
+
collect(node.arguments[0] as ts.StringLiteralLike)
|
|
140
|
+
}
|
|
141
|
+
// `typeof import('./x')` / `import('./x').Foo` — a TYPE-position node
|
|
142
|
+
// whose argument is a literal type wrapping the string.
|
|
143
|
+
if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
|
144
|
+
const literal = node.argument.literal
|
|
145
|
+
if (ts.isStringLiteralLike(literal)) collect(literal)
|
|
146
|
+
}
|
|
147
|
+
ts.forEachChild(node, visit)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const collect = (literal: ts.StringLiteralLike): void => {
|
|
151
|
+
const specifier = literal.text
|
|
152
|
+
if (!specifier.startsWith('.')) return
|
|
153
|
+
const next = rewriteRelative(specifier)
|
|
154
|
+
if (next === specifier) return
|
|
155
|
+
edits.push({
|
|
156
|
+
start: literal.getStart(sf),
|
|
157
|
+
end: literal.getEnd(),
|
|
158
|
+
// Re-quote rather than reusing the original delimiters: a rewritten
|
|
159
|
+
// POSIX-relative path never contains a quote to escape.
|
|
160
|
+
text: `'${next}'`,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
ts.forEachChild(sf, visit)
|
|
165
|
+
if (edits.length === 0) return sourceText
|
|
166
|
+
|
|
167
|
+
let out = sourceText
|
|
168
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) {
|
|
169
|
+
out = out.slice(0, edit.start) + edit.text + out.slice(edit.end)
|
|
170
|
+
}
|
|
171
|
+
return out
|
|
172
|
+
}
|
package/src/debug.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { analyzeClientNeeds } from './ir-to-client-js/index.ts'
|
|
|
29
29
|
import type { WrapReason } from './ir-to-client-js/reactivity.ts'
|
|
30
30
|
import { decideWrapFromAstFlags } from './ir-to-client-js/reactivity.ts'
|
|
31
31
|
import { tokenContainsIdent } from './ir-to-client-js/utils.ts'
|
|
32
|
+
import { identifierCallPattern } from './identifier-pattern.ts'
|
|
32
33
|
|
|
33
34
|
// =============================================================================
|
|
34
35
|
// Types
|
|
@@ -1990,12 +1991,12 @@ function attrValueToString(value: AttrValue): string | null {
|
|
|
1990
1991
|
function extractReactiveDeps(expr: string, signalGetters: Set<string>, memoNames: Set<string>): string[] {
|
|
1991
1992
|
const deps: string[] = []
|
|
1992
1993
|
for (const getter of signalGetters) {
|
|
1993
|
-
if (
|
|
1994
|
+
if (identifierCallPattern(getter).test(expr)) {
|
|
1994
1995
|
deps.push(getter)
|
|
1995
1996
|
}
|
|
1996
1997
|
}
|
|
1997
1998
|
for (const memo of memoNames) {
|
|
1998
|
-
if (
|
|
1999
|
+
if (identifierCallPattern(memo).test(expr)) {
|
|
1999
2000
|
deps.push(memo)
|
|
2000
2001
|
}
|
|
2001
2002
|
}
|
|
@@ -2012,7 +2013,7 @@ function extractSetterRefs(expr: string, signalGetters: Set<string>): string[] {
|
|
|
2012
2013
|
}
|
|
2013
2014
|
// Also detect signal getter reads in handler
|
|
2014
2015
|
for (const getter of signalGetters) {
|
|
2015
|
-
if (
|
|
2016
|
+
if (identifierCallPattern(getter).test(expr)) {
|
|
2016
2017
|
refs.push(getter)
|
|
2017
2018
|
}
|
|
2018
2019
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single door for building "does this expression text reference identifier
|
|
3
|
+
* X?" regexes (#2592).
|
|
4
|
+
*
|
|
5
|
+
* The naive `new RegExp(`\\b${name}\\b`)` idiom used throughout the compiler
|
|
6
|
+
* breaks for `$`-containing identifiers (legal in JS: `$item`, `item$`,
|
|
7
|
+
* `a$b`) in two independent ways:
|
|
8
|
+
*
|
|
9
|
+
* 1. Unescaped: an interpolated `$` is a regex metacharacter (end-of-
|
|
10
|
+
* input/line anchor), so `\b$item\b` / `\ba$b\b` can (almost) never
|
|
11
|
+
* match mid-string — false negative.
|
|
12
|
+
* 2. Even escaped, `\b` requires a `\w`/non-`\w` transition and `$` is
|
|
13
|
+
* not `\w` (`[A-Za-z0-9_]`) — so `\b\$item\b` still fails to match the
|
|
14
|
+
* leading boundary in `($item)` (both `(` and `$` are non-word, so no
|
|
15
|
+
* transition occurs there).
|
|
16
|
+
*
|
|
17
|
+
* `identifierPattern` / `identifierCallPattern` fix both: the identifier
|
|
18
|
+
* text is escaped before interpolation, and the boundary is asserted with
|
|
19
|
+
* lookaround against `\p{ID_Continue}` (Unicode "can continue an
|
|
20
|
+
* identifier") unioned with `$`, so `$` is correctly treated as
|
|
21
|
+
* identifier-like on both sides of the match.
|
|
22
|
+
*
|
|
23
|
+
* Scope: these remain the same *bounded lexical heuristic* the compiler has
|
|
24
|
+
* always used for expression-text scanning (not a general JS/TS parse —
|
|
25
|
+
* see CLAUDE.md's structural-parsing rule, which does not apply to this
|
|
26
|
+
* class of check). This module only fixes the `$` boundary bug; it does not
|
|
27
|
+
* change what the heuristic considers a "reference" (string literals,
|
|
28
|
+
* comments, and member-access tails are still opaque to it — callers that
|
|
29
|
+
* need that precision use `tokenContainsIdent` / `node.freeIdentifiers`
|
|
30
|
+
* instead, per #1267).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// Duplicate regex flags throw at construction ('gu' + 'u' -> SyntaxError),
|
|
34
|
+
// so `u` is added only when the caller didn't already pass it.
|
|
35
|
+
function withUnicodeFlag(flags: string): string {
|
|
36
|
+
return flags.includes('u') ? flags : `${flags}u`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Escape regex metacharacters in a literal identifier before interpolation. */
|
|
40
|
+
export function escapeIdentifierForRegex(name: string): string {
|
|
41
|
+
return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Lookaround fragments asserting "not preceded/followed by an identifier
|
|
46
|
+
* continuation character (including `$`)". `$` is a legal identifier char
|
|
47
|
+
* in JS but is not `\p{ID_Continue}`, so it's unioned in explicitly.
|
|
48
|
+
*
|
|
49
|
+
* Exported for the few call sites that must splice extra assertions
|
|
50
|
+
* between the identifier and the trailing boundary (e.g. "not followed by
|
|
51
|
+
* a call" as well as "not followed by an identifier char") — compose with
|
|
52
|
+
* these fragments rather than reintroducing a bare `\b`.
|
|
53
|
+
*/
|
|
54
|
+
export const ID_BOUNDARY_BEFORE = '(?<![\\p{ID_Continue}$])'
|
|
55
|
+
export const ID_BOUNDARY_AFTER = '(?![\\p{ID_Continue}$])'
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Regex matching a standalone reference to identifier `name` — the `$`-safe
|
|
59
|
+
* replacement for `new RegExp(`\\b${name}\\b`)`. Always carries the `u`
|
|
60
|
+
* (unicode) flag, required for `\p{ID_Continue}`; pass additional flags
|
|
61
|
+
* (e.g. `'g'` for `String.replace`/`matchAll` substitution sites) via
|
|
62
|
+
* `flags`.
|
|
63
|
+
*/
|
|
64
|
+
export function identifierPattern(name: string, flags = ''): RegExp {
|
|
65
|
+
const esc = escapeIdentifierForRegex(name)
|
|
66
|
+
return new RegExp(`${ID_BOUNDARY_BEFORE}${esc}${ID_BOUNDARY_AFTER}`, withUnicodeFlag(flags))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Regex matching identifier `name` used in call position (`name(...)`,
|
|
71
|
+
* allowing whitespace before the paren) — the `$`-safe replacement for
|
|
72
|
+
* `new RegExp(`\\b${name}\\s*\\(`)`. No trailing boundary assertion is
|
|
73
|
+
* needed: `\s`/`(` are already not `\p{ID_Continue}`/`$`, so they can't be
|
|
74
|
+
* mistaken for a continuation of `name`.
|
|
75
|
+
*/
|
|
76
|
+
export function identifierCallPattern(name: string, flags = ''): RegExp {
|
|
77
|
+
const esc = escapeIdentifierForRegex(name)
|
|
78
|
+
return new RegExp(`${ID_BOUNDARY_BEFORE}${esc}\\s*\\(`, withUnicodeFlag(flags))
|
|
79
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -89,7 +89,7 @@ export type {
|
|
|
89
89
|
} from './adapters/interface.ts'
|
|
90
90
|
export { JsxAdapter } from './adapters/jsx-adapter.ts'
|
|
91
91
|
export type { JsxAdapterConfig } from './adapters/jsx-adapter.ts'
|
|
92
|
-
export { rewriteImportsForTemplate } from './adapters/template-imports.ts'
|
|
92
|
+
export { rewriteImportsForTemplate, rewriteDynamicImportsInSource } from './adapters/template-imports.ts'
|
|
93
93
|
export { emitParsedExpr, groupBinaryOperand, isStringTypedOperand, isStringConcatBinary } from './adapters/parsed-expr-emitter.ts'
|
|
94
94
|
export type { ParsedExprEmitter, HigherOrderMethod, ArrayMethod, SortMethod, LiteralType } from './adapters/parsed-expr-emitter.ts'
|
|
95
95
|
export { collectLoopBoundNames } from './adapters/loop-bound-names.ts'
|