@pyreon/svelte-compat 0.17.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.
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import * as storeEntry from '../store'
3
+
4
+ /**
5
+ * `@pyreon/svelte-compat/store` is the `svelte/store` import surface —
6
+ * the vite plugin's `compat: 'svelte'` aliases `svelte/store` to it.
7
+ * It must re-export exactly the store API (no lifecycle/context) so the
8
+ * subpath mirrors Svelte's real `svelte/store` shape, and the
9
+ * re-exported functions must be the same identities as `../index`.
10
+ */
11
+ describe('@pyreon/svelte-compat/store entry', () => {
12
+ it('exposes exactly the store API', () => {
13
+ expect(Object.keys(storeEntry).sort()).toEqual([
14
+ 'derived',
15
+ 'get',
16
+ 'readable',
17
+ 'readonly',
18
+ 'writable',
19
+ ])
20
+ })
21
+
22
+ it('re-exported writable/derived/get round-trip through the subpath', () => {
23
+ const n = storeEntry.writable(2)
24
+ const doubled = storeEntry.derived(n, (v: number) => v * 2)
25
+ expect(storeEntry.get(doubled)).toBe(4)
26
+ n.set(5)
27
+ expect(storeEntry.get(doubled)).toBe(10)
28
+
29
+ const ro = storeEntry.readonly(n)
30
+ expect('set' in ro).toBe(false)
31
+ expect(storeEntry.get(ro)).toBe(5)
32
+
33
+ const r = storeEntry.readable(7)
34
+ expect(storeEntry.get(r)).toBe(7)
35
+ })
36
+ })
@@ -0,0 +1,547 @@
1
+ import type { ComponentFn } from '@pyreon/core'
2
+ import { mount as pyreonMount } from '@pyreon/runtime-dom'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import type { RenderContext } from '../jsx-runtime'
5
+ import { beginRender, endRender, getCurrentCtx, jsx } from '../jsx-runtime'
6
+ import {
7
+ afterUpdate,
8
+ beforeUpdate,
9
+ createEventDispatcher,
10
+ derived,
11
+ flushSync,
12
+ get,
13
+ getAllContexts,
14
+ getContext,
15
+ hasContext,
16
+ mount,
17
+ onDestroy,
18
+ onMount,
19
+ readable,
20
+ readonly,
21
+ setContext,
22
+ tick,
23
+ unmount,
24
+ writable,
25
+ } from '../index'
26
+
27
+ // ─── Test helpers ─────────────────────────────────────────────────────────────
28
+
29
+ /** Runs `fn` inside a fresh RenderContext to exercise hook-index code paths. */
30
+ function createHookRunner() {
31
+ const ctx: RenderContext = {
32
+ hooks: [],
33
+ scheduleRerender: () => {},
34
+ pendingEffects: [],
35
+ pendingLayoutEffects: [],
36
+ unmounted: false,
37
+ unmountCallbacks: [],
38
+ }
39
+ return {
40
+ ctx,
41
+ run<T>(fn: () => T): T {
42
+ beginRender(ctx)
43
+ const result = fn()
44
+ endRender()
45
+ return result
46
+ },
47
+ }
48
+ }
49
+
50
+ function container(): HTMLElement {
51
+ const el = document.createElement('div')
52
+ document.body.appendChild(el)
53
+ return el
54
+ }
55
+
56
+ describe('@pyreon/svelte-compat — svelte/store', () => {
57
+ // ─── writable ──────────────────────────────────────────────────────────
58
+
59
+ it('writable calls subscriber synchronously with the initial value', () => {
60
+ const store = writable(1)
61
+ const seen: number[] = []
62
+ store.subscribe((v) => seen.push(v))
63
+ expect(seen).toEqual([1])
64
+ })
65
+
66
+ it('writable set notifies subscribers', () => {
67
+ const store = writable(0)
68
+ const seen: number[] = []
69
+ store.subscribe((v) => seen.push(v))
70
+ store.set(5)
71
+ expect(seen).toEqual([0, 5])
72
+ })
73
+
74
+ it('writable update applies the updater to the current value', () => {
75
+ const store = writable(10)
76
+ const seen: number[] = []
77
+ store.subscribe((v) => seen.push(v))
78
+ store.update((n) => n + 3)
79
+ expect(seen).toEqual([10, 13])
80
+ })
81
+
82
+ it('writable supports multiple independent subscribers', () => {
83
+ const store = writable('a')
84
+ const a: string[] = []
85
+ const b: string[] = []
86
+ store.subscribe((v) => a.push(v))
87
+ store.subscribe((v) => b.push(v))
88
+ store.set('b')
89
+ expect(a).toEqual(['a', 'b'])
90
+ expect(b).toEqual(['a', 'b'])
91
+ })
92
+
93
+ it('writable unsubscribe stops further notifications', () => {
94
+ const store = writable(0)
95
+ const seen: number[] = []
96
+ const unsub = store.subscribe((v) => seen.push(v))
97
+ store.set(1)
98
+ unsub()
99
+ store.set(2)
100
+ expect(seen).toEqual([0, 1])
101
+ })
102
+
103
+ it('writable invalidate fires before run on change', () => {
104
+ const store = writable(0)
105
+ const order: string[] = []
106
+ store.subscribe(
107
+ () => order.push('run'),
108
+ () => order.push('invalidate'),
109
+ )
110
+ store.set(1)
111
+ // Real Svelte semantics: the initial subscribe calls `run` only (no
112
+ // `invalidate`); each subsequent change is the two-phase
113
+ // `invalidate` → `run`. So: run(0), then invalidate(1), run(1).
114
+ expect(order).toEqual(['run', 'invalidate', 'run'])
115
+ })
116
+
117
+ it('writable runs start on first subscriber and stop on last', () => {
118
+ const start = vi.fn(() => stop)
119
+ const stop = vi.fn()
120
+ const store = writable<number>(0, start)
121
+ expect(start).not.toHaveBeenCalled()
122
+ const u1 = store.subscribe(() => {})
123
+ expect(start).toHaveBeenCalledTimes(1)
124
+ const u2 = store.subscribe(() => {})
125
+ expect(start).toHaveBeenCalledTimes(1) // not called again
126
+ u1()
127
+ expect(stop).not.toHaveBeenCalled() // still one subscriber
128
+ u2()
129
+ expect(stop).toHaveBeenCalledTimes(1)
130
+ })
131
+
132
+ it('writable start can push values via its set argument', () => {
133
+ const store = writable<number>(0, (set) => {
134
+ set(42)
135
+ return () => {}
136
+ })
137
+ expect(get(store)).toBe(42)
138
+ })
139
+
140
+ // ─── readable ──────────────────────────────────────────────────────────
141
+
142
+ it('readable exposes only subscribe', () => {
143
+ const store = readable(7)
144
+ expect(typeof store.subscribe).toBe('function')
145
+ expect((store as unknown as Record<string, unknown>).set).toBeUndefined()
146
+ expect(get(store)).toBe(7)
147
+ })
148
+
149
+ it('readable start notifier can drive values', () => {
150
+ const store = readable<number>(0, (set) => {
151
+ set(99)
152
+ })
153
+ expect(get(store)).toBe(99)
154
+ })
155
+
156
+ // ─── readonly ──────────────────────────────────────────────────────────
157
+
158
+ it('readonly returns a subscribe-only view of a writable', () => {
159
+ const w = writable(1)
160
+ const ro = readonly(w)
161
+ expect((ro as unknown as Record<string, unknown>).set).toBeUndefined()
162
+ const seen: number[] = []
163
+ ro.subscribe((v) => seen.push(v))
164
+ w.set(2)
165
+ expect(seen).toEqual([1, 2])
166
+ })
167
+
168
+ // ─── get ───────────────────────────────────────────────────────────────
169
+
170
+ it('get reads the current value without keeping a subscription', () => {
171
+ const store = writable(123)
172
+ expect(get(store)).toBe(123)
173
+ store.set(456)
174
+ expect(get(store)).toBe(456)
175
+ })
176
+
177
+ // ─── derived ───────────────────────────────────────────────────────────
178
+
179
+ it('derived (single store, sync) maps the source value', () => {
180
+ const n = writable(2)
181
+ const doubled = derived(n, (v: number) => v * 2)
182
+ const seen: number[] = []
183
+ doubled.subscribe((v) => seen.push(v))
184
+ expect(seen).toEqual([4])
185
+ n.set(5)
186
+ expect(seen).toEqual([4, 10])
187
+ })
188
+
189
+ it('derived (array of stores, sync) combines values', () => {
190
+ const a = writable(1)
191
+ const b = writable(2)
192
+ const sum = derived([a, b], ([x, y]: [number, number]) => x + y)
193
+ const seen: number[] = []
194
+ sum.subscribe((v) => seen.push(v))
195
+ expect(seen).toEqual([3])
196
+ a.set(10)
197
+ expect(seen).toEqual([3, 12])
198
+ b.set(20)
199
+ expect(seen).toEqual([3, 12, 30])
200
+ })
201
+
202
+ it('derived (async set form) shows the initial value then the async value', async () => {
203
+ const n = writable(1)
204
+ const async = derived(
205
+ n,
206
+ (v: number, set: (x: number) => void) => {
207
+ // Deferred set models a real async source (fetch/timer): the
208
+ // initial value is shown until the async result lands.
209
+ const id = setTimeout(() => set(v + 100), 0)
210
+ return () => clearTimeout(id)
211
+ },
212
+ 0,
213
+ )
214
+ const seen: number[] = []
215
+ async.subscribe((v) => seen.push(v))
216
+ expect(seen).toEqual([0]) // initial value, async not yet resolved
217
+ await new Promise((r) => setTimeout(r, 10))
218
+ expect(seen).toEqual([0, 101])
219
+ n.set(2)
220
+ await new Promise((r) => setTimeout(r, 10))
221
+ expect(seen).toEqual([0, 101, 102])
222
+ })
223
+
224
+ it('derived (async form) runs the returned cleanup before the next run', () => {
225
+ const n = writable(1)
226
+ const cleanup = vi.fn()
227
+ const d = derived(
228
+ n,
229
+ (v: number, set: (x: number) => void) => {
230
+ set(v)
231
+ return cleanup
232
+ },
233
+ 0,
234
+ )
235
+ const unsub = d.subscribe(() => {})
236
+ expect(cleanup).not.toHaveBeenCalled()
237
+ n.set(2) // re-runs sync → previous cleanup fires
238
+ expect(cleanup).toHaveBeenCalledTimes(1)
239
+ unsub() // last subscriber → start's stop runs final cleanup
240
+ expect(cleanup).toHaveBeenCalledTimes(2)
241
+ })
242
+ })
243
+
244
+ describe('@pyreon/svelte-compat — svelte lifecycle', () => {
245
+ it('onMount runs after the first render and its cleanup on unmount', async () => {
246
+ const mounted = vi.fn()
247
+ const cleaned = vi.fn()
248
+ const Comp: ComponentFn = () => {
249
+ onMount(() => {
250
+ mounted()
251
+ return cleaned
252
+ })
253
+ return jsx('div', { children: 'hi' })
254
+ }
255
+ const el = container()
256
+ const dispose = pyreonMount(jsx(Comp, {}), el)
257
+ await new Promise((r) => setTimeout(r, 30))
258
+ expect(mounted).toHaveBeenCalledTimes(1)
259
+ dispose()
260
+ expect(cleaned).toHaveBeenCalledTimes(1)
261
+ })
262
+
263
+ it('onMount is hook-index-stable across re-renders (no double registration)', () => {
264
+ const runner = createHookRunner()
265
+ const fn = vi.fn()
266
+ // First render: registers (pendingEffects gets the entry, hook slot set).
267
+ runner.run(() => onMount(fn))
268
+ expect(runner.ctx.pendingEffects).toHaveLength(1)
269
+ expect(runner.ctx.hooks).toHaveLength(1)
270
+ // Re-render: beginRender clears pendingEffects; the hook-index guard
271
+ // must NOT re-push (onMount runs exactly once, Svelte semantics).
272
+ runner.run(() => onMount(fn))
273
+ expect(runner.ctx.pendingEffects).toHaveLength(0)
274
+ expect(runner.ctx.hooks).toHaveLength(1)
275
+ })
276
+
277
+ it('onMount outside a component falls back to the Pyreon lifecycle', () => {
278
+ // No current ctx → exercises the pyreonOnMount branch (no throw).
279
+ expect(() => onMount(() => {})).not.toThrow()
280
+ })
281
+
282
+ it('onDestroy runs when the component unmounts', async () => {
283
+ const destroyed = vi.fn()
284
+ const Comp: ComponentFn = () => {
285
+ onDestroy(destroyed)
286
+ return jsx('div', { children: 'x' })
287
+ }
288
+ const el = container()
289
+ const dispose = pyreonMount(jsx(Comp, {}), el)
290
+ await new Promise((r) => setTimeout(r, 10))
291
+ dispose()
292
+ expect(destroyed).toHaveBeenCalledTimes(1)
293
+ })
294
+
295
+ it('onDestroy is hook-index-stable across re-renders', () => {
296
+ const runner = createHookRunner()
297
+ const fn = vi.fn()
298
+ runner.run(() => onDestroy(fn))
299
+ runner.run(() => onDestroy(fn))
300
+ expect(runner.ctx.unmountCallbacks).toHaveLength(1)
301
+ })
302
+
303
+ it('onDestroy outside a component does not throw', () => {
304
+ expect(() => onDestroy(() => {})).not.toThrow()
305
+ })
306
+
307
+ it('beforeUpdate runs once before the first render commits', () => {
308
+ const runner = createHookRunner()
309
+ const fn = vi.fn()
310
+ runner.run(() => beforeUpdate(fn))
311
+ runner.run(() => beforeUpdate(fn))
312
+ expect(fn).toHaveBeenCalledTimes(1)
313
+ })
314
+
315
+ it('beforeUpdate outside a component runs immediately', () => {
316
+ const fn = vi.fn()
317
+ beforeUpdate(fn)
318
+ expect(fn).toHaveBeenCalledTimes(1)
319
+ })
320
+
321
+ it('afterUpdate runs after the first render', async () => {
322
+ const fn = vi.fn()
323
+ const Comp: ComponentFn = () => {
324
+ afterUpdate(fn)
325
+ return jsx('div', { children: 'a' })
326
+ }
327
+ const el = container()
328
+ pyreonMount(jsx(Comp, {}), el)
329
+ await new Promise((r) => setTimeout(r, 30))
330
+ expect(fn).toHaveBeenCalledTimes(1)
331
+ })
332
+
333
+ it('tick resolves after the current microtask', async () => {
334
+ let flag = false
335
+ queueMicrotask(() => {
336
+ flag = true
337
+ })
338
+ await tick()
339
+ expect(flag).toBe(true)
340
+ })
341
+ })
342
+
343
+ describe('@pyreon/svelte-compat — svelte context', () => {
344
+ it('setContext/getContext propagate through the component tree', () => {
345
+ const KEY = Symbol('theme')
346
+ const Consumer: ComponentFn = () => {
347
+ const theme = getContext<string>(KEY)
348
+ return jsx('span', { 'data-theme': theme, children: theme })
349
+ }
350
+ const Provider: ComponentFn = () => {
351
+ setContext(KEY, 'dark')
352
+ return jsx(Consumer, {})
353
+ }
354
+ const el = container()
355
+ pyreonMount(jsx(Provider, { children: undefined }), el)
356
+ const span = el.querySelector('span')
357
+ expect(span?.getAttribute('data-theme')).toBe('dark')
358
+ })
359
+
360
+ it('hasContext reports whether a value was provided', () => {
361
+ const KEY = 'k'
362
+ let inside = false
363
+ let outside = true
364
+ const Consumer: ComponentFn = () => {
365
+ inside = hasContext(KEY)
366
+ return jsx('i', {})
367
+ }
368
+ const Provider: ComponentFn = () => {
369
+ setContext(KEY, 1)
370
+ return jsx(Consumer, {})
371
+ }
372
+ const Bare: ComponentFn = () => {
373
+ outside = hasContext('never-set')
374
+ return jsx('i', {})
375
+ }
376
+ const el = container()
377
+ pyreonMount(jsx(Provider, {}), el)
378
+ pyreonMount(jsx(Bare, {}), container())
379
+ expect(inside).toBe(true)
380
+ expect(outside).toBe(false)
381
+ })
382
+
383
+ it('getAllContexts returns a Map (best-effort)', () => {
384
+ expect(getAllContexts()).toBeInstanceOf(Map)
385
+ })
386
+
387
+ it('setContext returns the provided value', () => {
388
+ let returned: unknown
389
+ const Comp: ComponentFn = () => {
390
+ returned = setContext('x', 99)
391
+ return jsx('i', {})
392
+ }
393
+ pyreonMount(jsx(Comp, {}), container())
394
+ expect(returned).toBe(99)
395
+ })
396
+ })
397
+
398
+ describe('@pyreon/svelte-compat — createEventDispatcher', () => {
399
+ it('dispatches a CustomEvent to the on<Type> prop', async () => {
400
+ const handler = vi.fn()
401
+ const Child: ComponentFn = () => {
402
+ const dispatch = createEventDispatcher<{ ping: number }>()
403
+ onMount(() => {
404
+ dispatch('ping', 7)
405
+ })
406
+ return jsx('span', { children: 'child' })
407
+ }
408
+ const Parent: ComponentFn = () => jsx(Child, { onPing: handler })
409
+ const el = container()
410
+ pyreonMount(jsx(Parent, {}), el)
411
+ await new Promise((r) => setTimeout(r, 30))
412
+ expect(handler).toHaveBeenCalledTimes(1)
413
+ const evt = handler.mock.calls[0]![0] as CustomEvent
414
+ expect(evt.type).toBe('ping')
415
+ expect(evt.detail).toBe(7)
416
+ })
417
+
418
+ it('returns true when the event is not cancelled', () => {
419
+ let result: boolean | undefined
420
+ const Child: ComponentFn = () => {
421
+ const dispatch = createEventDispatcher<{ go: void }>()
422
+ result = dispatch('go')
423
+ return jsx('i', {})
424
+ }
425
+ pyreonMount(jsx(Child, {}), container())
426
+ expect(result).toBe(true)
427
+ })
428
+ })
429
+
430
+ describe('@pyreon/svelte-compat — mount/unmount/flushSync', () => {
431
+ it('mount renders a component into the target and unmount removes it', () => {
432
+ const App = () => jsx('div', { id: 'svelte-app', children: 'mounted' })
433
+ const target = container()
434
+ const mounted = mount(App, { target })
435
+ expect(target.querySelector('#svelte-app')?.textContent).toBe('mounted')
436
+ unmount(mounted as Record<symbol, unknown>)
437
+ expect(target.querySelector('#svelte-app')).toBeNull()
438
+ })
439
+
440
+ it('mount passes props through to the component', () => {
441
+ const App = (props: { label: string }) => jsx('div', { id: 'lbl', children: props.label })
442
+ const target = container()
443
+ mount(App, { target, props: { label: 'hello' } })
444
+ expect(target.querySelector('#lbl')?.textContent).toBe('hello')
445
+ })
446
+
447
+ it('unmount is a no-op on an object never mounted', () => {
448
+ expect(() => unmount({} as Record<symbol, unknown>)).not.toThrow()
449
+ })
450
+
451
+ it('flushSync invokes fn and returns its result', () => {
452
+ expect(flushSync(() => 42)).toBe(42)
453
+ })
454
+
455
+ it('flushSync with no fn returns undefined', () => {
456
+ expect(flushSync()).toBeUndefined()
457
+ })
458
+ })
459
+
460
+ describe('@pyreon/svelte-compat — jsx-runtime coverage', () => {
461
+ it('native components pass through without wrapping', async () => {
462
+ const { Show } = await import('../index')
463
+ const vnode = jsx(Show as ComponentFn, {
464
+ when: () => true,
465
+ children: jsx('span', { children: 'hi' }),
466
+ })
467
+ expect(vnode.type).toBe(Show)
468
+ })
469
+
470
+ it('jsx forwards a key prop', () => {
471
+ const vnode = jsx('div', { children: 'test' }, 'my-key')
472
+ expect(vnode.props.key).toBe('my-key')
473
+ })
474
+
475
+ it('jsx handles no children and array children', () => {
476
+ expect(jsx('div', { class: 'empty' })).toBeDefined()
477
+ const list = jsx('ul', {
478
+ children: [jsx('li', { children: 'a' }), jsx('li', { children: 'b' })],
479
+ })
480
+ expect(list).toBeDefined()
481
+ })
482
+
483
+ it('wrapCompatComponent caches the wrapper per component', () => {
484
+ const Comp: ComponentFn = () => jsx('div', { children: 'c' })
485
+ const v1 = jsx(Comp, {})
486
+ const v2 = jsx(Comp, {})
487
+ expect(v1.type).toBe(v2.type)
488
+ })
489
+
490
+ it('component re-renders on store change and patches the DOM', async () => {
491
+ const count = writable(0)
492
+ const Comp: ComponentFn = () => {
493
+ let v = 0
494
+ count.subscribe((n) => {
495
+ v = n
496
+ })
497
+ return jsx('span', { id: 'cnt', children: String(v) })
498
+ }
499
+ const el = container()
500
+ const dispose = pyreonMount(jsx(Comp, {}), el)
501
+ expect(el.querySelector('#cnt')?.textContent).toBe('0')
502
+ count.set(3)
503
+ await new Promise((r) => setTimeout(r, 30))
504
+ expect(el.querySelector('#cnt')?.textContent).toBe('3')
505
+ dispose()
506
+ })
507
+
508
+ it('scheduleRerender after unmount does not re-render', async () => {
509
+ const count = writable(0)
510
+ let renders = 0
511
+ const Comp: ComponentFn = () => {
512
+ renders++
513
+ count.subscribe(() => {})
514
+ return jsx('span', { children: 'x' })
515
+ }
516
+ const el = container()
517
+ const dispose = pyreonMount(jsx(Comp, {}), el)
518
+ await new Promise((r) => setTimeout(r, 10))
519
+ const before = renders
520
+ dispose()
521
+ count.set(1)
522
+ await new Promise((r) => setTimeout(r, 30))
523
+ expect(renders).toBe(before)
524
+ })
525
+
526
+ it('layout effects pushed during render run with cleanup', () => {
527
+ const log: string[] = []
528
+ let pushed = false
529
+ const Comp: ComponentFn = () => {
530
+ const ctx = getCurrentCtx()!
531
+ if (!pushed) {
532
+ pushed = true
533
+ ctx.pendingLayoutEffects.push({
534
+ fn: () => {
535
+ log.push('run')
536
+ return () => log.push('cleanup')
537
+ },
538
+ deps: undefined,
539
+ cleanup: undefined,
540
+ })
541
+ }
542
+ return jsx('div', { children: 'lf' })
543
+ }
544
+ pyreonMount(jsx(Comp, {}), container())
545
+ expect(log).toContain('run')
546
+ })
547
+ })