@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.
Files changed (52) hide show
  1. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  2. package/dist/adapters/template-imports.d.ts +27 -15
  3. package/dist/adapters/template-imports.d.ts.map +1 -1
  4. package/dist/debug.d.ts.map +1 -1
  5. package/dist/identifier-pattern.d.ts +62 -0
  6. package/dist/identifier-pattern.d.ts.map +1 -0
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +226 -163
  10. package/dist/ir-to-client-js/collect-elements.d.ts +23 -2
  11. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/csr-substitute.d.ts +12 -1
  15. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/html-template.d.ts +20 -12
  17. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/prop-handling.d.ts +25 -2
  20. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/reactivity.d.ts +30 -2
  22. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  25. package/dist/jsx-to-ir.d.ts.map +1 -1
  26. package/dist/module-exports.d.ts.map +1 -1
  27. package/dist/relocate.d.ts.map +1 -1
  28. package/package.json +2 -2
  29. package/src/__tests__/binding-scope-ratchet.test.ts +1 -1
  30. package/src/__tests__/csr-materialize-loop-preamble-shadow.test.ts +76 -0
  31. package/src/__tests__/csr-substitute-enclosing-scope.test.ts +77 -0
  32. package/src/__tests__/identifier-pattern.test.ts +170 -0
  33. package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +107 -0
  34. package/src/__tests__/rewrite-dynamic-imports.test.ts +98 -0
  35. package/src/adapters/jsx-adapter.ts +2 -1
  36. package/src/adapters/template-imports.ts +93 -0
  37. package/src/debug.ts +4 -3
  38. package/src/identifier-pattern.ts +79 -0
  39. package/src/index.ts +1 -1
  40. package/src/ir-to-client-js/collect-elements.ts +44 -15
  41. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +2 -1
  42. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +2 -1
  43. package/src/ir-to-client-js/csr-substitute.ts +19 -2
  44. package/src/ir-to-client-js/html-template.ts +37 -31
  45. package/src/ir-to-client-js/imports.ts +2 -1
  46. package/src/ir-to-client-js/prop-handling.ts +28 -1
  47. package/src/ir-to-client-js/reactivity.ts +54 -4
  48. package/src/ir-to-client-js/rewrite-props-object.ts +2 -1
  49. package/src/ir-to-client-js/utils.ts +9 -8
  50. package/src/jsx-to-ir.ts +18 -9
  51. package/src/module-exports.ts +3 -2
  52. 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 = new RegExp(`\\b${signal.setter}\\b`).test(setterRefText)
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 (new RegExp(`\\b${getter}\\s*\\(`).test(expr)) {
1994
+ if (identifierCallPattern(getter).test(expr)) {
1994
1995
  deps.push(getter)
1995
1996
  }
1996
1997
  }
1997
1998
  for (const memo of memoNames) {
1998
- if (new RegExp(`\\b${memo}\\s*\\(`).test(expr)) {
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 (new RegExp(`\\b${getter}\\s*\\(`).test(expr)) {
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'