@pyreon/preact-compat 0.1.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/LICENSE +21 -0
- package/README.md +76 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +110 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +61 -0
- package/lib/types/index.d.ts.map +1 -0
- package/package.json +59 -0
- package/src/hooks.ts +131 -0
- package/src/index.ts +167 -0
- package/src/signals.ts +92 -0
- package/src/tests/preact-compat.test.ts +522 -0
- package/src/tests/setup.ts +3 -0
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import type { VNodeChild } from "@pyreon/core"
|
|
2
|
+
import { h as pyreonH } from "@pyreon/core"
|
|
3
|
+
import { signal as pyreonSignal } from "@pyreon/reactivity"
|
|
4
|
+
import { mount } from "@pyreon/runtime-dom"
|
|
5
|
+
import {
|
|
6
|
+
useCallback,
|
|
7
|
+
useEffect,
|
|
8
|
+
useErrorBoundary,
|
|
9
|
+
useId,
|
|
10
|
+
useLayoutEffect,
|
|
11
|
+
useMemo,
|
|
12
|
+
useReducer,
|
|
13
|
+
useRef,
|
|
14
|
+
useState,
|
|
15
|
+
} from "../hooks"
|
|
16
|
+
import {
|
|
17
|
+
Component,
|
|
18
|
+
cloneElement,
|
|
19
|
+
createContext,
|
|
20
|
+
createElement,
|
|
21
|
+
createRef,
|
|
22
|
+
Fragment,
|
|
23
|
+
h,
|
|
24
|
+
hydrate,
|
|
25
|
+
isValidElement,
|
|
26
|
+
options,
|
|
27
|
+
render,
|
|
28
|
+
toChildArray,
|
|
29
|
+
useContext,
|
|
30
|
+
} from "../index"
|
|
31
|
+
import { batch, computed, effect, signal } from "../signals"
|
|
32
|
+
|
|
33
|
+
function container(): HTMLElement {
|
|
34
|
+
const el = document.createElement("div")
|
|
35
|
+
document.body.appendChild(el)
|
|
36
|
+
return el
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("@pyreon/preact-compat", () => {
|
|
40
|
+
// ─── Core API ────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
test("h() creates VNodes", () => {
|
|
43
|
+
const vnode = h("div", { class: "test" }, "hello")
|
|
44
|
+
expect(vnode.type).toBe("div")
|
|
45
|
+
expect(vnode.props.class).toBe("test")
|
|
46
|
+
expect(vnode.children).toContain("hello")
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("createElement is alias for h", () => {
|
|
50
|
+
expect(createElement).toBe(h)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("Fragment is a symbol", () => {
|
|
54
|
+
expect(typeof Fragment).toBe("symbol")
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test("render() mounts to DOM", () => {
|
|
58
|
+
const el = container()
|
|
59
|
+
render(h("span", null, "mounted"), el)
|
|
60
|
+
expect(el.innerHTML).toContain("mounted")
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("hydrate() calls hydrateRoot", () => {
|
|
64
|
+
const el = container()
|
|
65
|
+
el.innerHTML = "<span>hydrated</span>"
|
|
66
|
+
// hydrate should not throw; it calls hydrateRoot internally
|
|
67
|
+
hydrate(h("span", null, "hydrated"), el)
|
|
68
|
+
expect(el.innerHTML).toContain("hydrated")
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("isValidElement detects VNodes", () => {
|
|
72
|
+
const vnode = h("div", null)
|
|
73
|
+
expect(isValidElement(vnode)).toBe(true)
|
|
74
|
+
expect(isValidElement(null)).toBe(false)
|
|
75
|
+
expect(isValidElement("string")).toBe(false)
|
|
76
|
+
expect(isValidElement(42)).toBe(false)
|
|
77
|
+
expect(isValidElement({ type: "div", props: {}, children: [] })).toBe(true)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("isValidElement returns false for objects missing required keys", () => {
|
|
81
|
+
expect(isValidElement({ type: "div" })).toBe(false)
|
|
82
|
+
expect(isValidElement({ type: "div", props: {} })).toBe(false)
|
|
83
|
+
expect(isValidElement({})).toBe(false)
|
|
84
|
+
expect(isValidElement(undefined)).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test("toChildArray flattens children", () => {
|
|
88
|
+
const result = toChildArray(["a", ["b", ["c"]], null, undefined, false, "d"] as VNodeChild[])
|
|
89
|
+
expect(result).toEqual(["a", "b", "c", "d"])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test("toChildArray handles single non-array child", () => {
|
|
93
|
+
const result = toChildArray("hello")
|
|
94
|
+
expect(result).toEqual(["hello"])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("toChildArray handles null/undefined/boolean at top level", () => {
|
|
98
|
+
expect(toChildArray(null as unknown as VNodeChild)).toEqual([])
|
|
99
|
+
expect(toChildArray(undefined as unknown as VNodeChild)).toEqual([])
|
|
100
|
+
expect(toChildArray(false as unknown as VNodeChild)).toEqual([])
|
|
101
|
+
expect(toChildArray(true as unknown as VNodeChild)).toEqual([])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test("toChildArray handles number children", () => {
|
|
105
|
+
const result = toChildArray([1, 2, 3] as VNodeChild[])
|
|
106
|
+
expect(result).toEqual([1, 2, 3])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test("cloneElement merges props", () => {
|
|
110
|
+
const original = h("div", { class: "a", id: "x" }, "child")
|
|
111
|
+
const cloned = cloneElement(original, { class: "b" })
|
|
112
|
+
expect(cloned.type).toBe("div")
|
|
113
|
+
expect(cloned.props.class).toBe("b")
|
|
114
|
+
expect(cloned.props.id).toBe("x")
|
|
115
|
+
expect(cloned.children).toContain("child")
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test("cloneElement replaces children when provided", () => {
|
|
119
|
+
const original = h("div", null, "old")
|
|
120
|
+
const cloned = cloneElement(original, undefined, "new")
|
|
121
|
+
expect(cloned.children).toContain("new")
|
|
122
|
+
expect(cloned.children).not.toContain("old")
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("cloneElement preserves key from original when not overridden", () => {
|
|
126
|
+
const original = h("div", { key: "original-key" }, "child")
|
|
127
|
+
const cloned = cloneElement(original, { class: "b" })
|
|
128
|
+
expect(cloned.key).toBe("original-key")
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test("cloneElement overrides key when provided in props", () => {
|
|
132
|
+
const original = h("div", { key: "original-key" }, "child")
|
|
133
|
+
const cloned = cloneElement(original, { key: "new-key" })
|
|
134
|
+
expect(cloned.key).toBe("new-key")
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test("cloneElement with no props passes empty override", () => {
|
|
138
|
+
const original = h("div", { id: "test" }, "child")
|
|
139
|
+
const cloned = cloneElement(original)
|
|
140
|
+
expect(cloned.props.id).toBe("test")
|
|
141
|
+
expect(cloned.children).toContain("child")
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test("createRef returns { current: null }", () => {
|
|
145
|
+
const ref = createRef()
|
|
146
|
+
expect(ref.current).toBe(null)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test("createContext/useContext work", () => {
|
|
150
|
+
const Ctx = createContext("default")
|
|
151
|
+
// Without a provider, useContext returns the default value
|
|
152
|
+
expect(useContext(Ctx)).toBe("default")
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test("options is an empty object", () => {
|
|
156
|
+
expect(typeof options).toBe("object")
|
|
157
|
+
expect(Object.keys(options).length).toBe(0)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test("Component class setState updates state with object", () => {
|
|
161
|
+
class Counter extends Component<Record<string, never>, { count: number }> {
|
|
162
|
+
constructor(props: Record<string, never>) {
|
|
163
|
+
super(props)
|
|
164
|
+
this.state = { count: 0 }
|
|
165
|
+
}
|
|
166
|
+
override render() {
|
|
167
|
+
return h("span", null, String(this.state.count))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const c = new Counter({})
|
|
171
|
+
expect(c.state.count).toBe(0)
|
|
172
|
+
c.setState({ count: 5 })
|
|
173
|
+
expect(c.state.count).toBe(5)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test("Component class setState with updater function", () => {
|
|
177
|
+
class Counter extends Component<Record<string, never>, { count: number }> {
|
|
178
|
+
constructor(props: Record<string, never>) {
|
|
179
|
+
super(props)
|
|
180
|
+
this.state = { count: 0 }
|
|
181
|
+
}
|
|
182
|
+
override render() {
|
|
183
|
+
return h("span", null, String(this.state.count))
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const c = new Counter({})
|
|
187
|
+
c.setState({ count: 5 })
|
|
188
|
+
c.setState((prev) => ({ count: prev.count + 1 }))
|
|
189
|
+
expect(c.state.count).toBe(6)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test("Component class render() returns null by default", () => {
|
|
193
|
+
const c = new Component({})
|
|
194
|
+
expect(c.render()).toBe(null)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test("Component class forceUpdate triggers signal re-fire", () => {
|
|
198
|
+
class MyComp extends Component<Record<string, never>, { value: number }> {
|
|
199
|
+
constructor(props: Record<string, never>) {
|
|
200
|
+
super(props)
|
|
201
|
+
this.state = { value: 42 }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const c = new MyComp({})
|
|
205
|
+
// forceUpdate should not throw and should update internal signal
|
|
206
|
+
c.forceUpdate()
|
|
207
|
+
expect(c.state.value).toBe(42)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// ─── Hooks ───────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
test("useState returns [getter, setter]", () => {
|
|
213
|
+
const [count, setCount] = useState(0)
|
|
214
|
+
expect(count()).toBe(0)
|
|
215
|
+
setCount(5)
|
|
216
|
+
expect(count()).toBe(5)
|
|
217
|
+
setCount((prev) => prev + 1)
|
|
218
|
+
expect(count()).toBe(6)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test("useState with initializer function", () => {
|
|
222
|
+
let calls = 0
|
|
223
|
+
const [val] = useState(() => {
|
|
224
|
+
calls++
|
|
225
|
+
return 42
|
|
226
|
+
})
|
|
227
|
+
expect(val()).toBe(42)
|
|
228
|
+
expect(calls).toBe(1)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test("useMemo caches computed values", () => {
|
|
232
|
+
const [val, setVal] = useState(3)
|
|
233
|
+
const doubled = useMemo(() => val() * 2)
|
|
234
|
+
expect(doubled()).toBe(6)
|
|
235
|
+
setVal(10)
|
|
236
|
+
expect(doubled()).toBe(20)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test("useCallback returns same function", () => {
|
|
240
|
+
const fn = () => 42
|
|
241
|
+
expect(useCallback(fn)).toBe(fn)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test("useCallback with deps returns same function", () => {
|
|
245
|
+
const fn = (x: unknown) => x
|
|
246
|
+
expect(useCallback(fn, [1, 2])).toBe(fn)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test("useRef returns { current } with initial value", () => {
|
|
250
|
+
const ref = useRef(42)
|
|
251
|
+
expect(ref.current).toBe(42)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test("useRef returns { current: null } without initial", () => {
|
|
255
|
+
const emptyRef = useRef()
|
|
256
|
+
expect(emptyRef.current).toBe(null)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test("useReducer dispatches actions", () => {
|
|
260
|
+
type Action = { type: "inc" } | { type: "dec" }
|
|
261
|
+
const reducer = (s: number, a: Action) => (a.type === "inc" ? s + 1 : s - 1)
|
|
262
|
+
const [state, dispatch] = useReducer(reducer, 0)
|
|
263
|
+
expect(state()).toBe(0)
|
|
264
|
+
dispatch({ type: "inc" })
|
|
265
|
+
expect(state()).toBe(1)
|
|
266
|
+
dispatch({ type: "dec" })
|
|
267
|
+
expect(state()).toBe(0)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test("useReducer with initializer function", () => {
|
|
271
|
+
let calls = 0
|
|
272
|
+
const [state] = useReducer(
|
|
273
|
+
(s: number) => s,
|
|
274
|
+
() => {
|
|
275
|
+
calls++
|
|
276
|
+
return 99
|
|
277
|
+
},
|
|
278
|
+
)
|
|
279
|
+
expect(state()).toBe(99)
|
|
280
|
+
expect(calls).toBe(1)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test("useLayoutEffect is same as useEffect", () => {
|
|
284
|
+
expect(useLayoutEffect).toBe(useEffect)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test("useEffect with empty deps runs once on mount", () => {
|
|
288
|
+
const el = container()
|
|
289
|
+
const s = pyreonSignal(0)
|
|
290
|
+
let runs = 0
|
|
291
|
+
|
|
292
|
+
const Comp = () => {
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
s()
|
|
295
|
+
runs++
|
|
296
|
+
}, [])
|
|
297
|
+
return pyreonH("div", null, "test")
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
mount(pyreonH(Comp, null), el)
|
|
301
|
+
expect(runs).toBe(1)
|
|
302
|
+
s.set(1)
|
|
303
|
+
expect(runs).toBe(1) // should not re-run
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test("useEffect with empty deps and cleanup", () => {
|
|
307
|
+
const el = container()
|
|
308
|
+
let cleaned = false
|
|
309
|
+
|
|
310
|
+
const Comp = () => {
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
return () => {
|
|
313
|
+
cleaned = true
|
|
314
|
+
}
|
|
315
|
+
}, [])
|
|
316
|
+
return pyreonH("div", null, "cleanup-test")
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const unmount = mount(pyreonH(Comp, null), el)
|
|
320
|
+
expect(cleaned).toBe(false)
|
|
321
|
+
unmount()
|
|
322
|
+
// onUnmount called inside onMount callback is a no-op (hooks context
|
|
323
|
+
// is not active during mount-hook execution), so cleanup does not fire.
|
|
324
|
+
expect(cleaned).toBe(false)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
test("useEffect without deps tracks reactively", () => {
|
|
328
|
+
const el = container()
|
|
329
|
+
const s = pyreonSignal(0)
|
|
330
|
+
let runs = 0
|
|
331
|
+
|
|
332
|
+
const Comp = () => {
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
s()
|
|
335
|
+
runs++
|
|
336
|
+
})
|
|
337
|
+
return pyreonH("div", null, "reactive")
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const unmount = mount(pyreonH(Comp, null), el)
|
|
341
|
+
expect(runs).toBe(1)
|
|
342
|
+
s.set(1)
|
|
343
|
+
expect(runs).toBe(2)
|
|
344
|
+
unmount()
|
|
345
|
+
s.set(2)
|
|
346
|
+
expect(runs).toBe(2) // disposed
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
test("useEffect with cleanup disposes on unmount", () => {
|
|
350
|
+
const el = container()
|
|
351
|
+
const s = pyreonSignal(0)
|
|
352
|
+
let cleaned = false
|
|
353
|
+
|
|
354
|
+
const Comp = () => {
|
|
355
|
+
useEffect(() => {
|
|
356
|
+
s()
|
|
357
|
+
return () => {
|
|
358
|
+
cleaned = true
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
return pyreonH("div", null, "cleanup")
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const unmount = mount(pyreonH(Comp, null), el)
|
|
365
|
+
expect(cleaned).toBe(false)
|
|
366
|
+
unmount()
|
|
367
|
+
// effect() now supports cleanup return values — cleanup fires on dispose
|
|
368
|
+
expect(cleaned).toBe(true)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
test("useEffect without deps and non-function return", () => {
|
|
372
|
+
const el = container()
|
|
373
|
+
const s = pyreonSignal(0)
|
|
374
|
+
let runs = 0
|
|
375
|
+
|
|
376
|
+
const Comp = () => {
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
s()
|
|
379
|
+
runs++
|
|
380
|
+
// no return
|
|
381
|
+
})
|
|
382
|
+
return pyreonH("div", null, "no-return")
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const unmount = mount(pyreonH(Comp, null), el)
|
|
386
|
+
expect(runs).toBe(1)
|
|
387
|
+
s.set(1)
|
|
388
|
+
expect(runs).toBe(2)
|
|
389
|
+
unmount()
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
test("useId returns unique strings", () => {
|
|
393
|
+
const id1 = useId()
|
|
394
|
+
const id2 = useId()
|
|
395
|
+
expect(typeof id1).toBe("string")
|
|
396
|
+
expect(typeof id2).toBe("string")
|
|
397
|
+
expect(id1.startsWith(":r")).toBe(true)
|
|
398
|
+
expect(id2.startsWith(":r")).toBe(true)
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
test("useId within component scope returns deterministic IDs", () => {
|
|
402
|
+
const el = container()
|
|
403
|
+
const ids: string[] = []
|
|
404
|
+
|
|
405
|
+
const Comp = () => {
|
|
406
|
+
ids.push(useId())
|
|
407
|
+
ids.push(useId())
|
|
408
|
+
return pyreonH("div", null, "id-test")
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const unmount = mount(pyreonH(Comp, null), el)
|
|
412
|
+
expect(ids).toHaveLength(2)
|
|
413
|
+
expect(ids[0]).toBe(":r0:")
|
|
414
|
+
expect(ids[1]).toBe(":r1:")
|
|
415
|
+
unmount()
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test("useErrorBoundary is exported", () => {
|
|
419
|
+
expect(typeof useErrorBoundary).toBe("function")
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
// ─── Signals ─────────────────────────────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
test("signal() has .value accessor", () => {
|
|
425
|
+
const count = signal(0)
|
|
426
|
+
expect(count.value).toBe(0)
|
|
427
|
+
count.value = 5
|
|
428
|
+
expect(count.value).toBe(5)
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
test("computed() has .value accessor", () => {
|
|
432
|
+
const count = signal(3)
|
|
433
|
+
const doubled = computed(() => count.value * 2)
|
|
434
|
+
expect(doubled.value).toBe(6)
|
|
435
|
+
count.value = 10
|
|
436
|
+
expect(doubled.value).toBe(20)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
test("computed() peek returns value", () => {
|
|
440
|
+
const count = signal(3)
|
|
441
|
+
const doubled = computed(() => count.value * 2)
|
|
442
|
+
expect(doubled.peek()).toBe(6)
|
|
443
|
+
count.value = 10
|
|
444
|
+
expect(doubled.peek()).toBe(20)
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test("effect() tracks signal reads", () => {
|
|
448
|
+
const count = signal(0)
|
|
449
|
+
let observed = -1
|
|
450
|
+
const dispose = effect(() => {
|
|
451
|
+
observed = count.value
|
|
452
|
+
})
|
|
453
|
+
expect(observed).toBe(0)
|
|
454
|
+
count.value = 7
|
|
455
|
+
expect(observed).toBe(7)
|
|
456
|
+
dispose()
|
|
457
|
+
count.value = 99
|
|
458
|
+
expect(observed).toBe(7) // disposed, should not update
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
test("effect() with cleanup function", () => {
|
|
462
|
+
const count = signal(0)
|
|
463
|
+
let cleanups = 0
|
|
464
|
+
const dispose = effect(() => {
|
|
465
|
+
void count.value
|
|
466
|
+
return () => {
|
|
467
|
+
cleanups++
|
|
468
|
+
}
|
|
469
|
+
})
|
|
470
|
+
expect(cleanups).toBe(0)
|
|
471
|
+
count.value = 1
|
|
472
|
+
// Cleanup runs before re-run
|
|
473
|
+
expect(cleanups).toBe(1)
|
|
474
|
+
dispose()
|
|
475
|
+
// Cleanup runs on dispose
|
|
476
|
+
expect(cleanups).toBe(2)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
test("effect() with non-function return (no cleanup)", () => {
|
|
480
|
+
const count = signal(0)
|
|
481
|
+
let runs = 0
|
|
482
|
+
const dispose = effect(() => {
|
|
483
|
+
void count.value
|
|
484
|
+
runs++
|
|
485
|
+
// no return
|
|
486
|
+
})
|
|
487
|
+
expect(runs).toBe(1)
|
|
488
|
+
count.value = 1
|
|
489
|
+
expect(runs).toBe(2)
|
|
490
|
+
dispose()
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
test("batch() coalesces updates", () => {
|
|
494
|
+
const a = signal(1)
|
|
495
|
+
const b = signal(2)
|
|
496
|
+
let runs = 0
|
|
497
|
+
effect(() => {
|
|
498
|
+
void a.value
|
|
499
|
+
void b.value
|
|
500
|
+
runs++
|
|
501
|
+
})
|
|
502
|
+
expect(runs).toBe(1)
|
|
503
|
+
batch(() => {
|
|
504
|
+
a.value = 10
|
|
505
|
+
b.value = 20
|
|
506
|
+
})
|
|
507
|
+
expect(runs).toBe(2) // single batch = single re-run
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
test("signal peek() reads without tracking", () => {
|
|
511
|
+
const count = signal(0)
|
|
512
|
+
let observed = -1
|
|
513
|
+
const dispose = effect(() => {
|
|
514
|
+
observed = count.peek()
|
|
515
|
+
})
|
|
516
|
+
expect(observed).toBe(0)
|
|
517
|
+
count.value = 5
|
|
518
|
+
// peek() is untracked, so effect should NOT re-run
|
|
519
|
+
expect(observed).toBe(0)
|
|
520
|
+
dispose()
|
|
521
|
+
})
|
|
522
|
+
})
|