@pyreon/runtime-dom 0.12.7 → 0.12.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/runtime-dom",
3
- "version": "0.12.7",
3
+ "version": "0.12.8",
4
4
  "description": "DOM renderer for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/runtime-dom#readme",
6
6
  "bugs": {
@@ -42,11 +42,13 @@
42
42
  "prepublishOnly": "bun run build"
43
43
  },
44
44
  "dependencies": {
45
- "@pyreon/core": "^0.12.7",
46
- "@pyreon/reactivity": "^0.12.7"
45
+ "@pyreon/core": "^0.12.8",
46
+ "@pyreon/reactivity": "^0.12.8"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@happy-dom/global-registrator": "^20.8.9",
50
+ "@pyreon/compiler": "^0.12.8",
51
+ "@pyreon/runtime-server": "^0.12.8",
50
52
  "happy-dom": "^20.8.3"
51
53
  }
52
54
  }
@@ -0,0 +1,508 @@
1
+ /**
2
+ * Compiler Integration Tests
3
+ *
4
+ * Full pipeline: source code -> transformJSX -> runtime mount -> signal change -> DOM verification.
5
+ *
6
+ * The compiler emits code referencing _tpl, _bind, _bindText, _bindDirect, _rp, h.
7
+ * We strip the import lines from compiler output, inject dependencies via Function
8
+ * constructor, execute, mount the result, and assert DOM state.
9
+ */
10
+ import { transformJSX } from '@pyreon/compiler'
11
+ import { Fragment, h, _rp } from '@pyreon/core'
12
+ import { _bind, signal } from '@pyreon/reactivity'
13
+ import { _tpl, _bindText, _bindDirect } from '../template'
14
+ import { _applyProps, mount, mountChild } from '../index'
15
+
16
+ // ─── Helpers ────────────────────────────────────────────────────────────────
17
+
18
+ /** Strip import lines from compiler output — we pass deps via Function args. */
19
+ function stripImports(code: string): string {
20
+ return code.replace(/^import\s+.*$/gm, '').trim()
21
+ }
22
+
23
+ /** Runtime deps that the compiler output references. */
24
+ const RUNTIME_DEPS = {
25
+ _tpl,
26
+ _bind,
27
+ _bindText,
28
+ _bindDirect,
29
+ _applyProps,
30
+ _rp,
31
+ h,
32
+ Fragment,
33
+ signal,
34
+ document,
35
+ } as const
36
+
37
+ const DEP_NAMES = Object.keys(RUNTIME_DEPS)
38
+ const DEP_VALUES = Object.values(RUNTIME_DEPS)
39
+
40
+ /**
41
+ * Compile JSX source and execute it, returning the resulting NativeItem or VNode.
42
+ * For component definitions, pass the component source and call it separately.
43
+ */
44
+ function compileExpression(source: string) {
45
+ const result = transformJSX(source, 'test.tsx')
46
+ const body = stripImports(result.code)
47
+ return { body, code: result.code }
48
+ }
49
+
50
+ /**
51
+ * Compile a standalone JSX expression (not a component), execute it,
52
+ * mount it into a container, and return { container, cleanup, code }.
53
+ */
54
+ function compileAndMount(
55
+ source: string,
56
+ globals: Record<string, unknown> = {},
57
+ ) {
58
+ const { body, code } = compileExpression(source)
59
+
60
+ const globalNames = Object.keys(globals)
61
+ const globalValues = Object.values(globals)
62
+
63
+ // The body is an expression (e.g. _tpl(...)) — wrap in return
64
+ const fn = new Function(
65
+ ...DEP_NAMES,
66
+ ...globalNames,
67
+ `return ${body}`,
68
+ )
69
+
70
+ const result = fn(...DEP_VALUES, ...globalValues)
71
+ const container = document.createElement('div')
72
+ document.body.appendChild(container)
73
+ const cleanup = mountChild(result, container)
74
+
75
+ return { container, cleanup, code }
76
+ }
77
+
78
+ /**
79
+ * Compile a component definition, extract the component function,
80
+ * mount it with given props, and return { container, cleanup, code }.
81
+ */
82
+ function compileComponent(
83
+ source: string,
84
+ props: Record<string, unknown> = {},
85
+ globals: Record<string, unknown> = {},
86
+ ) {
87
+ const { body, code } = compileExpression(source)
88
+
89
+ const globalNames = Object.keys(globals)
90
+ const globalValues = Object.values(globals)
91
+
92
+ // Component defs are statements like `const Comp = (props) => _tpl(...)`.
93
+ // We execute the body then return the named component.
94
+ // Extract the component name from the source.
95
+ const nameMatch = body.match(/^const\s+(\w+)\s*=/)
96
+ if (!nameMatch) throw new Error('Could not find component name in compiled output')
97
+ const compName = nameMatch[1]
98
+
99
+ const fn = new Function(
100
+ ...DEP_NAMES,
101
+ ...globalNames,
102
+ `${body}\nreturn ${compName}`,
103
+ )
104
+
105
+ const Component = fn(...DEP_VALUES, ...globalValues)
106
+
107
+ // Build reactive props (same as what the runtime does when mounting h(Comp, props))
108
+ const container = document.createElement('div')
109
+ document.body.appendChild(container)
110
+
111
+ const vnode = h(Component, props)
112
+ const cleanup = mountChild(vnode, container)
113
+
114
+ return { container, cleanup, code }
115
+ }
116
+
117
+ function createContainer(): HTMLDivElement {
118
+ const el = document.createElement('div')
119
+ document.body.appendChild(el)
120
+ return el
121
+ }
122
+
123
+ // ─── Tests ──────────────────────────────────────────────────────────────────
124
+
125
+ describe('compiler integration — signal text reactivity', () => {
126
+ afterEach(() => {
127
+ document.body.innerHTML = ''
128
+ })
129
+
130
+ it('signal() in text — _bindText updates DOM on signal.set', () => {
131
+ const count = signal(0)
132
+ const { container } = compileAndMount(
133
+ '<div>{count()}</div>',
134
+ { count },
135
+ )
136
+
137
+ expect(container.querySelector('div')!.textContent).toBe('0')
138
+
139
+ count.set(42)
140
+ expect(container.querySelector('div')!.textContent).toBe('42')
141
+
142
+ count.set(-1)
143
+ expect(container.querySelector('div')!.textContent).toBe('-1')
144
+ })
145
+
146
+ it('two independent signals — changing one does not affect the other', () => {
147
+ const a = signal('hello')
148
+ const b = signal('world')
149
+ const { container } = compileAndMount(
150
+ '<div><span>{a()}</span><span>{b()}</span></div>',
151
+ { a, b },
152
+ )
153
+
154
+ const spans = container.querySelectorAll('span')
155
+ expect(spans[0]!.textContent).toBe('hello')
156
+ expect(spans[1]!.textContent).toBe('world')
157
+
158
+ a.set('changed')
159
+ expect(spans[0]!.textContent).toBe('changed')
160
+ expect(spans[1]!.textContent).toBe('world')
161
+
162
+ b.set('updated')
163
+ expect(spans[0]!.textContent).toBe('changed')
164
+ expect(spans[1]!.textContent).toBe('updated')
165
+ })
166
+ })
167
+
168
+ describe('compiler integration — props reactivity', () => {
169
+ afterEach(() => {
170
+ document.body.innerHTML = ''
171
+ })
172
+
173
+ it('props.name in text — reactive via _bind', () => {
174
+ const name = signal('Alice')
175
+ const { container } = compileComponent(
176
+ 'const Comp = (props) => <div>{props.name}</div>',
177
+ { name: _rp(() => name()) },
178
+ )
179
+
180
+ expect(container.querySelector('div')!.textContent).toBe('Alice')
181
+
182
+ name.set('Bob')
183
+ expect(container.querySelector('div')!.textContent).toBe('Bob')
184
+ })
185
+
186
+ it('const x = props.y ?? "def" — compiler inlines props.y, reactive', () => {
187
+ const y = signal<string | undefined>(undefined)
188
+ const { container } = compileComponent(
189
+ 'const Comp = (props) => { const x = props.y ?? "def"; return <div>{x}</div> }',
190
+ { y: _rp(() => y()) },
191
+ )
192
+
193
+ // Initially undefined, so ?? "def" should produce "def"
194
+ expect(container.querySelector('div')!.textContent).toBe('def')
195
+
196
+ y.set('custom')
197
+ expect(container.querySelector('div')!.textContent).toBe('custom')
198
+
199
+ y.set(undefined)
200
+ expect(container.querySelector('div')!.textContent).toBe('def')
201
+ })
202
+
203
+ it('multiple uses of same const derived from props — both update', () => {
204
+ const y = signal('A')
205
+ const { container } = compileComponent(
206
+ 'const Comp = (props) => { const x = props.y; return <div><span>{x}</span><p>{x}</p></div> }',
207
+ { y: _rp(() => y()) },
208
+ )
209
+
210
+ expect(container.querySelector('span')!.textContent).toBe('A')
211
+ expect(container.querySelector('p')!.textContent).toBe('A')
212
+
213
+ y.set('B')
214
+ expect(container.querySelector('span')!.textContent).toBe('B')
215
+ expect(container.querySelector('p')!.textContent).toBe('B')
216
+ })
217
+
218
+ it('let x = props.y — NOT reactive (let is mutable, unsafe to inline)', () => {
219
+ const y = signal('initial')
220
+ const { container } = compileComponent(
221
+ 'const Comp = (props) => { let x = props.y; return <div>{x}</div> }',
222
+ { y: _rp(() => y()) },
223
+ )
224
+
225
+ // Initial value captured at component creation time
226
+ expect(container.querySelector('div')!.textContent).toBe('initial')
227
+
228
+ // Signal change should NOT update — let variables are not inlined
229
+ y.set('changed')
230
+ expect(container.querySelector('div')!.textContent).toBe('initial')
231
+ })
232
+ })
233
+
234
+ describe('compiler integration — class attribute reactivity', () => {
235
+ afterEach(() => {
236
+ document.body.innerHTML = ''
237
+ })
238
+
239
+ it('class={cls()} — _bindDirect updates className on signal change', () => {
240
+ const cls = signal('active')
241
+ const { container } = compileAndMount(
242
+ '<div class={cls()}></div>',
243
+ { cls },
244
+ )
245
+
246
+ expect(container.querySelector('div')!.className).toBe('active')
247
+
248
+ cls.set('inactive')
249
+ expect(container.querySelector('div')!.className).toBe('inactive')
250
+
251
+ cls.set('')
252
+ expect(container.querySelector('div')!.className).toBe('')
253
+ })
254
+ })
255
+
256
+ describe('compiler integration — static content', () => {
257
+ afterEach(() => {
258
+ document.body.innerHTML = ''
259
+ })
260
+
261
+ it('purely static JSX — no bindings, renders correctly', () => {
262
+ const { container, code } = compileAndMount(
263
+ '<div class="box"><span>hello</span></div>',
264
+ )
265
+
266
+ expect(container.querySelector('div')!.className).toBe('box')
267
+ expect(container.querySelector('span')!.textContent).toBe('hello')
268
+ // Verify the compiler output has no reactive bindings
269
+ expect(code).toContain('() => null')
270
+ })
271
+ })
272
+
273
+ describe('compiler integration — SVG', () => {
274
+ afterEach(() => {
275
+ document.body.innerHTML = ''
276
+ })
277
+
278
+ it('SVG element renders correctly via _tpl', () => {
279
+ const { container } = compileAndMount(
280
+ '<svg><circle cx="50" cy="50" r="40"></circle></svg>',
281
+ )
282
+
283
+ const svg = container.querySelector('svg')
284
+ expect(svg).not.toBeNull()
285
+ const circle = container.querySelector('circle')
286
+ expect(circle).not.toBeNull()
287
+ expect(circle!.getAttribute('cx')).toBe('50')
288
+ expect(circle!.getAttribute('cy')).toBe('50')
289
+ expect(circle!.getAttribute('r')).toBe('40')
290
+ })
291
+ })
292
+
293
+ describe('compiler integration — component element with _rp', () => {
294
+ afterEach(() => {
295
+ document.body.innerHTML = ''
296
+ })
297
+
298
+ it('component prop wrapped with _rp — reactive when signal changes', () => {
299
+ const name = signal('Alice')
300
+ let mountCount = 0
301
+
302
+ const MyComponent = (props: { name: string }) => {
303
+ mountCount++
304
+ return h('span', null, () => props.name)
305
+ }
306
+
307
+ // The compiler emits: <MyComponent name={_rp(() => count())} />
308
+ // which is equivalent to h(MyComponent, { name: _rp(() => name()) })
309
+ const container = createContainer()
310
+ mount(h(MyComponent, { name: _rp(() => name()) }), container)
311
+
312
+ expect(mountCount).toBe(1)
313
+ expect(container.querySelector('span')!.textContent).toBe('Alice')
314
+
315
+ name.set('Bob')
316
+ expect(mountCount).toBe(1) // no remount
317
+ expect(container.querySelector('span')!.textContent).toBe('Bob')
318
+ })
319
+ })
320
+
321
+ describe('compiler integration — compiler output structure', () => {
322
+ it('signal in text emits _bindText import and call', () => {
323
+ const { code } = transformJSX('<div>{count()}</div>', 'test.tsx')
324
+ expect(code).toContain('import { _tpl, _bindText } from "@pyreon/runtime-dom"')
325
+ expect(code).toContain('_bindText(count,')
326
+ })
327
+
328
+ it('props.name emits _bind import from @pyreon/reactivity', () => {
329
+ const { code } = transformJSX(
330
+ 'const Comp = (props) => <div>{props.name}</div>',
331
+ 'test.tsx',
332
+ )
333
+ expect(code).toContain('import { _bind } from "@pyreon/reactivity"')
334
+ expect(code).toContain('_bind(() => { __t0.data = props.name })')
335
+ })
336
+
337
+ it('class={cls()} emits _bindDirect', () => {
338
+ const { code } = transformJSX('<div class={cls()}></div>', 'test.tsx')
339
+ expect(code).toContain('_bindDirect(cls,')
340
+ expect(code).toContain('__root.className')
341
+ })
342
+
343
+ it('component reactive prop emits _rp wrapping', () => {
344
+ const { code } = transformJSX('<Button label={getText()} />', 'test.tsx')
345
+ expect(code).toContain('_rp(() => getText())')
346
+ })
347
+
348
+ it('const from props gets inlined back to props.y in JSX', () => {
349
+ const { code } = transformJSX(
350
+ 'const Comp = (props) => { const x = props.y; return <div>{x}</div> }',
351
+ 'test.tsx',
352
+ )
353
+ // Compiler inlines: const x = props.y → uses props.y directly in _bind
354
+ expect(code).toContain('__t0.data = (props.y)')
355
+ })
356
+
357
+ it('let from props does NOT get inlined — uses captured value', () => {
358
+ const { code } = transformJSX(
359
+ 'const Comp = (props) => { let x = props.y; return <div>{x}</div> }',
360
+ 'test.tsx',
361
+ )
362
+ // let is not inlined — uses static textContent assignment
363
+ expect(code).toContain('__root.textContent = x')
364
+ expect(code).not.toContain('_bind')
365
+ })
366
+
367
+ it('static JSX emits _tpl with null bind function', () => {
368
+ const { code } = transformJSX(
369
+ '<div class="box"><span>hello</span></div>',
370
+ 'test.tsx',
371
+ )
372
+ expect(code).toContain('() => null')
373
+ expect(code).not.toContain('_bind')
374
+ expect(code).not.toContain('_bindText')
375
+ })
376
+ })
377
+
378
+ // ─── Additional edge cases ──────────────────────────────────────────────────
379
+
380
+ describe('compiler integration — prop-derived with defaults', () => {
381
+ afterEach(() => { document.body.innerHTML = '' })
382
+
383
+ it('props.x ?? default — starts with default, updates when set', () => {
384
+ const x = signal<string | undefined>(undefined)
385
+ const { container } = compileComponent(
386
+ 'const Comp = (props) => { const label = props.x ?? "fallback"; return <span>{label}</span> }',
387
+ { x: _rp(() => x()) },
388
+ )
389
+ expect(container.querySelector('span')!.textContent).toBe('fallback')
390
+ x.set('real')
391
+ expect(container.querySelector('span')!.textContent).toBe('real')
392
+ x.set(undefined)
393
+ expect(container.querySelector('span')!.textContent).toBe('fallback')
394
+ })
395
+
396
+ it('props.x || default — falsy fallback works', () => {
397
+ const x = signal('')
398
+ const { container } = compileComponent(
399
+ 'const Comp = (props) => { const v = props.x || "empty"; return <span>{v}</span> }',
400
+ { x: _rp(() => x()) },
401
+ )
402
+ expect(container.querySelector('span')!.textContent).toBe('empty')
403
+ x.set('filled')
404
+ expect(container.querySelector('span')!.textContent).toBe('filled')
405
+ })
406
+ })
407
+
408
+ describe('compiler integration — ternary and expressions', () => {
409
+ afterEach(() => { document.body.innerHTML = '' })
410
+
411
+ it('ternary with signal — updates on change', () => {
412
+ const on = signal(true)
413
+ const { container } = compileAndMount(
414
+ '<div>{on() ? "yes" : "no"}</div>',
415
+ { on },
416
+ )
417
+ expect(container.querySelector('div')!.textContent).toBe('yes')
418
+ on.set(false)
419
+ expect(container.querySelector('div')!.textContent).toBe('no')
420
+ })
421
+
422
+ it('template literal with signal', () => {
423
+ const name = signal('world')
424
+ const { container } = compileAndMount(
425
+ '<div>{`hello ${name()}`}</div>',
426
+ { name },
427
+ )
428
+ expect(container.querySelector('div')!.textContent).toBe('hello world')
429
+ name.set('Pyreon')
430
+ expect(container.querySelector('div')!.textContent).toBe('hello Pyreon')
431
+ })
432
+
433
+ it('arithmetic with signal', () => {
434
+ const n = signal(5)
435
+ const { container } = compileAndMount(
436
+ '<div>{n() * 2 + 1}</div>',
437
+ { n },
438
+ )
439
+ expect(container.querySelector('div')!.textContent).toBe('11')
440
+ n.set(10)
441
+ expect(container.querySelector('div')!.textContent).toBe('21')
442
+ })
443
+ })
444
+
445
+ describe('compiler integration — multiple attributes', () => {
446
+ afterEach(() => { document.body.innerHTML = '' })
447
+
448
+ it('reactive class + static id', () => {
449
+ const cls = signal('a')
450
+ const { container } = compileAndMount(
451
+ '<div id="fixed" class={cls()}></div>',
452
+ { cls },
453
+ )
454
+ const div = container.querySelector('div')!
455
+ expect(div.id).toBe('fixed')
456
+ expect(div.className).toBe('a')
457
+ cls.set('b')
458
+ expect(div.id).toBe('fixed')
459
+ expect(div.className).toBe('b')
460
+ })
461
+
462
+ it('reactive style string', () => {
463
+ const color = signal('red')
464
+ const { container } = compileAndMount(
465
+ '<div style={`color: ${color()}`}></div>',
466
+ { color },
467
+ )
468
+ expect(container.querySelector('div')!.style.color).toBe('red')
469
+ color.set('blue')
470
+ expect(container.querySelector('div')!.style.color).toBe('blue')
471
+ })
472
+ })
473
+
474
+ describe('compiler integration — prop-derived in attributes', () => {
475
+ afterEach(() => { document.body.innerHTML = '' })
476
+
477
+ it('const cls = props.class ?? "default" on class attr', () => {
478
+ const c = signal<string | undefined>(undefined)
479
+ const { container } = compileComponent(
480
+ 'const Comp = (props) => { const cls = props.class ?? "default"; return <div class={cls}></div> }',
481
+ { class: _rp(() => c()) },
482
+ )
483
+ expect(container.querySelector('div')!.className).toBe('default')
484
+ c.set('custom')
485
+ expect(container.querySelector('div')!.className).toBe('custom')
486
+ })
487
+ })
488
+
489
+ describe('compiler integration — no false inlining', () => {
490
+ it('.map callback param not treated as props', () => {
491
+ const { code } = transformJSX(
492
+ 'function App(props) { return <div>{items.map((item) => { const name = item.name; return <span>{name}</span> })}</div> }',
493
+ 'test.tsx',
494
+ )
495
+ // item.name should NOT be inlined — item is a callback param, not props
496
+ expect(code).not.toContain('(item.name)')
497
+ })
498
+
499
+ it('property access obj.x where x is also a prop-derived var', () => {
500
+ const { code } = transformJSX(
501
+ 'const Comp = (props) => { const x = props.x; return <div>{other.x}</div> }',
502
+ 'test.tsx',
503
+ )
504
+ // other.x should stay as other.x — not replaced with (props.x)
505
+ // sliceExpr only replaces standalone identifiers, not property access
506
+ expect(code).not.toContain('other.(props.x)')
507
+ })
508
+ })