@pyreon/vite-plugin 0.22.0 → 0.24.0
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/README.md +97 -25
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +388 -22
- package/lib/types/index.d.ts +128 -2
- package/package.json +2 -2
- package/src/index.ts +631 -18
- package/src/tests/cache-eviction-on-delete.test.ts +187 -0
- package/src/tests/dev-server.test.ts +5 -1
- package/src/tests/lpih-auto-bridge.test.ts +408 -0
- package/src/tests/lpih-injection.test.ts +559 -0
- package/src/tests/vite-plugin.test.ts +5 -2
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R4 — build-time LPIH source-location injection.
|
|
3
|
+
*
|
|
4
|
+
* The Vite plugin rewrites `const x = signal(0)` → `const x = signal(0,
|
|
5
|
+
* { name: "x", __sourceLocation: { file, line, col } })` so the runtime
|
|
6
|
+
* skips the `new Error().stack` capture in `_rdRegister` — saves
|
|
7
|
+
* ~2.2µs per signal creation when devtools is active.
|
|
8
|
+
*
|
|
9
|
+
* Bisect-verified: removing the `__sourceLocation` literal from the
|
|
10
|
+
* injection makes the line/col-correctness tests fail with
|
|
11
|
+
* "expected to include __sourceLocation".
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, it } from 'vitest'
|
|
14
|
+
import pyreonPlugin from '../index'
|
|
15
|
+
import {
|
|
16
|
+
_computeLineStarts,
|
|
17
|
+
_maskStringsAndComments,
|
|
18
|
+
_offsetToLineCol,
|
|
19
|
+
} from '../index'
|
|
20
|
+
|
|
21
|
+
type ConfigHook = (
|
|
22
|
+
userConfig: Record<string, unknown>,
|
|
23
|
+
env: { command: string; isSsrBuild?: boolean },
|
|
24
|
+
) => Record<string, unknown>
|
|
25
|
+
|
|
26
|
+
function createPlugin() {
|
|
27
|
+
const plugin = pyreonPlugin()
|
|
28
|
+
// Simulate Vite calling config() so isBuild / projectRoot are set
|
|
29
|
+
;(plugin.config as unknown as ConfigHook)({}, { command: 'serve' })
|
|
30
|
+
return plugin
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function runTransform(code: string, id: string): Promise<{ code: string } | undefined> {
|
|
34
|
+
const plugin = createPlugin()
|
|
35
|
+
const transformHook = plugin.transform as (
|
|
36
|
+
this: {
|
|
37
|
+
warn: (msg: string) => void
|
|
38
|
+
resolve: (id: string, importer?: string, options?: { skipSelf: boolean }) => Promise<{ id: string } | null>
|
|
39
|
+
},
|
|
40
|
+
code: string,
|
|
41
|
+
id: string,
|
|
42
|
+
) => Promise<{ code: string; map: null } | undefined>
|
|
43
|
+
return await transformHook.call(
|
|
44
|
+
{
|
|
45
|
+
warn: () => undefined,
|
|
46
|
+
resolve: async () => null,
|
|
47
|
+
},
|
|
48
|
+
code,
|
|
49
|
+
id,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ctx = { transform: runTransform }
|
|
54
|
+
|
|
55
|
+
// Helper: extract the FIRST `__sourceLocation` literal from transformed code.
|
|
56
|
+
function extractLoc(code: string): { file: string; line: number; col: number } | null {
|
|
57
|
+
const m = code.match(
|
|
58
|
+
/__sourceLocation: \{ file: ("(?:[^"\\]|\\.)*"), line: (\d+), col: (\d+) \}/,
|
|
59
|
+
)
|
|
60
|
+
if (!m) return null
|
|
61
|
+
return {
|
|
62
|
+
file: JSON.parse(m[1] ?? '"?"'),
|
|
63
|
+
line: parseInt(m[2] ?? '0', 10),
|
|
64
|
+
col: parseInt(m[3] ?? '0', 10),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('_computeLineStarts', () => {
|
|
69
|
+
it('starts with offset 0 for line 1', () => {
|
|
70
|
+
expect(_computeLineStarts('hello')).toEqual([0])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('records each newline position', () => {
|
|
74
|
+
const code = 'line1\nline2\nline3'
|
|
75
|
+
expect(_computeLineStarts(code)).toEqual([0, 6, 12])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('handles empty file', () => {
|
|
79
|
+
expect(_computeLineStarts('')).toEqual([0])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('handles trailing newline', () => {
|
|
83
|
+
expect(_computeLineStarts('a\n')).toEqual([0, 2])
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('_offsetToLineCol', () => {
|
|
88
|
+
const starts = _computeLineStarts('line1\nline2\nline3')
|
|
89
|
+
|
|
90
|
+
it('offset 0 → line 1 col 1', () => {
|
|
91
|
+
expect(_offsetToLineCol(0, starts)).toEqual({ line: 1, col: 1 })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('offset 5 → line 1 col 6 (last char of line 1)', () => {
|
|
95
|
+
expect(_offsetToLineCol(5, starts)).toEqual({ line: 1, col: 6 })
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('offset 6 → line 2 col 1 (first char after newline)', () => {
|
|
99
|
+
expect(_offsetToLineCol(6, starts)).toEqual({ line: 2, col: 1 })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('offset 12 → line 3 col 1', () => {
|
|
103
|
+
expect(_offsetToLineCol(12, starts)).toEqual({ line: 3, col: 1 })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('offset at end → last line correct col', () => {
|
|
107
|
+
expect(_offsetToLineCol(16, starts)).toEqual({ line: 3, col: 5 })
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('R4 — injection at function-scope signal() call sites', () => {
|
|
112
|
+
// NOTE: module-scope signals get rewritten to `__hmr_signal(...)` by
|
|
113
|
+
// the HMR pass FIRST. Location injection runs after and skips them
|
|
114
|
+
// (regex matches `signal(` not `__hmr_signal(`). Module-scope signals
|
|
115
|
+
// still pay the runtime stack-capture cost — documented limitation,
|
|
116
|
+
// tracked as a follow-up.
|
|
117
|
+
|
|
118
|
+
it('injects __sourceLocation with the resolved module path', async () => {
|
|
119
|
+
const code = `import { signal } from "@pyreon/reactivity"
|
|
120
|
+
export function Counter() {
|
|
121
|
+
const count = signal(0)
|
|
122
|
+
return count
|
|
123
|
+
}
|
|
124
|
+
`
|
|
125
|
+
const result = await ctx.transform(code, '/abs/path/app.tsx')
|
|
126
|
+
expect(result).toBeDefined()
|
|
127
|
+
expect(result!.code).toContain('__sourceLocation')
|
|
128
|
+
const loc = extractLoc(result!.code)
|
|
129
|
+
expect(loc?.file).toBe('/abs/path/app.tsx')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('injects correct line for signal call inside function', async () => {
|
|
133
|
+
const code = `import { signal } from "@pyreon/reactivity"
|
|
134
|
+
export function Counter() {
|
|
135
|
+
const count = signal(0)
|
|
136
|
+
return count
|
|
137
|
+
}
|
|
138
|
+
`
|
|
139
|
+
const result = await ctx.transform(code, 'app.tsx')
|
|
140
|
+
const loc = extractLoc(result!.code)
|
|
141
|
+
expect(loc?.line).toBe(3) // ` const count = signal(0)` is line 3
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('injects different locations for signals on different lines', async () => {
|
|
145
|
+
const code = `import { signal } from "@pyreon/reactivity"
|
|
146
|
+
export function Multi() {
|
|
147
|
+
const a = signal(0)
|
|
148
|
+
const b = signal(0)
|
|
149
|
+
const c = signal(0)
|
|
150
|
+
return [a, b, c]
|
|
151
|
+
}
|
|
152
|
+
`
|
|
153
|
+
const result = await ctx.transform(code, 'app.tsx')
|
|
154
|
+
const locs = [...result!.code.matchAll(/line: (\d+)/g)].map((m) => parseInt(m[1] ?? '0', 10))
|
|
155
|
+
expect(locs).toContain(3)
|
|
156
|
+
expect(locs).toContain(4)
|
|
157
|
+
expect(locs).toContain(5)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('skips signals that already have an options arg', async () => {
|
|
161
|
+
const code = `import { signal } from "@pyreon/reactivity"
|
|
162
|
+
export function App() {
|
|
163
|
+
const a = signal(0, { name: "custom" })
|
|
164
|
+
return a
|
|
165
|
+
}
|
|
166
|
+
`
|
|
167
|
+
const result = await ctx.transform(code, 'app.tsx')
|
|
168
|
+
expect(result!.code).not.toContain('__sourceLocation')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('does not inject for non-signal calls', async () => {
|
|
172
|
+
const code = `export function App() {
|
|
173
|
+
const a = doSomething(0)
|
|
174
|
+
return a
|
|
175
|
+
}
|
|
176
|
+
`
|
|
177
|
+
const result = await ctx.transform(code, 'app.tsx')
|
|
178
|
+
expect(result!.code).not.toContain('__sourceLocation')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('handles multiline arg in signal() correctly', async () => {
|
|
182
|
+
const code = `import { signal } from "@pyreon/reactivity"
|
|
183
|
+
export function App() {
|
|
184
|
+
const obj = signal({
|
|
185
|
+
a: 1,
|
|
186
|
+
b: 2,
|
|
187
|
+
})
|
|
188
|
+
return obj
|
|
189
|
+
}
|
|
190
|
+
`
|
|
191
|
+
const result = await ctx.transform(code, 'app.tsx')
|
|
192
|
+
// Location anchors at the OPENING signal( — line 3.
|
|
193
|
+
const loc = extractLoc(result!.code)
|
|
194
|
+
expect(loc?.line).toBe(3)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
describe('R8 — extension to computed() and effect() calls', () => {
|
|
199
|
+
it('injects { name, __sourceLocation } into bound computed() call', async () => {
|
|
200
|
+
const code = `import { signal, computed } from "@pyreon/reactivity"
|
|
201
|
+
export function App() {
|
|
202
|
+
const s = signal(0)
|
|
203
|
+
const doubled = computed(() => s() * 2)
|
|
204
|
+
return doubled
|
|
205
|
+
}
|
|
206
|
+
`
|
|
207
|
+
const result = await ctx.transform(code, '/abs/app.tsx')
|
|
208
|
+
expect(result).toBeDefined()
|
|
209
|
+
expect(result!.code).toMatch(
|
|
210
|
+
/computed\(\(\) => s\(\) \* 2, \{ name: "doubled", __sourceLocation: \{ file: "\/abs\/app\.tsx", line: \d+, col: \d+ \} \}\)/,
|
|
211
|
+
)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('injects { name, __sourceLocation } into bound effect() call', async () => {
|
|
215
|
+
const code = `import { signal, effect } from "@pyreon/reactivity"
|
|
216
|
+
export function App() {
|
|
217
|
+
const s = signal(0)
|
|
218
|
+
const e = effect(() => { console.log(s()) })
|
|
219
|
+
return e
|
|
220
|
+
}
|
|
221
|
+
`
|
|
222
|
+
const result = await ctx.transform(code, '/abs/app.tsx')
|
|
223
|
+
expect(result).toBeDefined()
|
|
224
|
+
expect(result!.code).toMatch(
|
|
225
|
+
/effect\(\(\) => \{ console\.log\(s\(\)\) \}, \{ name: "e", __sourceLocation: \{ file: "\/abs\/app\.tsx", line: \d+, col: \d+ \} \}\)/,
|
|
226
|
+
)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('injects { __sourceLocation } into unbound effect() call (no name)', async () => {
|
|
230
|
+
const code = `import { signal, effect } from "@pyreon/reactivity"
|
|
231
|
+
export function App() {
|
|
232
|
+
const s = signal(0)
|
|
233
|
+
effect(() => { console.log(s()) })
|
|
234
|
+
}
|
|
235
|
+
`
|
|
236
|
+
const result = await ctx.transform(code, '/abs/app.tsx')
|
|
237
|
+
expect(result).toBeDefined()
|
|
238
|
+
// Unbound effect — `name:` MUST be absent.
|
|
239
|
+
expect(result!.code).toMatch(
|
|
240
|
+
/effect\(\(\) => \{ console\.log\(s\(\)\) \}, \{ __sourceLocation: \{ file: "\/abs\/app\.tsx", line: \d+, col: \d+ \} \}\)/,
|
|
241
|
+
)
|
|
242
|
+
// Targeted assertion — the unbound effect call must NOT have a name field
|
|
243
|
+
// (the bound `const s = signal(...)` line DOES have `name: "s"`, hence
|
|
244
|
+
// a global "no name:" check would false-fire).
|
|
245
|
+
expect(result!.code).not.toMatch(
|
|
246
|
+
/effect\(\(\) => \{ console\.log\(s\(\)\) \}, \{ name:/,
|
|
247
|
+
)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('injects different lines for signal + computed + effect on different lines', async () => {
|
|
251
|
+
const code = `import { signal, computed, effect } from "@pyreon/reactivity"
|
|
252
|
+
export function App() {
|
|
253
|
+
const s = signal(0)
|
|
254
|
+
const d = computed(() => s() * 2)
|
|
255
|
+
effect(() => { console.log(d()) })
|
|
256
|
+
}
|
|
257
|
+
`
|
|
258
|
+
const result = await ctx.transform(code, 'app.tsx')
|
|
259
|
+
const lines = [...result!.code.matchAll(/line: (\d+)/g)].map((m) => parseInt(m[1] ?? '0', 10))
|
|
260
|
+
expect(lines).toContain(3) // signal
|
|
261
|
+
expect(lines).toContain(4) // computed
|
|
262
|
+
expect(lines).toContain(5) // effect
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('does NOT double-inject when bound effect() also matches unbound pattern', async () => {
|
|
266
|
+
// Critical: `const e = effect(...)` MUST be processed by pass 1 only.
|
|
267
|
+
// If pass 2 also matches it, we'd emit `effect(fn, { ... }, { ... })`
|
|
268
|
+
// which becomes a 3-arg call — silently breaks runtime behavior.
|
|
269
|
+
const code = `import { effect } from "@pyreon/reactivity"
|
|
270
|
+
export function App() {
|
|
271
|
+
const e = effect(() => {})
|
|
272
|
+
return e
|
|
273
|
+
}
|
|
274
|
+
`
|
|
275
|
+
const result = await ctx.transform(code, 'app.tsx')
|
|
276
|
+
// Exactly ONE `__sourceLocation` literal in the output.
|
|
277
|
+
const matches = result!.code.match(/__sourceLocation/g) ?? []
|
|
278
|
+
expect(matches).toHaveLength(1)
|
|
279
|
+
// The name MUST still appear (pass 1 ran), so pass 2 didn't take over.
|
|
280
|
+
expect(result!.code).toContain('name: "e"')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('skips computed() that already has 2 args (custom equals)', async () => {
|
|
284
|
+
const code = `import { signal, computed } from "@pyreon/reactivity"
|
|
285
|
+
export function App() {
|
|
286
|
+
const s = signal(0)
|
|
287
|
+
const d = computed(() => s(), { equals: Object.is })
|
|
288
|
+
return d
|
|
289
|
+
}
|
|
290
|
+
`
|
|
291
|
+
const result = await ctx.transform(code, 'app.tsx')
|
|
292
|
+
// computed() with existing options — skip. (The signal() above WILL
|
|
293
|
+
// inject; just don't touch the computed.)
|
|
294
|
+
expect(result!.code).not.toContain(
|
|
295
|
+
'computed(() => s(), { equals: Object.is }, {',
|
|
296
|
+
)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('skips effect() that already has 2 args (existing options)', async () => {
|
|
300
|
+
const code = `import { effect } from "@pyreon/reactivity"
|
|
301
|
+
export function App() {
|
|
302
|
+
effect(() => {}, { name: "preset" })
|
|
303
|
+
}
|
|
304
|
+
`
|
|
305
|
+
const result = await ctx.transform(code, 'app.tsx')
|
|
306
|
+
// No double-injection — there should still be exactly ONE options arg.
|
|
307
|
+
expect(result!.code).not.toMatch(/effect\(\(\) => \{\}, \{ name: "preset" \}, \{/)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('does NOT match member-access .effect() — only bare effect calls', async () => {
|
|
311
|
+
const code = `export function App() {
|
|
312
|
+
const obj = { effect: () => {} }
|
|
313
|
+
obj.effect()
|
|
314
|
+
return obj
|
|
315
|
+
}
|
|
316
|
+
`
|
|
317
|
+
const result = await ctx.transform(code, 'app.tsx')
|
|
318
|
+
// \`obj.effect()\` must not be rewritten — pass 2's negative lookbehind
|
|
319
|
+
// forbids leading \`.\` to avoid touching method calls.
|
|
320
|
+
expect(result!.code).not.toContain('__sourceLocation')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('does NOT match identifier-ending-in-effect calls (sideEffect, etc.)', async () => {
|
|
324
|
+
const code = `export function App() {
|
|
325
|
+
function sideEffect() {}
|
|
326
|
+
sideEffect()
|
|
327
|
+
return null
|
|
328
|
+
}
|
|
329
|
+
`
|
|
330
|
+
const result = await ctx.transform(code, 'app.tsx')
|
|
331
|
+
// \`sideEffect(\` ends in \`effect(\` but is preceded by an identifier char.
|
|
332
|
+
// Negative lookbehind \`(?<![\\w\$.])\` excludes it.
|
|
333
|
+
expect(result!.code).not.toContain('__sourceLocation')
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('handles computed() with custom-equals signature unbound at expression position', async () => {
|
|
337
|
+
// A `computed()` expression used as an arg — no binding. We choose NOT
|
|
338
|
+
// to inject location for unbound computed/signal (conservative: only
|
|
339
|
+
// unbound effect, which is the common anonymous-effect pattern).
|
|
340
|
+
const code = `import { computed } from "@pyreon/reactivity"
|
|
341
|
+
export function App(deriver) {
|
|
342
|
+
deriver(computed(() => 1))
|
|
343
|
+
}
|
|
344
|
+
`
|
|
345
|
+
const result = await ctx.transform(code, 'app.tsx')
|
|
346
|
+
// No injection on unbound computed — that's a deliberate scope choice.
|
|
347
|
+
expect(result!.code).not.toContain('__sourceLocation')
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('injects for ALL three primitives in the same file with no conflicts', async () => {
|
|
351
|
+
const code = `import { signal, computed, effect } from "@pyreon/reactivity"
|
|
352
|
+
export function App() {
|
|
353
|
+
const s = signal(0)
|
|
354
|
+
const d = computed(() => s() + 1)
|
|
355
|
+
const e = effect(() => { console.log(d()) })
|
|
356
|
+
effect(() => { console.log(s()) })
|
|
357
|
+
return [s, d, e]
|
|
358
|
+
}
|
|
359
|
+
`
|
|
360
|
+
const result = await ctx.transform(code, 'app.tsx')
|
|
361
|
+
// Each gets exactly one `__sourceLocation` injection — 4 total.
|
|
362
|
+
const matches = result!.code.match(/__sourceLocation/g) ?? []
|
|
363
|
+
expect(matches).toHaveLength(4)
|
|
364
|
+
// Bound forms carry `name:` — count: s, d, e = 3.
|
|
365
|
+
const names = result!.code.match(/name: "[sde]"/g) ?? []
|
|
366
|
+
expect(names).toHaveLength(3)
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
describe('_maskStringsAndComments', () => {
|
|
371
|
+
it('preserves length so positions line up with original', () => {
|
|
372
|
+
const code = 'const x = "hello"; const y = 1'
|
|
373
|
+
expect(_maskStringsAndComments(code)).toHaveLength(code.length)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('masks `"..."` string content', () => {
|
|
377
|
+
const code = 'const x = "effect(()=>1)"'
|
|
378
|
+
const masked = _maskStringsAndComments(code)
|
|
379
|
+
// Outside of string: unchanged
|
|
380
|
+
expect(masked.slice(0, 11)).toBe('const x = ')
|
|
381
|
+
// Inside string (positions 11..24): all spaces
|
|
382
|
+
expect(masked.slice(11, 25)).toMatch(/^ +$/)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it("masks `'...'` string content", () => {
|
|
386
|
+
const code = "const x = 'signal(0)'"
|
|
387
|
+
const masked = _maskStringsAndComments(code)
|
|
388
|
+
expect(masked.includes('signal(0)')).toBe(false)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('masks template-literal text content', () => {
|
|
392
|
+
const code = 'const x = `effect(() => 1)`'
|
|
393
|
+
const masked = _maskStringsAndComments(code)
|
|
394
|
+
expect(masked.includes('effect(')).toBe(false)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('KEEPS template-literal `${...}` interpolations as code', () => {
|
|
398
|
+
// The CRITICAL contract — interpolation bodies can contain real
|
|
399
|
+
// reactive primitive calls that DO deserve injection.
|
|
400
|
+
const code = 'const x = `value: ${signal(0)}`'
|
|
401
|
+
const masked = _maskStringsAndComments(code)
|
|
402
|
+
expect(masked.includes('signal(0)')).toBe(true)
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it('handles nested braces inside `${...}` correctly', () => {
|
|
406
|
+
const code = 'const x = `${{ a: signal(0) }}`'
|
|
407
|
+
const masked = _maskStringsAndComments(code)
|
|
408
|
+
// The `signal(0)` inside the object literal inside the interpolation
|
|
409
|
+
// must survive masking.
|
|
410
|
+
expect(masked.includes('signal(0)')).toBe(true)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('masks `// ...` line comments to end of line', () => {
|
|
414
|
+
const code = 'const x = 1 // effect(() => 2)\nconst y = 3'
|
|
415
|
+
const masked = _maskStringsAndComments(code)
|
|
416
|
+
// The `effect(` mention in the comment is gone.
|
|
417
|
+
expect(masked.includes('effect(')).toBe(false)
|
|
418
|
+
// `const y = 3` after the newline survives.
|
|
419
|
+
expect(masked.includes('const y = 3')).toBe(true)
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('masks `/* ... */` block comments across newlines', () => {
|
|
423
|
+
const code = 'const x = 1 /* effect(\n () => 2\n) */\nconst y = signal(0)'
|
|
424
|
+
const masked = _maskStringsAndComments(code)
|
|
425
|
+
expect(masked.includes('effect(')).toBe(false)
|
|
426
|
+
// Newlines INSIDE the block comment are preserved so line numbers
|
|
427
|
+
// don't shift.
|
|
428
|
+
expect(masked.split('\n')).toHaveLength(code.split('\n').length)
|
|
429
|
+
// Code AFTER the block comment survives.
|
|
430
|
+
expect(masked.includes('signal(0)')).toBe(true)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('handles escape sequences in strings (`\\"` doesn\'t end the string)', () => {
|
|
434
|
+
const code = 'const x = "with \\"effect(\\" inside"'
|
|
435
|
+
const masked = _maskStringsAndComments(code)
|
|
436
|
+
expect(masked.includes('effect(')).toBe(false)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it('handles escape sequences in template literals (`\\`` doesn\'t end)', () => {
|
|
440
|
+
const code = 'const x = `with \\`effect(\\` inside`'
|
|
441
|
+
const masked = _maskStringsAndComments(code)
|
|
442
|
+
expect(masked.includes('effect(')).toBe(false)
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('does not touch code outside strings/comments', () => {
|
|
446
|
+
const code = 'const x = signal(0); const y = computed(() => 1); effect(() => 2)'
|
|
447
|
+
const masked = _maskStringsAndComments(code)
|
|
448
|
+
// Verbatim — no string/comment regions to mask.
|
|
449
|
+
expect(masked).toBe(code)
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('preserves newlines so injected line numbers stay correct', () => {
|
|
453
|
+
const code = 'line1\n"line2"\nline3'
|
|
454
|
+
const masked = _maskStringsAndComments(code)
|
|
455
|
+
expect(masked.split('\n')).toHaveLength(3)
|
|
456
|
+
})
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
describe('injectSignalNames — string-region false-positive prevention', () => {
|
|
460
|
+
it('does NOT inject into `effect(` inside a template literal (docstring)', async () => {
|
|
461
|
+
const code = `import { signal } from "@pyreon/reactivity"
|
|
462
|
+
export function App() {
|
|
463
|
+
const docs = \`
|
|
464
|
+
Use effect() like this:
|
|
465
|
+
effect(() => console.log(state))
|
|
466
|
+
\`
|
|
467
|
+
return docs
|
|
468
|
+
}
|
|
469
|
+
`
|
|
470
|
+
const result = await ctx.transform(code, '/abs/app.tsx')
|
|
471
|
+
// The template literal's content must NOT be modified.
|
|
472
|
+
expect(result!.code).toContain('Use effect() like this:')
|
|
473
|
+
expect(result!.code).toContain('effect(() => console.log(state))')
|
|
474
|
+
// No __sourceLocation injection (the only `effect(` is inside the string).
|
|
475
|
+
expect(result!.code).not.toContain('__sourceLocation')
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it('does NOT inject into `signal(` / `computed(` inside a template literal', async () => {
|
|
479
|
+
const code = `import { signal } from "@pyreon/reactivity"
|
|
480
|
+
export function App() {
|
|
481
|
+
const docs = \`
|
|
482
|
+
Pattern: const count = signal(0)
|
|
483
|
+
Pattern: const doubled = computed(() => count() * 2)
|
|
484
|
+
\`
|
|
485
|
+
return docs
|
|
486
|
+
}
|
|
487
|
+
`
|
|
488
|
+
const result = await ctx.transform(code, '/abs/app.tsx')
|
|
489
|
+
expect(result!.code).toContain('const count = signal(0)')
|
|
490
|
+
expect(result!.code).toContain('const doubled = computed(() => count() * 2)')
|
|
491
|
+
// The template-literal content stays verbatim — no __sourceLocation.
|
|
492
|
+
expect(result!.code).not.toContain('__sourceLocation')
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('does NOT inject into `effect(` inside a "..." string', async () => {
|
|
496
|
+
// Common shape: throw new Error('effect() must be called inside ...')
|
|
497
|
+
const code = `import { signal } from "@pyreon/reactivity"
|
|
498
|
+
export function App() {
|
|
499
|
+
throw new Error("effect() must be called inside a component")
|
|
500
|
+
}
|
|
501
|
+
`
|
|
502
|
+
const result = await ctx.transform(code, '/abs/app.tsx')
|
|
503
|
+
expect(result!.code).toContain('"effect() must be called inside a component"')
|
|
504
|
+
expect(result!.code).not.toContain('__sourceLocation')
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
it('does NOT inject into `effect(` inside a line comment', async () => {
|
|
508
|
+
const code = `import { signal, effect } from "@pyreon/reactivity"
|
|
509
|
+
export function App() {
|
|
510
|
+
// TODO: replace effect(() => log()) with watch()
|
|
511
|
+
const x = signal(0)
|
|
512
|
+
return x
|
|
513
|
+
}
|
|
514
|
+
`
|
|
515
|
+
const result = await ctx.transform(code, '/abs/app.tsx')
|
|
516
|
+
// signal(0) gets injected; the comment-mention of effect(...) does not.
|
|
517
|
+
expect(result!.code).toContain('signal(0, { name: "x"')
|
|
518
|
+
// The comment line is verbatim.
|
|
519
|
+
expect(result!.code).toContain('// TODO: replace effect(() => log()) with watch()')
|
|
520
|
+
// Only ONE __sourceLocation — the signal call.
|
|
521
|
+
const matches = result!.code.match(/__sourceLocation/g) ?? []
|
|
522
|
+
expect(matches).toHaveLength(1)
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it('STILL injects into real `effect(` calls outside strings — false-negative guard', async () => {
|
|
526
|
+
const code = `import { signal, effect } from "@pyreon/reactivity"
|
|
527
|
+
export function App() {
|
|
528
|
+
const docs = \`effect(() => x)\` // fake call in string — must NOT inject
|
|
529
|
+
const s = signal(0) // real — MUST inject
|
|
530
|
+
effect(() => { console.log(s()) }) // real — MUST inject
|
|
531
|
+
return docs
|
|
532
|
+
}
|
|
533
|
+
`
|
|
534
|
+
const result = await ctx.transform(code, '/abs/app.tsx')
|
|
535
|
+
// The string's content survives.
|
|
536
|
+
expect(result!.code).toContain('`effect(() => x)`')
|
|
537
|
+
// Two real reactive calls → two __sourceLocation injections.
|
|
538
|
+
const matches = result!.code.match(/__sourceLocation/g) ?? []
|
|
539
|
+
expect(matches).toHaveLength(2)
|
|
540
|
+
// The signal is bound, has a name.
|
|
541
|
+
expect(result!.code).toContain('name: "s"')
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
it('handles real `signal()` call INSIDE template-literal `${...}` interpolation', async () => {
|
|
545
|
+
// Interpolation bodies are real code — `signal()` there should
|
|
546
|
+
// still be tracked.
|
|
547
|
+
const code = `import { signal } from "@pyreon/reactivity"
|
|
548
|
+
export function App() {
|
|
549
|
+
const make = () => {
|
|
550
|
+
const s = signal(0)
|
|
551
|
+
return \`value: \${s()}\`
|
|
552
|
+
}
|
|
553
|
+
return make()
|
|
554
|
+
}
|
|
555
|
+
`
|
|
556
|
+
const result = await ctx.transform(code, '/abs/app.tsx')
|
|
557
|
+
expect(result!.code).toContain('signal(0, { name: "s"')
|
|
558
|
+
})
|
|
559
|
+
})
|
|
@@ -186,8 +186,11 @@ export function Counter() {
|
|
|
186
186
|
expect(result).toBeDefined()
|
|
187
187
|
// The signal inside the function body should NOT be rewritten to __hmr_signal
|
|
188
188
|
expect(result!.code).not.toContain('__hmr_signal')
|
|
189
|
-
// But should get a debug name injected
|
|
190
|
-
|
|
189
|
+
// But should get a debug name + source location injected (the LPIH
|
|
190
|
+
// build-time injection, R4 follow-up — see lpih.md docs).
|
|
191
|
+
expect(result!.code).toMatch(
|
|
192
|
+
/signal\(0, \{ name: "local", __sourceLocation: \{ file: "\/src\/Counter\.tsx", line: \d+, col: \d+ \} \}\)/,
|
|
193
|
+
)
|
|
191
194
|
})
|
|
192
195
|
|
|
193
196
|
it('rewrites multiple module-scope signals', async () => {
|