@pyreon/preact-compat 0.11.5 → 0.11.7
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 +14 -12
- package/lib/hooks.js.map +1 -1
- package/lib/index.js.map +1 -1
- package/lib/jsx-runtime.js.map +1 -1
- package/lib/signals.js.map +1 -1
- package/package.json +13 -13
- package/src/hooks.ts +8 -8
- package/src/index.ts +10 -10
- package/src/jsx-dev-runtime.ts +1 -1
- package/src/jsx-runtime.ts +6 -6
- package/src/signals.ts +2 -2
- package/src/tests/preact-compat.test.ts +194 -194
- package/src/tests/setup.ts +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { VNodeChild } from
|
|
2
|
-
import { h as pyreonH } from
|
|
3
|
-
import { mount } from
|
|
1
|
+
import type { VNodeChild } from '@pyreon/core'
|
|
2
|
+
import { h as pyreonH } from '@pyreon/core'
|
|
3
|
+
import { mount } from '@pyreon/runtime-dom'
|
|
4
4
|
import {
|
|
5
5
|
memo,
|
|
6
6
|
useCallback,
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
useReducer,
|
|
13
13
|
useRef,
|
|
14
14
|
useState,
|
|
15
|
-
} from
|
|
15
|
+
} from '../hooks'
|
|
16
16
|
import {
|
|
17
17
|
Component,
|
|
18
18
|
cloneElement,
|
|
@@ -27,13 +27,13 @@ import {
|
|
|
27
27
|
render,
|
|
28
28
|
toChildArray,
|
|
29
29
|
useContext,
|
|
30
|
-
} from
|
|
31
|
-
import type { RenderContext } from
|
|
32
|
-
import { beginRender, endRender, jsx } from
|
|
33
|
-
import { batch, computed, signal, effect as signalEffect } from
|
|
30
|
+
} from '../index'
|
|
31
|
+
import type { RenderContext } from '../jsx-runtime'
|
|
32
|
+
import { beginRender, endRender, jsx } from '../jsx-runtime'
|
|
33
|
+
import { batch, computed, signal, effect as signalEffect } from '../signals'
|
|
34
34
|
|
|
35
35
|
function container(): HTMLElement {
|
|
36
|
-
const el = document.createElement(
|
|
36
|
+
const el = document.createElement('div')
|
|
37
37
|
document.body.appendChild(el)
|
|
38
38
|
return el
|
|
39
39
|
}
|
|
@@ -73,133 +73,133 @@ function createHookRunner() {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
describe(
|
|
76
|
+
describe('@pyreon/preact-compat', () => {
|
|
77
77
|
// ─── Core API ────────────────────────────────────────────────────────────
|
|
78
78
|
|
|
79
|
-
test(
|
|
80
|
-
const vnode = h(
|
|
81
|
-
expect(vnode.type).toBe(
|
|
82
|
-
expect(vnode.props.class).toBe(
|
|
83
|
-
expect(vnode.children).toContain(
|
|
79
|
+
test('h() creates VNodes', () => {
|
|
80
|
+
const vnode = h('div', { class: 'test' }, 'hello')
|
|
81
|
+
expect(vnode.type).toBe('div')
|
|
82
|
+
expect(vnode.props.class).toBe('test')
|
|
83
|
+
expect(vnode.children).toContain('hello')
|
|
84
84
|
})
|
|
85
85
|
|
|
86
|
-
test(
|
|
86
|
+
test('createElement is alias for h', () => {
|
|
87
87
|
expect(createElement).toBe(h)
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
-
test(
|
|
91
|
-
expect(typeof Fragment).toBe(
|
|
90
|
+
test('Fragment is a symbol', () => {
|
|
91
|
+
expect(typeof Fragment).toBe('symbol')
|
|
92
92
|
})
|
|
93
93
|
|
|
94
|
-
test(
|
|
94
|
+
test('render() mounts to DOM', () => {
|
|
95
95
|
const el = container()
|
|
96
|
-
render(h(
|
|
97
|
-
expect(el.innerHTML).toContain(
|
|
96
|
+
render(h('span', null, 'mounted'), el)
|
|
97
|
+
expect(el.innerHTML).toContain('mounted')
|
|
98
98
|
})
|
|
99
99
|
|
|
100
|
-
test(
|
|
100
|
+
test('hydrate() calls hydrateRoot', () => {
|
|
101
101
|
const el = container()
|
|
102
|
-
el.innerHTML =
|
|
103
|
-
hydrate(h(
|
|
104
|
-
expect(el.innerHTML).toContain(
|
|
102
|
+
el.innerHTML = '<span>hydrated</span>'
|
|
103
|
+
hydrate(h('span', null, 'hydrated'), el)
|
|
104
|
+
expect(el.innerHTML).toContain('hydrated')
|
|
105
105
|
})
|
|
106
106
|
|
|
107
|
-
test(
|
|
108
|
-
const vnode = h(
|
|
107
|
+
test('isValidElement detects VNodes', () => {
|
|
108
|
+
const vnode = h('div', null)
|
|
109
109
|
expect(isValidElement(vnode)).toBe(true)
|
|
110
110
|
expect(isValidElement(null)).toBe(false)
|
|
111
|
-
expect(isValidElement(
|
|
111
|
+
expect(isValidElement('string')).toBe(false)
|
|
112
112
|
expect(isValidElement(42)).toBe(false)
|
|
113
|
-
expect(isValidElement({ type:
|
|
113
|
+
expect(isValidElement({ type: 'div', props: {}, children: [] })).toBe(true)
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
-
test(
|
|
117
|
-
expect(isValidElement({ type:
|
|
118
|
-
expect(isValidElement({ type:
|
|
116
|
+
test('isValidElement returns false for objects missing required keys', () => {
|
|
117
|
+
expect(isValidElement({ type: 'div' })).toBe(false)
|
|
118
|
+
expect(isValidElement({ type: 'div', props: {} })).toBe(false)
|
|
119
119
|
expect(isValidElement({})).toBe(false)
|
|
120
120
|
expect(isValidElement(undefined)).toBe(false)
|
|
121
121
|
})
|
|
122
122
|
|
|
123
|
-
test(
|
|
124
|
-
const result = toChildArray([
|
|
125
|
-
expect(result).toEqual([
|
|
123
|
+
test('toChildArray flattens children', () => {
|
|
124
|
+
const result = toChildArray(['a', ['b', ['c']], null, undefined, false, 'd'] as VNodeChild[])
|
|
125
|
+
expect(result).toEqual(['a', 'b', 'c', 'd'])
|
|
126
126
|
})
|
|
127
127
|
|
|
128
|
-
test(
|
|
129
|
-
const result = toChildArray(
|
|
130
|
-
expect(result).toEqual([
|
|
128
|
+
test('toChildArray handles single non-array child', () => {
|
|
129
|
+
const result = toChildArray('hello')
|
|
130
|
+
expect(result).toEqual(['hello'])
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
-
test(
|
|
133
|
+
test('toChildArray handles null/undefined/boolean at top level', () => {
|
|
134
134
|
expect(toChildArray(null as unknown as VNodeChild)).toEqual([])
|
|
135
135
|
expect(toChildArray(undefined as unknown as VNodeChild)).toEqual([])
|
|
136
136
|
expect(toChildArray(false as unknown as VNodeChild)).toEqual([])
|
|
137
137
|
expect(toChildArray(true as unknown as VNodeChild)).toEqual([])
|
|
138
138
|
})
|
|
139
139
|
|
|
140
|
-
test(
|
|
140
|
+
test('toChildArray handles number children', () => {
|
|
141
141
|
const result = toChildArray([1, 2, 3] as VNodeChild[])
|
|
142
142
|
expect(result).toEqual([1, 2, 3])
|
|
143
143
|
})
|
|
144
144
|
|
|
145
|
-
test(
|
|
146
|
-
const original = h(
|
|
147
|
-
const cloned = cloneElement(original, { class:
|
|
148
|
-
expect(cloned.type).toBe(
|
|
149
|
-
expect(cloned.props.class).toBe(
|
|
150
|
-
expect(cloned.props.id).toBe(
|
|
151
|
-
expect(cloned.children).toContain(
|
|
145
|
+
test('cloneElement merges props', () => {
|
|
146
|
+
const original = h('div', { class: 'a', id: 'x' }, 'child')
|
|
147
|
+
const cloned = cloneElement(original, { class: 'b' })
|
|
148
|
+
expect(cloned.type).toBe('div')
|
|
149
|
+
expect(cloned.props.class).toBe('b')
|
|
150
|
+
expect(cloned.props.id).toBe('x')
|
|
151
|
+
expect(cloned.children).toContain('child')
|
|
152
152
|
})
|
|
153
153
|
|
|
154
|
-
test(
|
|
155
|
-
const original = h(
|
|
156
|
-
const cloned = cloneElement(original, undefined,
|
|
157
|
-
expect(cloned.children).toContain(
|
|
158
|
-
expect(cloned.children).not.toContain(
|
|
154
|
+
test('cloneElement replaces children when provided', () => {
|
|
155
|
+
const original = h('div', null, 'old')
|
|
156
|
+
const cloned = cloneElement(original, undefined, 'new')
|
|
157
|
+
expect(cloned.children).toContain('new')
|
|
158
|
+
expect(cloned.children).not.toContain('old')
|
|
159
159
|
})
|
|
160
160
|
|
|
161
|
-
test(
|
|
162
|
-
const original = h(
|
|
163
|
-
const cloned = cloneElement(original, { class:
|
|
164
|
-
expect(cloned.key).toBe(
|
|
161
|
+
test('cloneElement preserves key from original when not overridden', () => {
|
|
162
|
+
const original = h('div', { key: 'original-key' }, 'child')
|
|
163
|
+
const cloned = cloneElement(original, { class: 'b' })
|
|
164
|
+
expect(cloned.key).toBe('original-key')
|
|
165
165
|
})
|
|
166
166
|
|
|
167
|
-
test(
|
|
168
|
-
const original = h(
|
|
169
|
-
const cloned = cloneElement(original, { key:
|
|
170
|
-
expect(cloned.key).toBe(
|
|
167
|
+
test('cloneElement overrides key when provided in props', () => {
|
|
168
|
+
const original = h('div', { key: 'original-key' }, 'child')
|
|
169
|
+
const cloned = cloneElement(original, { key: 'new-key' })
|
|
170
|
+
expect(cloned.key).toBe('new-key')
|
|
171
171
|
})
|
|
172
172
|
|
|
173
|
-
test(
|
|
174
|
-
const original = h(
|
|
173
|
+
test('cloneElement with no props passes empty override', () => {
|
|
174
|
+
const original = h('div', { id: 'test' }, 'child')
|
|
175
175
|
const cloned = cloneElement(original)
|
|
176
|
-
expect(cloned.props.id).toBe(
|
|
177
|
-
expect(cloned.children).toContain(
|
|
176
|
+
expect(cloned.props.id).toBe('test')
|
|
177
|
+
expect(cloned.children).toContain('child')
|
|
178
178
|
})
|
|
179
179
|
|
|
180
|
-
test(
|
|
180
|
+
test('createRef returns { current: null }', () => {
|
|
181
181
|
const ref = createRef()
|
|
182
182
|
expect(ref.current).toBe(null)
|
|
183
183
|
})
|
|
184
184
|
|
|
185
|
-
test(
|
|
186
|
-
const Ctx = createContext(
|
|
187
|
-
expect(useContext(Ctx)).toBe(
|
|
185
|
+
test('createContext/useContext work', () => {
|
|
186
|
+
const Ctx = createContext('default')
|
|
187
|
+
expect(useContext(Ctx)).toBe('default')
|
|
188
188
|
})
|
|
189
189
|
|
|
190
|
-
test(
|
|
191
|
-
expect(typeof options).toBe(
|
|
190
|
+
test('options is an empty object', () => {
|
|
191
|
+
expect(typeof options).toBe('object')
|
|
192
192
|
expect(Object.keys(options).length).toBe(0)
|
|
193
193
|
})
|
|
194
194
|
|
|
195
|
-
test(
|
|
195
|
+
test('Component class setState updates state with object', () => {
|
|
196
196
|
class Counter extends Component<Record<string, never>, { count: number }> {
|
|
197
197
|
constructor(props: Record<string, never>) {
|
|
198
198
|
super(props)
|
|
199
199
|
this.state = { count: 0 }
|
|
200
200
|
}
|
|
201
201
|
override render() {
|
|
202
|
-
return h(
|
|
202
|
+
return h('span', null, String(this.state.count))
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
const c = new Counter({})
|
|
@@ -208,14 +208,14 @@ describe("@pyreon/preact-compat", () => {
|
|
|
208
208
|
expect(c.state.count).toBe(5)
|
|
209
209
|
})
|
|
210
210
|
|
|
211
|
-
test(
|
|
211
|
+
test('Component class setState with updater function', () => {
|
|
212
212
|
class Counter extends Component<Record<string, never>, { count: number }> {
|
|
213
213
|
constructor(props: Record<string, never>) {
|
|
214
214
|
super(props)
|
|
215
215
|
this.state = { count: 0 }
|
|
216
216
|
}
|
|
217
217
|
override render() {
|
|
218
|
-
return h(
|
|
218
|
+
return h('span', null, String(this.state.count))
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
221
|
const c = new Counter({})
|
|
@@ -224,12 +224,12 @@ describe("@pyreon/preact-compat", () => {
|
|
|
224
224
|
expect(c.state.count).toBe(6)
|
|
225
225
|
})
|
|
226
226
|
|
|
227
|
-
test(
|
|
227
|
+
test('Component class render() returns null by default', () => {
|
|
228
228
|
const c = new Component({})
|
|
229
229
|
expect(c.render()).toBe(null)
|
|
230
230
|
})
|
|
231
231
|
|
|
232
|
-
test(
|
|
232
|
+
test('Component class forceUpdate triggers signal re-fire', () => {
|
|
233
233
|
class MyComp extends Component<Record<string, never>, { value: number }> {
|
|
234
234
|
constructor(props: Record<string, never>) {
|
|
235
235
|
super(props)
|
|
@@ -244,13 +244,13 @@ describe("@pyreon/preact-compat", () => {
|
|
|
244
244
|
|
|
245
245
|
// ─── useState ─────────────────────────────────────────────────────────────────
|
|
246
246
|
|
|
247
|
-
describe(
|
|
248
|
-
test(
|
|
247
|
+
describe('useState', () => {
|
|
248
|
+
test('returns [value, setter] — value is the initial value', () => {
|
|
249
249
|
const [count] = withHookCtx(() => useState(0))
|
|
250
250
|
expect(count).toBe(0)
|
|
251
251
|
})
|
|
252
252
|
|
|
253
|
-
test(
|
|
253
|
+
test('setter updates value on re-render', () => {
|
|
254
254
|
const runner = createHookRunner()
|
|
255
255
|
const [, setCount] = runner.run(() => useState(0))
|
|
256
256
|
setCount(5)
|
|
@@ -258,7 +258,7 @@ describe("useState", () => {
|
|
|
258
258
|
expect(count2).toBe(5)
|
|
259
259
|
})
|
|
260
260
|
|
|
261
|
-
test(
|
|
261
|
+
test('setter with function updater', () => {
|
|
262
262
|
const runner = createHookRunner()
|
|
263
263
|
const [, setCount] = runner.run(() => useState(10))
|
|
264
264
|
setCount((prev) => prev + 1)
|
|
@@ -266,7 +266,7 @@ describe("useState", () => {
|
|
|
266
266
|
expect(count2).toBe(11)
|
|
267
267
|
})
|
|
268
268
|
|
|
269
|
-
test(
|
|
269
|
+
test('initializer function is called once', () => {
|
|
270
270
|
let calls = 0
|
|
271
271
|
const runner = createHookRunner()
|
|
272
272
|
runner.run(() =>
|
|
@@ -285,7 +285,7 @@ describe("useState", () => {
|
|
|
285
285
|
expect(calls).toBe(1)
|
|
286
286
|
})
|
|
287
287
|
|
|
288
|
-
test(
|
|
288
|
+
test('setter does nothing when value is the same (Object.is)', () => {
|
|
289
289
|
const runner = createHookRunner()
|
|
290
290
|
let rerenders = 0
|
|
291
291
|
runner.ctx.scheduleRerender = () => {
|
|
@@ -298,7 +298,7 @@ describe("useState", () => {
|
|
|
298
298
|
expect(rerenders).toBe(1)
|
|
299
299
|
})
|
|
300
300
|
|
|
301
|
-
test(
|
|
301
|
+
test('re-render in a component via compat JSX runtime', async () => {
|
|
302
302
|
const el = container()
|
|
303
303
|
let renderCount = 0
|
|
304
304
|
let triggerSet: (v: number | ((p: number) => number)) => void = () => {}
|
|
@@ -307,44 +307,44 @@ describe("useState", () => {
|
|
|
307
307
|
const [count, setCount] = useState(0)
|
|
308
308
|
renderCount++
|
|
309
309
|
triggerSet = setCount
|
|
310
|
-
return pyreonH(
|
|
310
|
+
return pyreonH('span', null, String(count))
|
|
311
311
|
}
|
|
312
312
|
|
|
313
313
|
const vnode = jsx(Counter, {})
|
|
314
314
|
mount(vnode, el)
|
|
315
|
-
expect(el.textContent).toBe(
|
|
315
|
+
expect(el.textContent).toBe('0')
|
|
316
316
|
const initialRenders = renderCount
|
|
317
317
|
|
|
318
318
|
triggerSet(1)
|
|
319
319
|
await new Promise<void>((r) => queueMicrotask(r))
|
|
320
320
|
await new Promise<void>((r) => queueMicrotask(r))
|
|
321
|
-
expect(el.textContent).toBe(
|
|
321
|
+
expect(el.textContent).toBe('1')
|
|
322
322
|
expect(renderCount).toBe(initialRenders + 1)
|
|
323
323
|
})
|
|
324
324
|
})
|
|
325
325
|
|
|
326
326
|
// ─── useReducer ───────────────────────────────────────────────────────────────
|
|
327
327
|
|
|
328
|
-
describe(
|
|
329
|
-
test(
|
|
328
|
+
describe('useReducer', () => {
|
|
329
|
+
test('dispatch applies reducer', () => {
|
|
330
330
|
const runner = createHookRunner()
|
|
331
|
-
type Action = { type:
|
|
331
|
+
type Action = { type: 'inc' } | { type: 'dec' }
|
|
332
332
|
const reducer = (state: number, action: Action) =>
|
|
333
|
-
action.type ===
|
|
333
|
+
action.type === 'inc' ? state + 1 : state - 1
|
|
334
334
|
|
|
335
335
|
const [state0, dispatch] = runner.run(() => useReducer(reducer, 0))
|
|
336
336
|
expect(state0).toBe(0)
|
|
337
337
|
|
|
338
|
-
dispatch({ type:
|
|
338
|
+
dispatch({ type: 'inc' })
|
|
339
339
|
const [state1] = runner.run(() => useReducer(reducer, 0))
|
|
340
340
|
expect(state1).toBe(1)
|
|
341
341
|
|
|
342
|
-
dispatch({ type:
|
|
342
|
+
dispatch({ type: 'dec' })
|
|
343
343
|
const [state2] = runner.run(() => useReducer(reducer, 0))
|
|
344
344
|
expect(state2).toBe(0)
|
|
345
345
|
})
|
|
346
346
|
|
|
347
|
-
test(
|
|
347
|
+
test('initializer function is called once', () => {
|
|
348
348
|
let calls = 0
|
|
349
349
|
const runner = createHookRunner()
|
|
350
350
|
const [state] = runner.run(() =>
|
|
@@ -370,22 +370,22 @@ describe("useReducer", () => {
|
|
|
370
370
|
expect(calls).toBe(1)
|
|
371
371
|
})
|
|
372
372
|
|
|
373
|
-
test(
|
|
373
|
+
test('dispatch does nothing when reducer returns same state', () => {
|
|
374
374
|
const runner = createHookRunner()
|
|
375
375
|
let rerenders = 0
|
|
376
376
|
runner.ctx.scheduleRerender = () => {
|
|
377
377
|
rerenders++
|
|
378
378
|
}
|
|
379
379
|
const [, dispatch] = runner.run(() => useReducer((_s: number, _a: string) => 5, 5))
|
|
380
|
-
dispatch(
|
|
380
|
+
dispatch('anything')
|
|
381
381
|
expect(rerenders).toBe(0)
|
|
382
382
|
})
|
|
383
383
|
})
|
|
384
384
|
|
|
385
385
|
// ─── useEffect ────────────────────────────────────────────────────────────────
|
|
386
386
|
|
|
387
|
-
describe(
|
|
388
|
-
test(
|
|
387
|
+
describe('useEffect', () => {
|
|
388
|
+
test('effect runs after render via compat JSX runtime', async () => {
|
|
389
389
|
const el = container()
|
|
390
390
|
let effectRuns = 0
|
|
391
391
|
|
|
@@ -393,7 +393,7 @@ describe("useEffect", () => {
|
|
|
393
393
|
useEffect(() => {
|
|
394
394
|
effectRuns++
|
|
395
395
|
})
|
|
396
|
-
return pyreonH(
|
|
396
|
+
return pyreonH('div', null, 'test')
|
|
397
397
|
}
|
|
398
398
|
|
|
399
399
|
mount(jsx(Comp, {}), el)
|
|
@@ -401,7 +401,7 @@ describe("useEffect", () => {
|
|
|
401
401
|
expect(effectRuns).toBeGreaterThanOrEqual(1)
|
|
402
402
|
})
|
|
403
403
|
|
|
404
|
-
test(
|
|
404
|
+
test('effect with empty deps runs once', async () => {
|
|
405
405
|
const el = container()
|
|
406
406
|
let effectRuns = 0
|
|
407
407
|
let triggerSet: (v: number) => void = () => {}
|
|
@@ -412,7 +412,7 @@ describe("useEffect", () => {
|
|
|
412
412
|
useEffect(() => {
|
|
413
413
|
effectRuns++
|
|
414
414
|
}, [])
|
|
415
|
-
return pyreonH(
|
|
415
|
+
return pyreonH('div', null, String(count))
|
|
416
416
|
}
|
|
417
417
|
|
|
418
418
|
mount(jsx(Comp, {}), el)
|
|
@@ -425,7 +425,7 @@ describe("useEffect", () => {
|
|
|
425
425
|
expect(effectRuns).toBe(1)
|
|
426
426
|
})
|
|
427
427
|
|
|
428
|
-
test(
|
|
428
|
+
test('effect with deps re-runs when deps change', async () => {
|
|
429
429
|
const el = container()
|
|
430
430
|
let effectRuns = 0
|
|
431
431
|
let triggerSet: (v: number | ((p: number) => number)) => void = () => {}
|
|
@@ -436,7 +436,7 @@ describe("useEffect", () => {
|
|
|
436
436
|
useEffect(() => {
|
|
437
437
|
effectRuns++
|
|
438
438
|
}, [count])
|
|
439
|
-
return pyreonH(
|
|
439
|
+
return pyreonH('div', null, String(count))
|
|
440
440
|
}
|
|
441
441
|
|
|
442
442
|
mount(jsx(Comp, {}), el)
|
|
@@ -450,7 +450,7 @@ describe("useEffect", () => {
|
|
|
450
450
|
expect(effectRuns).toBe(2)
|
|
451
451
|
})
|
|
452
452
|
|
|
453
|
-
test(
|
|
453
|
+
test('effect cleanup runs before re-execution', async () => {
|
|
454
454
|
const el = container()
|
|
455
455
|
let cleanups = 0
|
|
456
456
|
let triggerSet: (v: number | ((p: number) => number)) => void = () => {}
|
|
@@ -463,7 +463,7 @@ describe("useEffect", () => {
|
|
|
463
463
|
cleanups++
|
|
464
464
|
}
|
|
465
465
|
}, [count])
|
|
466
|
-
return pyreonH(
|
|
466
|
+
return pyreonH('div', null, String(count))
|
|
467
467
|
}
|
|
468
468
|
|
|
469
469
|
mount(jsx(Comp, {}), el)
|
|
@@ -477,7 +477,7 @@ describe("useEffect", () => {
|
|
|
477
477
|
expect(cleanups).toBe(1)
|
|
478
478
|
})
|
|
479
479
|
|
|
480
|
-
test(
|
|
480
|
+
test('pendingEffects populated during render', () => {
|
|
481
481
|
const runner = createHookRunner()
|
|
482
482
|
runner.run(() => {
|
|
483
483
|
useEffect(() => {})
|
|
@@ -485,7 +485,7 @@ describe("useEffect", () => {
|
|
|
485
485
|
expect(runner.ctx.pendingEffects).toHaveLength(1)
|
|
486
486
|
})
|
|
487
487
|
|
|
488
|
-
test(
|
|
488
|
+
test('effect with same deps does not re-queue', () => {
|
|
489
489
|
const runner = createHookRunner()
|
|
490
490
|
runner.run(() => {
|
|
491
491
|
useEffect(() => {}, [1, 2])
|
|
@@ -501,8 +501,8 @@ describe("useEffect", () => {
|
|
|
501
501
|
|
|
502
502
|
// ─── useLayoutEffect ─────────────────────────────────────────────────────────
|
|
503
503
|
|
|
504
|
-
describe(
|
|
505
|
-
test(
|
|
504
|
+
describe('useLayoutEffect', () => {
|
|
505
|
+
test('layout effect runs synchronously during render in compat runtime', () => {
|
|
506
506
|
const el = container()
|
|
507
507
|
let effectRuns = 0
|
|
508
508
|
|
|
@@ -510,14 +510,14 @@ describe("useLayoutEffect", () => {
|
|
|
510
510
|
useLayoutEffect(() => {
|
|
511
511
|
effectRuns++
|
|
512
512
|
})
|
|
513
|
-
return pyreonH(
|
|
513
|
+
return pyreonH('div', null, 'layout')
|
|
514
514
|
}
|
|
515
515
|
|
|
516
516
|
mount(jsx(Comp, {}), el)
|
|
517
517
|
expect(effectRuns).toBeGreaterThanOrEqual(1)
|
|
518
518
|
})
|
|
519
519
|
|
|
520
|
-
test(
|
|
520
|
+
test('pendingLayoutEffects populated during render', () => {
|
|
521
521
|
const runner = createHookRunner()
|
|
522
522
|
runner.run(() => {
|
|
523
523
|
useLayoutEffect(() => {})
|
|
@@ -525,7 +525,7 @@ describe("useLayoutEffect", () => {
|
|
|
525
525
|
expect(runner.ctx.pendingLayoutEffects).toHaveLength(1)
|
|
526
526
|
})
|
|
527
527
|
|
|
528
|
-
test(
|
|
528
|
+
test('layout effect with same deps does not re-queue', () => {
|
|
529
529
|
const runner = createHookRunner()
|
|
530
530
|
runner.run(() => {
|
|
531
531
|
useLayoutEffect(() => {}, [1])
|
|
@@ -541,13 +541,13 @@ describe("useLayoutEffect", () => {
|
|
|
541
541
|
|
|
542
542
|
// ─── useMemo ──────────────────────────────────────────────────────────────────
|
|
543
543
|
|
|
544
|
-
describe(
|
|
545
|
-
test(
|
|
544
|
+
describe('useMemo', () => {
|
|
545
|
+
test('returns computed value', () => {
|
|
546
546
|
const value = withHookCtx(() => useMemo(() => 3 * 2, []))
|
|
547
547
|
expect(value).toBe(6)
|
|
548
548
|
})
|
|
549
549
|
|
|
550
|
-
test(
|
|
550
|
+
test('recomputes when deps change', () => {
|
|
551
551
|
const runner = createHookRunner()
|
|
552
552
|
const v1 = runner.run(() => useMemo(() => 10, [1]))
|
|
553
553
|
expect(v1).toBe(10)
|
|
@@ -562,8 +562,8 @@ describe("useMemo", () => {
|
|
|
562
562
|
|
|
563
563
|
// ─── useCallback ──────────────────────────────────────────────────────────────
|
|
564
564
|
|
|
565
|
-
describe(
|
|
566
|
-
test(
|
|
565
|
+
describe('useCallback', () => {
|
|
566
|
+
test('returns the same function when deps unchanged', () => {
|
|
567
567
|
const runner = createHookRunner()
|
|
568
568
|
const fn1 = () => 42
|
|
569
569
|
const fn2 = () => 99
|
|
@@ -573,7 +573,7 @@ describe("useCallback", () => {
|
|
|
573
573
|
expect(result1()).toBe(42)
|
|
574
574
|
})
|
|
575
575
|
|
|
576
|
-
test(
|
|
576
|
+
test('returns new function when deps change', () => {
|
|
577
577
|
const runner = createHookRunner()
|
|
578
578
|
const fn1 = () => 42
|
|
579
579
|
const fn2 = () => 99
|
|
@@ -587,24 +587,24 @@ describe("useCallback", () => {
|
|
|
587
587
|
|
|
588
588
|
// ─── useRef ───────────────────────────────────────────────────────────────────
|
|
589
589
|
|
|
590
|
-
describe(
|
|
591
|
-
test(
|
|
590
|
+
describe('useRef', () => {
|
|
591
|
+
test('returns { current } with null default', () => {
|
|
592
592
|
const ref = withHookCtx(() => useRef<HTMLDivElement>())
|
|
593
593
|
expect(ref.current).toBeNull()
|
|
594
594
|
})
|
|
595
595
|
|
|
596
|
-
test(
|
|
596
|
+
test('returns { current } with initial value', () => {
|
|
597
597
|
const ref = withHookCtx(() => useRef(42))
|
|
598
598
|
expect(ref.current).toBe(42)
|
|
599
599
|
})
|
|
600
600
|
|
|
601
|
-
test(
|
|
601
|
+
test('current is mutable', () => {
|
|
602
602
|
const ref = withHookCtx(() => useRef(0))
|
|
603
603
|
ref.current = 10
|
|
604
604
|
expect(ref.current).toBe(10)
|
|
605
605
|
})
|
|
606
606
|
|
|
607
|
-
test(
|
|
607
|
+
test('same ref object persists across re-renders', () => {
|
|
608
608
|
const runner = createHookRunner()
|
|
609
609
|
const ref1 = runner.run(() => useRef(0))
|
|
610
610
|
ref1.current = 99
|
|
@@ -616,27 +616,27 @@ describe("useRef", () => {
|
|
|
616
616
|
|
|
617
617
|
// ─── memo ─────────────────────────────────────────────────────────────────────
|
|
618
618
|
|
|
619
|
-
describe(
|
|
620
|
-
test(
|
|
619
|
+
describe('memo', () => {
|
|
620
|
+
test('skips re-render when props are shallowly equal', () => {
|
|
621
621
|
let renderCount = 0
|
|
622
622
|
const MyComp = (props: { name: string }) => {
|
|
623
623
|
renderCount++
|
|
624
|
-
return pyreonH(
|
|
624
|
+
return pyreonH('span', null, props.name)
|
|
625
625
|
}
|
|
626
626
|
const Memoized = memo(MyComp)
|
|
627
|
-
Memoized({ name:
|
|
627
|
+
Memoized({ name: 'a' })
|
|
628
628
|
expect(renderCount).toBe(1)
|
|
629
|
-
Memoized({ name:
|
|
629
|
+
Memoized({ name: 'a' })
|
|
630
630
|
expect(renderCount).toBe(1)
|
|
631
|
-
Memoized({ name:
|
|
631
|
+
Memoized({ name: 'b' })
|
|
632
632
|
expect(renderCount).toBe(2)
|
|
633
633
|
})
|
|
634
634
|
|
|
635
|
-
test(
|
|
635
|
+
test('custom areEqual function', () => {
|
|
636
636
|
let renderCount = 0
|
|
637
637
|
const MyComp = (props: { x: number; y: number }) => {
|
|
638
638
|
renderCount++
|
|
639
|
-
return pyreonH(
|
|
639
|
+
return pyreonH('span', null, String(props.x))
|
|
640
640
|
}
|
|
641
641
|
const Memoized = memo(MyComp, (prev, next) => prev.x === next.x)
|
|
642
642
|
Memoized({ x: 1, y: 1 })
|
|
@@ -647,11 +647,11 @@ describe("memo", () => {
|
|
|
647
647
|
expect(renderCount).toBe(2)
|
|
648
648
|
})
|
|
649
649
|
|
|
650
|
-
test(
|
|
650
|
+
test('different number of keys triggers re-render', () => {
|
|
651
651
|
let renderCount = 0
|
|
652
652
|
const MyComp = (_props: Record<string, unknown>) => {
|
|
653
653
|
renderCount++
|
|
654
|
-
return pyreonH(
|
|
654
|
+
return pyreonH('span', null, 'x')
|
|
655
655
|
}
|
|
656
656
|
const Memoized = memo(MyComp)
|
|
657
657
|
Memoized({ a: 1 })
|
|
@@ -663,25 +663,25 @@ describe("memo", () => {
|
|
|
663
663
|
|
|
664
664
|
// ─── useId ────────────────────────────────────────────────────────────────────
|
|
665
665
|
|
|
666
|
-
describe(
|
|
667
|
-
test(
|
|
666
|
+
describe('useId', () => {
|
|
667
|
+
test('returns a unique string within a component', () => {
|
|
668
668
|
const el = container()
|
|
669
669
|
const ids: string[] = []
|
|
670
670
|
|
|
671
671
|
const Comp = () => {
|
|
672
672
|
ids.push(useId())
|
|
673
673
|
ids.push(useId())
|
|
674
|
-
return pyreonH(
|
|
674
|
+
return pyreonH('div', null, 'id-test')
|
|
675
675
|
}
|
|
676
676
|
|
|
677
677
|
mount(jsx(Comp, {}), el)
|
|
678
678
|
expect(ids.length).toBeGreaterThanOrEqual(2)
|
|
679
679
|
expect(ids[0]).not.toBe(ids[1])
|
|
680
|
-
expect(typeof ids[0]).toBe(
|
|
681
|
-
expect(ids[0]?.startsWith(
|
|
680
|
+
expect(typeof ids[0]).toBe('string')
|
|
681
|
+
expect(ids[0]?.startsWith(':r')).toBe(true)
|
|
682
682
|
})
|
|
683
683
|
|
|
684
|
-
test(
|
|
684
|
+
test('IDs are stable across re-renders', async () => {
|
|
685
685
|
const el = container()
|
|
686
686
|
const idHistory: string[] = []
|
|
687
687
|
let triggerSet: (v: number) => void = () => {}
|
|
@@ -691,7 +691,7 @@ describe("useId", () => {
|
|
|
691
691
|
triggerSet = setCount
|
|
692
692
|
const id = useId()
|
|
693
693
|
idHistory.push(id)
|
|
694
|
-
return pyreonH(
|
|
694
|
+
return pyreonH('div', null, `${id}-${count}`)
|
|
695
695
|
}
|
|
696
696
|
|
|
697
697
|
mount(jsx(Comp, {}), el)
|
|
@@ -710,23 +710,23 @@ describe("useId", () => {
|
|
|
710
710
|
|
|
711
711
|
// ─── useErrorBoundary ────────────────────────────────────────────────────────
|
|
712
712
|
|
|
713
|
-
describe(
|
|
714
|
-
test(
|
|
715
|
-
expect(typeof useErrorBoundary).toBe(
|
|
713
|
+
describe('useErrorBoundary', () => {
|
|
714
|
+
test('is exported as a function', () => {
|
|
715
|
+
expect(typeof useErrorBoundary).toBe('function')
|
|
716
716
|
})
|
|
717
717
|
})
|
|
718
718
|
|
|
719
719
|
// ─── Signals ─────────────────────────────────────────────────────────────────
|
|
720
720
|
|
|
721
|
-
describe(
|
|
722
|
-
test(
|
|
721
|
+
describe('signals', () => {
|
|
722
|
+
test('signal() has .value accessor', () => {
|
|
723
723
|
const count = signal(0)
|
|
724
724
|
expect(count.value).toBe(0)
|
|
725
725
|
count.value = 5
|
|
726
726
|
expect(count.value).toBe(5)
|
|
727
727
|
})
|
|
728
728
|
|
|
729
|
-
test(
|
|
729
|
+
test('computed() has .value accessor', () => {
|
|
730
730
|
const count = signal(3)
|
|
731
731
|
const doubled = computed(() => count.value * 2)
|
|
732
732
|
expect(doubled.value).toBe(6)
|
|
@@ -734,7 +734,7 @@ describe("signals", () => {
|
|
|
734
734
|
expect(doubled.value).toBe(20)
|
|
735
735
|
})
|
|
736
736
|
|
|
737
|
-
test(
|
|
737
|
+
test('computed() peek returns value', () => {
|
|
738
738
|
const count = signal(3)
|
|
739
739
|
const doubled = computed(() => count.value * 2)
|
|
740
740
|
expect(doubled.peek()).toBe(6)
|
|
@@ -742,7 +742,7 @@ describe("signals", () => {
|
|
|
742
742
|
expect(doubled.peek()).toBe(20)
|
|
743
743
|
})
|
|
744
744
|
|
|
745
|
-
test(
|
|
745
|
+
test('effect() tracks signal reads', () => {
|
|
746
746
|
const count = signal(0)
|
|
747
747
|
let observed = -1
|
|
748
748
|
const dispose = signalEffect(() => {
|
|
@@ -756,7 +756,7 @@ describe("signals", () => {
|
|
|
756
756
|
expect(observed).toBe(7)
|
|
757
757
|
})
|
|
758
758
|
|
|
759
|
-
test(
|
|
759
|
+
test('effect() with cleanup function', () => {
|
|
760
760
|
const count = signal(0)
|
|
761
761
|
let cleanups = 0
|
|
762
762
|
const dispose = signalEffect(() => {
|
|
@@ -772,7 +772,7 @@ describe("signals", () => {
|
|
|
772
772
|
expect(cleanups).toBe(2)
|
|
773
773
|
})
|
|
774
774
|
|
|
775
|
-
test(
|
|
775
|
+
test('effect() with non-function return (no cleanup)', () => {
|
|
776
776
|
const count = signal(0)
|
|
777
777
|
let runs = 0
|
|
778
778
|
const dispose = signalEffect(() => {
|
|
@@ -785,7 +785,7 @@ describe("signals", () => {
|
|
|
785
785
|
dispose()
|
|
786
786
|
})
|
|
787
787
|
|
|
788
|
-
test(
|
|
788
|
+
test('batch() coalesces updates', () => {
|
|
789
789
|
const a = signal(1)
|
|
790
790
|
const b = signal(2)
|
|
791
791
|
let runs = 0
|
|
@@ -802,7 +802,7 @@ describe("signals", () => {
|
|
|
802
802
|
expect(runs).toBe(2)
|
|
803
803
|
})
|
|
804
804
|
|
|
805
|
-
test(
|
|
805
|
+
test('signal peek() reads without tracking', () => {
|
|
806
806
|
const count = signal(0)
|
|
807
807
|
let observed = -1
|
|
808
808
|
const dispose = signalEffect(() => {
|
|
@@ -817,91 +817,91 @@ describe("signals", () => {
|
|
|
817
817
|
|
|
818
818
|
// ─── jsx-runtime ──────────────────────────────────────────────────────────────
|
|
819
819
|
|
|
820
|
-
describe(
|
|
821
|
-
test(
|
|
822
|
-
const vnode = jsx(
|
|
823
|
-
expect(vnode.type).toBe(
|
|
824
|
-
expect(vnode.children).toContain(
|
|
820
|
+
describe('jsx-runtime', () => {
|
|
821
|
+
test('jsx with string type creates element VNode', () => {
|
|
822
|
+
const vnode = jsx('div', { children: 'hello' })
|
|
823
|
+
expect(vnode.type).toBe('div')
|
|
824
|
+
expect(vnode.children).toContain('hello')
|
|
825
825
|
})
|
|
826
826
|
|
|
827
|
-
test(
|
|
828
|
-
const vnode = jsx(
|
|
829
|
-
expect(vnode.props.key).toBe(
|
|
827
|
+
test('jsx with key prop', () => {
|
|
828
|
+
const vnode = jsx('div', { children: 'x' }, 'my-key')
|
|
829
|
+
expect(vnode.props.key).toBe('my-key')
|
|
830
830
|
})
|
|
831
831
|
|
|
832
|
-
test(
|
|
833
|
-
const MyComp = () => pyreonH(
|
|
832
|
+
test('jsx with component wraps for re-render', () => {
|
|
833
|
+
const MyComp = () => pyreonH('span', null, 'hi')
|
|
834
834
|
const vnode = jsx(MyComp, {})
|
|
835
835
|
expect(vnode.type).not.toBe(MyComp)
|
|
836
|
-
expect(typeof vnode.type).toBe(
|
|
836
|
+
expect(typeof vnode.type).toBe('function')
|
|
837
837
|
})
|
|
838
838
|
|
|
839
|
-
test(
|
|
839
|
+
test('jsx with Fragment', () => {
|
|
840
840
|
const vnode = jsx(Fragment, {
|
|
841
|
-
children: [pyreonH(
|
|
841
|
+
children: [pyreonH('span', null, 'a'), pyreonH('span', null, 'b')],
|
|
842
842
|
})
|
|
843
843
|
expect(vnode.type).toBe(Fragment)
|
|
844
844
|
})
|
|
845
845
|
|
|
846
|
-
test(
|
|
847
|
-
const vnode = jsx(
|
|
846
|
+
test('jsx with single child (not array)', () => {
|
|
847
|
+
const vnode = jsx('div', { children: 'text' })
|
|
848
848
|
expect(vnode.children).toHaveLength(1)
|
|
849
849
|
})
|
|
850
850
|
|
|
851
|
-
test(
|
|
852
|
-
const vnode = jsx(
|
|
851
|
+
test('jsx with no children', () => {
|
|
852
|
+
const vnode = jsx('div', {})
|
|
853
853
|
expect(vnode.children).toHaveLength(0)
|
|
854
854
|
})
|
|
855
855
|
|
|
856
|
-
test(
|
|
857
|
-
const MyComp = (props: { children?: string }) => pyreonH(
|
|
858
|
-
const vnode = jsx(MyComp, { children:
|
|
859
|
-
expect(typeof vnode.type).toBe(
|
|
856
|
+
test('jsx component with children in props', () => {
|
|
857
|
+
const MyComp = (props: { children?: string }) => pyreonH('div', null, props.children ?? '')
|
|
858
|
+
const vnode = jsx(MyComp, { children: 'child-text' })
|
|
859
|
+
expect(typeof vnode.type).toBe('function')
|
|
860
860
|
})
|
|
861
861
|
})
|
|
862
862
|
|
|
863
863
|
// ─── Hooks outside component ─────────────────────────────────────────────────
|
|
864
864
|
|
|
865
|
-
describe(
|
|
866
|
-
test(
|
|
867
|
-
expect(() => useState(0)).toThrow(
|
|
865
|
+
describe('hooks outside component', () => {
|
|
866
|
+
test('useState throws when called outside render', () => {
|
|
867
|
+
expect(() => useState(0)).toThrow('Hook called outside')
|
|
868
868
|
})
|
|
869
869
|
|
|
870
|
-
test(
|
|
871
|
-
expect(() => useEffect(() => {})).toThrow(
|
|
870
|
+
test('useEffect throws when called outside render', () => {
|
|
871
|
+
expect(() => useEffect(() => {})).toThrow('Hook called outside')
|
|
872
872
|
})
|
|
873
873
|
|
|
874
|
-
test(
|
|
875
|
-
expect(() => useRef(0)).toThrow(
|
|
874
|
+
test('useRef throws when called outside render', () => {
|
|
875
|
+
expect(() => useRef(0)).toThrow('Hook called outside')
|
|
876
876
|
})
|
|
877
877
|
|
|
878
|
-
test(
|
|
879
|
-
expect(() => useMemo(() => 0, [])).toThrow(
|
|
878
|
+
test('useMemo throws when called outside render', () => {
|
|
879
|
+
expect(() => useMemo(() => 0, [])).toThrow('Hook called outside')
|
|
880
880
|
})
|
|
881
881
|
|
|
882
|
-
test(
|
|
883
|
-
expect(() => useId()).toThrow(
|
|
882
|
+
test('useId throws when called outside render', () => {
|
|
883
|
+
expect(() => useId()).toThrow('Hook called outside')
|
|
884
884
|
})
|
|
885
885
|
|
|
886
|
-
test(
|
|
887
|
-
expect(() => useReducer((s: number) => s, 0)).toThrow(
|
|
886
|
+
test('useReducer throws when called outside render', () => {
|
|
887
|
+
expect(() => useReducer((s: number) => s, 0)).toThrow('Hook called outside')
|
|
888
888
|
})
|
|
889
889
|
})
|
|
890
890
|
|
|
891
891
|
// ─── Edge cases ──────────────────────────────────────────────────────────────
|
|
892
892
|
|
|
893
|
-
describe(
|
|
894
|
-
test(
|
|
895
|
-
const [val] = withHookCtx(() => useState(
|
|
896
|
-
expect(val).toBe(
|
|
893
|
+
describe('edge cases', () => {
|
|
894
|
+
test('useState with string initial', () => {
|
|
895
|
+
const [val] = withHookCtx(() => useState('hello'))
|
|
896
|
+
expect(val).toBe('hello')
|
|
897
897
|
})
|
|
898
898
|
|
|
899
|
-
test(
|
|
900
|
-
const [state] = withHookCtx(() => useReducer((s: string, a: string) => s + a,
|
|
901
|
-
expect(state).toBe(
|
|
899
|
+
test('useReducer with non-function initial', () => {
|
|
900
|
+
const [state] = withHookCtx(() => useReducer((s: string, a: string) => s + a, 'start'))
|
|
901
|
+
expect(state).toBe('start')
|
|
902
902
|
})
|
|
903
903
|
|
|
904
|
-
test(
|
|
904
|
+
test('depsChanged handles different length arrays', () => {
|
|
905
905
|
const runner = createHookRunner()
|
|
906
906
|
runner.run(() => {
|
|
907
907
|
useEffect(() => {}, [1, 2])
|
|
@@ -914,7 +914,7 @@ describe("edge cases", () => {
|
|
|
914
914
|
expect(runner.ctx.pendingEffects).toHaveLength(1)
|
|
915
915
|
})
|
|
916
916
|
|
|
917
|
-
test(
|
|
917
|
+
test('depsChanged with undefined deps always re-runs', () => {
|
|
918
918
|
const runner = createHookRunner()
|
|
919
919
|
runner.run(() => {
|
|
920
920
|
useEffect(() => {})
|