@pyreon/compiler 0.16.0 → 0.19.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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +1919 -1152
- package/lib/types/index.d.ts +257 -93
- package/package.json +13 -12
- package/src/defer-inline.ts +686 -0
- package/src/index.ts +14 -1
- package/src/jsx.ts +164 -6
- package/src/load-native.ts +1 -0
- package/src/manifest.ts +280 -0
- package/src/pyreon-intercept.ts +164 -0
- package/src/react-intercept.ts +59 -0
- package/src/reactivity-lens.ts +190 -0
- package/src/tests/defer-inline.test.ts +387 -0
- package/src/tests/detector-tag-consistency.test.ts +2 -0
- package/src/tests/jsx.test.ts +23 -3
- package/src/tests/manifest-snapshot.test.ts +55 -0
- package/src/tests/native-equivalence.test.ts +104 -3
- package/src/tests/pyreon-intercept.test.ts +189 -0
- package/src/tests/react-intercept.test.ts +50 -2
- package/src/tests/reactivity-lens.test.ts +170 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { transformDeferInline } from '../defer-inline'
|
|
2
|
+
|
|
3
|
+
describe('transformDeferInline — basic rewrites', () => {
|
|
4
|
+
test('rewrites <Defer when={x}><Modal /></Defer> with named import', () => {
|
|
5
|
+
const input = `
|
|
6
|
+
import { Defer } from '@pyreon/core'
|
|
7
|
+
import { Modal } from './Modal'
|
|
8
|
+
|
|
9
|
+
export function App() {
|
|
10
|
+
const open = () => true
|
|
11
|
+
return <Defer when={open}><Modal /></Defer>
|
|
12
|
+
}
|
|
13
|
+
`
|
|
14
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
15
|
+
expect(result.changed).toBe(true)
|
|
16
|
+
expect(result.code).not.toContain("import { Modal } from './Modal'")
|
|
17
|
+
expect(result.code).toContain(`chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}`)
|
|
18
|
+
expect(result.code).toContain('{(__C) => <__C />}')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('rewrites with default import', () => {
|
|
22
|
+
const input = `
|
|
23
|
+
import { Defer } from '@pyreon/core'
|
|
24
|
+
import Modal from './Modal'
|
|
25
|
+
|
|
26
|
+
export function App() {
|
|
27
|
+
return <Defer when={() => true}><Modal /></Defer>
|
|
28
|
+
}
|
|
29
|
+
`
|
|
30
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
31
|
+
expect(result.changed).toBe(true)
|
|
32
|
+
expect(result.code).not.toContain('import Modal from')
|
|
33
|
+
expect(result.code).toContain(`chunk={() => import('./Modal')}`)
|
|
34
|
+
expect(result.code).not.toContain(`.then((__m) =>`)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('preserves other props on Defer (fallback, when, on)', () => {
|
|
38
|
+
const input = `
|
|
39
|
+
import { Defer } from '@pyreon/core'
|
|
40
|
+
import { Modal } from './Modal'
|
|
41
|
+
export function App() {
|
|
42
|
+
return <Defer when={() => true} fallback={<span>loading</span>}><Modal /></Defer>
|
|
43
|
+
}
|
|
44
|
+
`
|
|
45
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
46
|
+
expect(result.changed).toBe(true)
|
|
47
|
+
expect(result.code).toContain('when={() => true}')
|
|
48
|
+
expect(result.code).toContain('fallback={<span>loading</span>}')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('works for on="visible" trigger', () => {
|
|
52
|
+
const input = `
|
|
53
|
+
import { Defer } from '@pyreon/core'
|
|
54
|
+
import { Comments } from './Comments'
|
|
55
|
+
export function Post() {
|
|
56
|
+
return <Defer on="visible"><Comments /></Defer>
|
|
57
|
+
}
|
|
58
|
+
`
|
|
59
|
+
const result = transformDeferInline(input, 'post.tsx')
|
|
60
|
+
expect(result.changed).toBe(true)
|
|
61
|
+
expect(result.code).toContain('on="visible"')
|
|
62
|
+
expect(result.code).toContain(`chunk={() => import('./Comments').then((__m) => ({ default: __m.Comments }))}`)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('transformDeferInline — bail-out cases', () => {
|
|
67
|
+
test('leaves unchanged when chunk prop is already provided', () => {
|
|
68
|
+
const input = `
|
|
69
|
+
import { Defer } from '@pyreon/core'
|
|
70
|
+
import { Modal } from './Modal'
|
|
71
|
+
export function App() {
|
|
72
|
+
return (
|
|
73
|
+
<Defer chunk={() => import('./Modal')} when={() => true}>
|
|
74
|
+
{Modal => <Modal />}
|
|
75
|
+
</Defer>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
`
|
|
79
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
80
|
+
expect(result.changed).toBe(false)
|
|
81
|
+
expect(result.code).toBe(input)
|
|
82
|
+
expect(result.warnings).toEqual([])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('warns when inline child is also used outside the Defer', () => {
|
|
86
|
+
const input = `
|
|
87
|
+
import { Defer } from '@pyreon/core'
|
|
88
|
+
import { Modal } from './Modal'
|
|
89
|
+
const eagerCopy = <Modal />
|
|
90
|
+
export function App() {
|
|
91
|
+
return <Defer when={() => true}><Modal /></Defer>
|
|
92
|
+
}
|
|
93
|
+
`
|
|
94
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
95
|
+
expect(result.changed).toBe(false)
|
|
96
|
+
expect(result.warnings).toHaveLength(1)
|
|
97
|
+
expect(result.warnings[0]!.code).toBe('defer-inline/import-used-elsewhere')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('warns when inline child is not imported', () => {
|
|
101
|
+
const input = `
|
|
102
|
+
import { Defer } from '@pyreon/core'
|
|
103
|
+
export function App() {
|
|
104
|
+
return <Defer when={() => true}><LocalThing /></Defer>
|
|
105
|
+
}
|
|
106
|
+
function LocalThing() { return null }
|
|
107
|
+
`
|
|
108
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
109
|
+
expect(result.changed).toBe(false)
|
|
110
|
+
expect(result.warnings).toHaveLength(1)
|
|
111
|
+
expect(result.warnings[0]!.code).toBe('defer-inline/import-not-found')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('skips Defer with multiple children (still requires render-prop form)', () => {
|
|
115
|
+
const input = `
|
|
116
|
+
import { Defer } from '@pyreon/core'
|
|
117
|
+
import { Modal } from './Modal'
|
|
118
|
+
import { Spinner } from './Spinner'
|
|
119
|
+
export function App() {
|
|
120
|
+
return <Defer when={() => true}><Modal /><Spinner /></Defer>
|
|
121
|
+
}
|
|
122
|
+
`
|
|
123
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
124
|
+
// v2 now emits a `multiple-children` warning so the author knows to use
|
|
125
|
+
// the explicit `chunk` form. v1 was silent — that was a footgun.
|
|
126
|
+
expect(result.changed).toBe(false)
|
|
127
|
+
expect(result.warnings[0]?.code).toBe('defer-inline/multiple-children')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('fast-path: no Defer in source returns unchanged', () => {
|
|
131
|
+
const input = `
|
|
132
|
+
import { signal } from '@pyreon/reactivity'
|
|
133
|
+
export const count = signal(0)
|
|
134
|
+
`
|
|
135
|
+
const result = transformDeferInline(input, 'count.ts')
|
|
136
|
+
expect(result.changed).toBe(false)
|
|
137
|
+
expect(result.code).toBe(input)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('does not blow up on syntactically-invalid source — returns unchanged', () => {
|
|
141
|
+
const input = `import {{{ Defer broken syntax`
|
|
142
|
+
const result = transformDeferInline(input, 'broken.tsx')
|
|
143
|
+
expect(result.changed).toBe(false)
|
|
144
|
+
// Returns the input unchanged; downstream parser will surface the real error.
|
|
145
|
+
expect(result.code).toBe(input)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// v1 bailed on `{ Modal as M }`. v2 handles renamed imports — the
|
|
149
|
+
// local name in JSX is `M`, but the chunk extracts `__m.Modal` (the
|
|
150
|
+
// original exported name). See the positive test in the v2 section.
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('transformDeferInline — v2 capabilities', () => {
|
|
154
|
+
test('preserves props on inline child', () => {
|
|
155
|
+
const input = `
|
|
156
|
+
import { Defer } from '@pyreon/core'
|
|
157
|
+
import { Modal } from './Modal'
|
|
158
|
+
export function App() {
|
|
159
|
+
return <Defer when={() => true}><Modal title="Confirm" size="md" /></Defer>
|
|
160
|
+
}
|
|
161
|
+
`
|
|
162
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
163
|
+
expect(result.changed).toBe(true)
|
|
164
|
+
expect(result.code).not.toContain("import { Modal }")
|
|
165
|
+
// Props pass through verbatim. Only the component name is replaced.
|
|
166
|
+
expect(result.code).toContain('{(__C) => <__C title="Confirm" size="md" />}')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('preserves nested children on inline child (non-self-closing)', () => {
|
|
170
|
+
const input = `
|
|
171
|
+
import { Defer } from '@pyreon/core'
|
|
172
|
+
import { Modal } from './Modal'
|
|
173
|
+
export function App() {
|
|
174
|
+
return (
|
|
175
|
+
<Defer when={() => true}>
|
|
176
|
+
<Modal title="Hello">
|
|
177
|
+
<p>nested content</p>
|
|
178
|
+
</Modal>
|
|
179
|
+
</Defer>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
`
|
|
183
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
184
|
+
expect(result.changed).toBe(true)
|
|
185
|
+
// Both opening AND closing tag names replaced with __C; nested JSX intact.
|
|
186
|
+
expect(result.code).toContain('<__C title="Hello">')
|
|
187
|
+
expect(result.code).toContain('</__C>')
|
|
188
|
+
expect(result.code).toContain('<p>nested content</p>')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('captures closure variables via render-prop scope (signal in handler)', () => {
|
|
192
|
+
const input = `
|
|
193
|
+
import { Defer } from '@pyreon/core'
|
|
194
|
+
import { signal } from '@pyreon/reactivity'
|
|
195
|
+
import { Modal } from './Modal'
|
|
196
|
+
|
|
197
|
+
export function App() {
|
|
198
|
+
const open = signal(false)
|
|
199
|
+
const count = signal(0)
|
|
200
|
+
return (
|
|
201
|
+
<Defer when={open}>
|
|
202
|
+
<Modal count={count} onClose={() => open.set(false)} />
|
|
203
|
+
</Defer>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
`
|
|
207
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
208
|
+
expect(result.changed).toBe(true)
|
|
209
|
+
// The render-prop arrow naturally captures `count` + `open.set` from
|
|
210
|
+
// the App function's scope — no closure-tracking pass needed, JS
|
|
211
|
+
// lexical scope just works.
|
|
212
|
+
expect(result.code).toContain('<__C count={count} onClose={() => open.set(false)} />')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('handles renamed imports — { Modal as M }', () => {
|
|
216
|
+
const input = `
|
|
217
|
+
import { Defer } from '@pyreon/core'
|
|
218
|
+
import { Modal as M } from './Modal'
|
|
219
|
+
export function App() {
|
|
220
|
+
return <Defer when={() => true}><M /></Defer>
|
|
221
|
+
}
|
|
222
|
+
`
|
|
223
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
224
|
+
expect(result.changed).toBe(true)
|
|
225
|
+
// Local name `M` is used at the JSX site, but the chunk extracts
|
|
226
|
+
// `__m.Modal` (the original exported name from './Modal').
|
|
227
|
+
expect(result.code).toContain(`chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}`)
|
|
228
|
+
expect(result.code).not.toContain("import { Modal as M }")
|
|
229
|
+
expect(result.code).toContain('{(__C) => <__C />}')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('multi-specifier import: drops only the Defer-targeted binding', () => {
|
|
233
|
+
const input = `
|
|
234
|
+
import { Defer } from '@pyreon/core'
|
|
235
|
+
import { Modal, OtherThing } from './shared'
|
|
236
|
+
export function App() {
|
|
237
|
+
const use = OtherThing
|
|
238
|
+
return <Defer when={() => true}><Modal /></Defer>
|
|
239
|
+
}
|
|
240
|
+
`
|
|
241
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
242
|
+
expect(result.changed).toBe(true)
|
|
243
|
+
// OtherThing is referenced elsewhere — its import must survive.
|
|
244
|
+
expect(result.code).toContain("OtherThing")
|
|
245
|
+
expect(result.code).toMatch(/import \{\s*OtherThing\s*\} from '\.\/shared'/)
|
|
246
|
+
// Modal binding is gone from the import declaration.
|
|
247
|
+
expect(result.code).not.toMatch(/import \{[^}]*\bModal\b[^}]*\}/)
|
|
248
|
+
// ...but the dynamic chunk pulls Modal from './shared' (same source).
|
|
249
|
+
expect(result.code).toContain(`chunk={() => import('./shared').then((__m) => ({ default: __m.Modal }))}`)
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
describe('transformDeferInline — multiple Defers in one file', () => {
|
|
254
|
+
test('rewrites two independent Defers with distinct imports', () => {
|
|
255
|
+
const input = `
|
|
256
|
+
import { Defer } from '@pyreon/core'
|
|
257
|
+
import { Modal } from './Modal'
|
|
258
|
+
import { Comments } from './Comments'
|
|
259
|
+
export function App() {
|
|
260
|
+
return (
|
|
261
|
+
<div>
|
|
262
|
+
<Defer when={() => true}><Modal /></Defer>
|
|
263
|
+
<Defer on="visible"><Comments /></Defer>
|
|
264
|
+
</div>
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
`
|
|
268
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
269
|
+
expect(result.changed).toBe(true)
|
|
270
|
+
expect(result.code).not.toContain("import { Modal } from './Modal'")
|
|
271
|
+
expect(result.code).not.toContain("import { Comments } from './Comments'")
|
|
272
|
+
expect(result.code).toContain(`chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}`)
|
|
273
|
+
expect(result.code).toContain(`chunk={() => import('./Comments').then((__m) => ({ default: __m.Comments }))}`)
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
// Gap 4 from the v2 follow-up roadmap. Namespace imports were the last
|
|
278
|
+
// inline-Defer shape that fell back to the explicit form — closing this
|
|
279
|
+
// gap means EVERY common import shape is supported by the inline form.
|
|
280
|
+
describe('transformDeferInline — namespace imports (v3)', () => {
|
|
281
|
+
test('rewrites <M.Modal /> with namespace import — chunk extracts __m.Modal', () => {
|
|
282
|
+
const input = `
|
|
283
|
+
import { Defer } from '@pyreon/core'
|
|
284
|
+
import * as M from './Modal'
|
|
285
|
+
export function App() {
|
|
286
|
+
return <Defer when={() => true}><M.Modal /></Defer>
|
|
287
|
+
}
|
|
288
|
+
`
|
|
289
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
290
|
+
expect(result.changed).toBe(true)
|
|
291
|
+
expect(result.code).toContain(`chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}`)
|
|
292
|
+
// M.Modal in the JSX replaced with __C (the whole member expression
|
|
293
|
+
// is the "name" range that gets substituted).
|
|
294
|
+
expect(result.code).toContain('{(__C) => <__C />}')
|
|
295
|
+
expect(result.code).not.toContain('import * as M from')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('rewrites with props on member-expression child', () => {
|
|
299
|
+
const input = `
|
|
300
|
+
import { Defer } from '@pyreon/core'
|
|
301
|
+
import * as M from './Modal'
|
|
302
|
+
export function App() {
|
|
303
|
+
return <Defer when={() => true}><M.Modal title="hi" /></Defer>
|
|
304
|
+
}
|
|
305
|
+
`
|
|
306
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
307
|
+
expect(result.changed).toBe(true)
|
|
308
|
+
expect(result.code).toContain('{(__C) => <__C title="hi" />}')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('non-self-closing member-expression child preserves opening + closing replacement', () => {
|
|
312
|
+
const input = `
|
|
313
|
+
import { Defer } from '@pyreon/core'
|
|
314
|
+
import * as M from './Modal'
|
|
315
|
+
export function App() {
|
|
316
|
+
return <Defer when={() => true}><M.Modal title="hi"><span>body</span></M.Modal></Defer>
|
|
317
|
+
}
|
|
318
|
+
`
|
|
319
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
320
|
+
expect(result.changed).toBe(true)
|
|
321
|
+
expect(result.code).toContain('{(__C) => <__C title="hi"><span>body</span></__C>}')
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test('bails when namespace is referenced elsewhere in the file', () => {
|
|
325
|
+
// `M` is used for multiple components. Removing the static import
|
|
326
|
+
// would break the other usage AND the dynamic import becomes a
|
|
327
|
+
// no-op (Rolldown bundles the module statically when ANY part is
|
|
328
|
+
// referenced).
|
|
329
|
+
const input = `
|
|
330
|
+
import { Defer } from '@pyreon/core'
|
|
331
|
+
import * as M from './Modal'
|
|
332
|
+
export function App() {
|
|
333
|
+
void M.Settings
|
|
334
|
+
return <Defer when={() => true}><M.Modal /></Defer>
|
|
335
|
+
}
|
|
336
|
+
`
|
|
337
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
338
|
+
expect(result.changed).toBe(false)
|
|
339
|
+
expect(result.warnings).toHaveLength(1)
|
|
340
|
+
expect(result.warnings[0]!.code).toBe('defer-inline/import-used-elsewhere')
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
test('bails on deeper member expression — <M.Sub.X /> not supported', () => {
|
|
344
|
+
const input = `
|
|
345
|
+
import { Defer } from '@pyreon/core'
|
|
346
|
+
import * as M from './Modal'
|
|
347
|
+
export function App() {
|
|
348
|
+
return <Defer when={() => true}><M.Sub.Modal /></Defer>
|
|
349
|
+
}
|
|
350
|
+
`
|
|
351
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
352
|
+
// analyzeChildElement returns null for non-depth-1 member
|
|
353
|
+
// expressions → no match in findDeferMatches → no warning. The
|
|
354
|
+
// Defer is left alone; runtime errors with "missing chunk".
|
|
355
|
+
expect(result.changed).toBe(false)
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('bails when member property is lowercase — <M.helper /> is not a component', () => {
|
|
359
|
+
const input = `
|
|
360
|
+
import { Defer } from '@pyreon/core'
|
|
361
|
+
import * as M from './lib'
|
|
362
|
+
export function App() {
|
|
363
|
+
return <Defer when={() => true}><M.helper /></Defer>
|
|
364
|
+
}
|
|
365
|
+
`
|
|
366
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
367
|
+
expect(result.changed).toBe(false)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
test('bails when member expression but import is default (not namespace)', () => {
|
|
371
|
+
// `import M from './X'` (default) followed by `<M.Modal />` is a
|
|
372
|
+
// member access on the default-exported component itself, not a
|
|
373
|
+
// namespace lookup. Different semantics; out of scope. Compiler
|
|
374
|
+
// emits `unsupported-import-shape` so the author knows why.
|
|
375
|
+
const input = `
|
|
376
|
+
import { Defer } from '@pyreon/core'
|
|
377
|
+
import M from './Modal'
|
|
378
|
+
export function App() {
|
|
379
|
+
return <Defer when={() => true}><M.Modal /></Defer>
|
|
380
|
+
}
|
|
381
|
+
`
|
|
382
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
383
|
+
expect(result.changed).toBe(false)
|
|
384
|
+
expect(result.warnings).toHaveLength(1)
|
|
385
|
+
expect(result.warnings[0]!.code).toBe('defer-inline/unsupported-import-shape')
|
|
386
|
+
})
|
|
387
|
+
})
|
|
@@ -30,6 +30,7 @@ const COMPILER_CODES = [
|
|
|
30
30
|
'for-missing-by',
|
|
31
31
|
'for-with-key',
|
|
32
32
|
'props-destructured',
|
|
33
|
+
'props-destructured-body',
|
|
33
34
|
'process-dev-gate',
|
|
34
35
|
'empty-theme',
|
|
35
36
|
'raw-add-event-listener',
|
|
@@ -40,6 +41,7 @@ const COMPILER_CODES = [
|
|
|
40
41
|
'static-return-null-conditional',
|
|
41
42
|
'as-unknown-as-vnodechild',
|
|
42
43
|
'island-never-with-registry-entry',
|
|
44
|
+
'query-options-as-function',
|
|
43
45
|
] as const
|
|
44
46
|
type CompilerCode = (typeof COMPILER_CODES)[number]
|
|
45
47
|
|
package/src/tests/jsx.test.ts
CHANGED
|
@@ -283,13 +283,33 @@ describe('JSX transform — component elements', () => {
|
|
|
283
283
|
expect(result).toContain('_rp(')
|
|
284
284
|
})
|
|
285
285
|
|
|
286
|
-
test('spread props on component
|
|
286
|
+
test('spread props on component are wrapped with _wrapSpread to preserve reactivity', () => {
|
|
287
287
|
const result = t('<Comp {...getProps()} label="hi" />')
|
|
288
|
-
// Spread
|
|
289
|
-
|
|
288
|
+
// Spread argument is wrapped so getter-shaped reactive props survive
|
|
289
|
+
// esbuild's JS-level object spread in the automatic JSX runtime.
|
|
290
|
+
expect(result).toContain('{..._wrapSpread(getProps())}')
|
|
290
291
|
// Static label should not be wrapped
|
|
291
292
|
expect(result).not.toContain('_rp(() => "hi")')
|
|
292
293
|
})
|
|
294
|
+
|
|
295
|
+
test('spread props on DOM elements are NOT wrapped (handled by template path)', () => {
|
|
296
|
+
const result = t('<div {...rest} class="x" />')
|
|
297
|
+
// DOM-element spreads go through the template path's _applyProps.
|
|
298
|
+
expect(result).toContain('{...rest}')
|
|
299
|
+
expect(result).not.toContain('_wrapSpread')
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('multiple spread sources on a component each get wrapped independently', () => {
|
|
303
|
+
const result = t('<Comp {...a} {...b} foo="x" />')
|
|
304
|
+
expect(result).toContain('{..._wrapSpread(a)}')
|
|
305
|
+
expect(result).toContain('{..._wrapSpread(b)}')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test('_wrapSpread emission is idempotent on re-compilation', () => {
|
|
309
|
+
const result = t('<Comp {..._wrapSpread(rest)} />')
|
|
310
|
+
// Should not double-wrap.
|
|
311
|
+
expect(result).not.toContain('_wrapSpread(_wrapSpread(')
|
|
312
|
+
})
|
|
293
313
|
})
|
|
294
314
|
|
|
295
315
|
// ─── Spread attributes ──────────────────────────────────────────────────────
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
renderApiReferenceEntries,
|
|
3
|
+
renderLlmsFullSection,
|
|
4
|
+
renderLlmsTxtLine,
|
|
5
|
+
} from '@pyreon/manifest'
|
|
6
|
+
import manifest from '../manifest'
|
|
7
|
+
|
|
8
|
+
describe('gen-docs — compiler snapshot', () => {
|
|
9
|
+
it('renders a llms.txt bullet starting with the package prefix', () => {
|
|
10
|
+
const line = renderLlmsTxtLine(manifest)
|
|
11
|
+
expect(line.startsWith('- @pyreon/compiler —')).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('renders a llms-full.txt section with the right header', () => {
|
|
15
|
+
const section = renderLlmsFullSection(manifest)
|
|
16
|
+
expect(section.startsWith('## @pyreon/compiler —')).toBe(true)
|
|
17
|
+
expect(section).toContain('```typescript')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('renders MCP api-reference entries for every api[] item', () => {
|
|
21
|
+
const record = renderApiReferenceEntries(manifest)
|
|
22
|
+
expect(Object.keys(record).sort()).toEqual([
|
|
23
|
+
'compiler/analyzeReactivity',
|
|
24
|
+
'compiler/auditIslands',
|
|
25
|
+
'compiler/auditSsg',
|
|
26
|
+
'compiler/auditTestEnvironment',
|
|
27
|
+
'compiler/detectPyreonPatterns',
|
|
28
|
+
'compiler/detectReactPatterns',
|
|
29
|
+
'compiler/diagnoseError',
|
|
30
|
+
'compiler/formatIslandAudit',
|
|
31
|
+
'compiler/formatReactivityLens',
|
|
32
|
+
'compiler/formatSsgAudit',
|
|
33
|
+
'compiler/formatTestAudit',
|
|
34
|
+
'compiler/generateContext',
|
|
35
|
+
'compiler/hasPyreonPatterns',
|
|
36
|
+
'compiler/hasReactPatterns',
|
|
37
|
+
'compiler/migrateReactCode',
|
|
38
|
+
'compiler/transformDeferInline',
|
|
39
|
+
'compiler/transformJSX',
|
|
40
|
+
'compiler/transformJSX_JS',
|
|
41
|
+
])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('flags the experimental Reactivity-Lens entries', () => {
|
|
45
|
+
const r = renderApiReferenceEntries(manifest)
|
|
46
|
+
expect(r['compiler/analyzeReactivity']?.notes).toContain('[EXPERIMENTAL]')
|
|
47
|
+
expect(r['compiler/formatReactivityLens']?.notes).toContain('[EXPERIMENTAL]')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('carries the foot-gun catalog into MCP mistakes for flagship APIs', () => {
|
|
51
|
+
const r = renderApiReferenceEntries(manifest)
|
|
52
|
+
expect(r['compiler/transformJSX']?.mistakes).toBeTruthy()
|
|
53
|
+
expect(r['compiler/detectPyreonPatterns']?.mistakes).toContain('fixable')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -5,11 +5,23 @@
|
|
|
5
5
|
* between the two backends.
|
|
6
6
|
*/
|
|
7
7
|
import { transformJSX_JS } from '../jsx'
|
|
8
|
+
import type { ReactivitySpan } from '../jsx'
|
|
8
9
|
|
|
9
10
|
// Load native if available
|
|
10
|
-
let nativeTransform:
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
let nativeTransform:
|
|
12
|
+
| ((
|
|
13
|
+
code: string,
|
|
14
|
+
filename: string,
|
|
15
|
+
ssr: boolean,
|
|
16
|
+
knownSignals: string[] | null,
|
|
17
|
+
reactivityLens?: boolean,
|
|
18
|
+
) => {
|
|
19
|
+
code: string
|
|
20
|
+
usesTemplates?: boolean | null
|
|
21
|
+
warnings: Array<{ message: string; line: number; column: number; code: string }>
|
|
22
|
+
reactivityLens?: ReactivitySpan[] | null
|
|
23
|
+
})
|
|
24
|
+
| null = null
|
|
13
25
|
|
|
14
26
|
try {
|
|
15
27
|
const path = require('node:path')
|
|
@@ -39,6 +51,50 @@ function compareSsr(input: string) {
|
|
|
39
51
|
expect(rs.code).toBe(js.code)
|
|
40
52
|
}
|
|
41
53
|
|
|
54
|
+
// Reactivity-lens cross-backend gate (Phase 3). Asserts the Rust binary
|
|
55
|
+
// emits the SAME sidecar the JS oracle does. Two contracts:
|
|
56
|
+
// 1. ADDITIVE — `code` is byte-identical with the lens collected vs
|
|
57
|
+
// not, on BOTH backends (the option never affects codegen).
|
|
58
|
+
// 2. PARITY — the SET of recorded spans is identical JS↔Rust.
|
|
59
|
+
// Spans are compared order-independently: the LSP consumer sorts before
|
|
60
|
+
// rendering, so traversal order is NOT part of the contract — the SET of
|
|
61
|
+
// codegen decisions is. Sorting by (start,end,kind,detail) makes a
|
|
62
|
+
// missing/extra/wrong span fail loudly while ignoring walk order.
|
|
63
|
+
function canon(spans: ReactivitySpan[] | null | undefined): string[] {
|
|
64
|
+
return (spans ?? [])
|
|
65
|
+
.map(
|
|
66
|
+
(s) =>
|
|
67
|
+
`${s.start}|${s.end}|${s.line}|${s.column}|${s.endLine}|${s.endColumn}|${s.kind}|${s.detail}`,
|
|
68
|
+
)
|
|
69
|
+
.sort()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function compareLens(input: string, filename = 'test.tsx') {
|
|
73
|
+
const jsOff = transformJSX_JS(input, filename)
|
|
74
|
+
const jsOn = transformJSX_JS(input, filename, { reactivityLens: true })
|
|
75
|
+
const rsOff = nativeTransform!(input, filename, false, null, false)
|
|
76
|
+
const rsOn = nativeTransform!(input, filename, false, null, true)
|
|
77
|
+
|
|
78
|
+
// (1) additive — collecting the lens never changes emitted code, on
|
|
79
|
+
// either backend, and both backends still agree on that code.
|
|
80
|
+
expect(jsOn.code).toBe(jsOff.code)
|
|
81
|
+
expect(rsOn.code).toBe(rsOff.code)
|
|
82
|
+
expect(rsOn.code).toBe(jsOn.code)
|
|
83
|
+
|
|
84
|
+
// The opt-out path must NOT carry the sidecar (parity with JS, which
|
|
85
|
+
// omits the field entirely when not collecting).
|
|
86
|
+
expect(rsOff.reactivityLens == null).toBe(true)
|
|
87
|
+
expect(jsOff.reactivityLens == null).toBe(true)
|
|
88
|
+
|
|
89
|
+
// (2) parity — identical SET of spans.
|
|
90
|
+
const j = canon(jsOn.reactivityLens)
|
|
91
|
+
const r = canon(rsOn.reactivityLens)
|
|
92
|
+
expect(r).toEqual(j)
|
|
93
|
+
// Guard against the degenerate "both empty → trivially equal" pass:
|
|
94
|
+
// every fixture below is chosen to produce ≥1 span.
|
|
95
|
+
expect(j.length).toBeGreaterThan(0)
|
|
96
|
+
}
|
|
97
|
+
|
|
42
98
|
// ─── Cross-backend equivalence ──────────────────────────────────────────────
|
|
43
99
|
|
|
44
100
|
describeNative('Native vs JS equivalence — basic', () => {
|
|
@@ -729,3 +785,48 @@ describeNative('Native vs JS equivalence — DOM properties', () => {
|
|
|
729
785
|
compare('<div><input title={x()} /></div>')
|
|
730
786
|
})
|
|
731
787
|
})
|
|
788
|
+
|
|
789
|
+
// ─── Reactivity-lens parity (Phase 3) ───────────────────────────────────────
|
|
790
|
+
// The Rust binary must emit the SAME sidecar as the JS oracle so the
|
|
791
|
+
// ~80% of users on the native path get the Lens too. Each fixture is
|
|
792
|
+
// chosen to exercise one of the five structural kinds; `compareLens`
|
|
793
|
+
// also asserts the additive guarantee (codegen byte-identical with the
|
|
794
|
+
// option on vs off, both backends) so this block doubles as the native
|
|
795
|
+
// regression guard for "the lens option must never affect output".
|
|
796
|
+
//
|
|
797
|
+
// Bisect-verified: removing ANY of the 6 `ctx.lens(...)` calls in
|
|
798
|
+
// native/src/lib.rs fails the matching fixture below with an
|
|
799
|
+
// array-length / element mismatch in `compareLens`'s parity assertion
|
|
800
|
+
// (e.g. dropping the `reactive-prop` call → `<Comp value={x()} />` fails
|
|
801
|
+
// `expect(r).toEqual(j)` because the Rust set is missing that span);
|
|
802
|
+
// restored → 9/9 pass.
|
|
803
|
+
describeNative('Reactivity-lens — JS↔Rust span parity', () => {
|
|
804
|
+
test('reactive text child (_bindText)', () =>
|
|
805
|
+
compareLens('<div>{count()}</div>'))
|
|
806
|
+
|
|
807
|
+
test('reactive accessor text child (() => …)', () =>
|
|
808
|
+
compareLens('<div>{() => count()}</div>'))
|
|
809
|
+
|
|
810
|
+
test('static-text child (baked once — the high-precision negative)', () =>
|
|
811
|
+
compareLens('<div>{someConst}</div>'))
|
|
812
|
+
|
|
813
|
+
test('reactive-prop on a component (_rp(() => …))', () =>
|
|
814
|
+
compareLens('<Comp value={count()} />'))
|
|
815
|
+
|
|
816
|
+
test('reactive-attr on a DOM element (live binding)', () =>
|
|
817
|
+
compareLens('<div><span title={count()}>hi</span></div>'))
|
|
818
|
+
|
|
819
|
+
test('hoisted-static (module-scope hoist)', () =>
|
|
820
|
+
compareLens('<Comp>{<b class="x">hi</b>}</Comp>'))
|
|
821
|
+
|
|
822
|
+
test('mixed: reactive + static + prop in one tree', () =>
|
|
823
|
+
compareLens(
|
|
824
|
+
'<section><Comp value={count()} /><p>{count()}</p><p>{label}</p></section>',
|
|
825
|
+
))
|
|
826
|
+
|
|
827
|
+
test('multi-line source — line/column parity across newlines', () =>
|
|
828
|
+
compareLens('<div>\n {count()}\n <span title={other()}>\n z</span>\n</div>'))
|
|
829
|
+
|
|
830
|
+
test('signal auto-call shape — declared signal, bare {count} → reactive', () =>
|
|
831
|
+
compareLens('const count = signal(0); const App = () => <div>{count}</div>', 'auto.tsx'))
|
|
832
|
+
})
|