@pyreon/kinetic 0.11.3 → 0.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -415,55 +415,64 @@ const getKeyedChildren = (children) => {
415
415
  * Children that appear (new key) animate in. Children that disappear
416
416
  * (removed key) stay in DOM during leave animation, then unmount.
417
417
  * config.tag wraps all children as a container element.
418
+ *
419
+ * In Pyreon, components run once. Pass children as a reactive accessor
420
+ * `() => VNode[]` for the group to detect changes and animate entries/exits.
418
421
  */
419
422
  const GroupRenderer = ({ config, htmlProps, appear, timeout, callbacks, children }) => {
420
423
  const effectiveAppear = appear ?? config.appear ?? false;
421
424
  const effectiveTimeout = timeout ?? config.timeout ?? 5e3;
422
425
  const prevMap = /* @__PURE__ */ new Map();
423
426
  const leavingMap = /* @__PURE__ */ new Map();
424
- const forceUpdateSignal = signal(0);
425
- const currentKeyed = getKeyedChildren(children);
426
- const currentMap = /* @__PURE__ */ new Map();
427
- for (const { key, element } of currentKeyed) currentMap.set(key, element);
428
- const initialKeys = new Set(currentMap.keys());
429
- for (const [key, child] of prevMap) if (!currentMap.has(key)) leavingMap.set(key, child);
430
- for (const key of currentMap.keys()) leavingMap.delete(key);
431
- prevMap.clear();
432
- for (const [key, element] of currentMap) prevMap.set(key, element);
427
+ const forceUpdate = signal(0);
428
+ const getChildren = typeof children === "function" ? children : () => children;
429
+ const initialKeyed = getKeyedChildren(getChildren());
430
+ const initialKeys = new Set(initialKeyed.map((c) => c.key));
431
+ for (const { key, element } of initialKeyed) prevMap.set(key, element);
433
432
  const handleAfterLeave = (key) => {
434
433
  leavingMap.delete(key);
435
434
  callbacks.onAfterLeave?.();
436
- forceUpdateSignal.update((c) => c + 1);
435
+ forceUpdate.update((c) => c + 1);
437
436
  };
438
- const allEntries = [...currentKeyed];
439
- for (const [key, element] of leavingMap) allEntries.push({
440
- key,
441
- element
442
- });
443
- const groupedChildren = allEntries.map(({ key, element }) => {
444
- const isInitial = initialKeys.has(key);
445
- const isShowing = currentMap.has(key);
446
- return /* @__PURE__ */ jsx(TransitionItem, {
447
- show: () => isShowing,
448
- appear: isInitial ? effectiveAppear : true,
449
- timeout: effectiveTimeout,
450
- enterStyle: config.enterStyle,
451
- enterToStyle: config.enterToStyle,
452
- enterTransition: config.enterTransition,
453
- leaveStyle: config.leaveStyle,
454
- leaveToStyle: config.leaveToStyle,
455
- leaveTransition: config.leaveTransition,
456
- enter: config.enter,
457
- enterFrom: config.enterFrom,
458
- enterTo: config.enterTo,
459
- leave: config.leave,
460
- leaveFrom: config.leaveFrom,
461
- leaveTo: config.leaveTo,
462
- onAfterLeave: () => handleAfterLeave(key),
463
- children: element
437
+ return (() => {
438
+ forceUpdate();
439
+ const currentKeyed = getKeyedChildren(getChildren());
440
+ const currentMap = /* @__PURE__ */ new Map();
441
+ for (const { key, element } of currentKeyed) currentMap.set(key, element);
442
+ for (const [key, child] of prevMap) if (!currentMap.has(key) && !leavingMap.has(key)) leavingMap.set(key, child);
443
+ for (const key of currentMap.keys()) leavingMap.delete(key);
444
+ prevMap.clear();
445
+ for (const [key, element] of currentMap) prevMap.set(key, element);
446
+ const allEntries = [...currentKeyed];
447
+ for (const [key, element] of leavingMap) allEntries.push({
448
+ key,
449
+ element
450
+ });
451
+ const groupedChildren = allEntries.map(({ key, element }) => {
452
+ const isInitial = initialKeys.has(key);
453
+ const isShowing = currentMap.has(key);
454
+ return /* @__PURE__ */ jsx(TransitionItem, {
455
+ show: () => isShowing,
456
+ appear: isInitial ? effectiveAppear : true,
457
+ timeout: effectiveTimeout,
458
+ enterStyle: config.enterStyle,
459
+ enterToStyle: config.enterToStyle,
460
+ enterTransition: config.enterTransition,
461
+ leaveStyle: config.leaveStyle,
462
+ leaveToStyle: config.leaveToStyle,
463
+ leaveTransition: config.leaveTransition,
464
+ enter: config.enter,
465
+ enterFrom: config.enterFrom,
466
+ enterTo: config.enterTo,
467
+ leave: config.leave,
468
+ leaveFrom: config.leaveFrom,
469
+ leaveTo: config.leaveTo,
470
+ onAfterLeave: () => handleAfterLeave(key),
471
+ children: element
472
+ });
464
473
  });
474
+ return h(config.tag, { ...htmlProps }, ...groupedChildren);
465
475
  });
466
- return h(config.tag, { ...htmlProps }, ...groupedChildren);
467
476
  };
468
477
 
469
478
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/kinetic",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/pyreon/pyreon",
@@ -44,11 +44,11 @@
44
44
  "typecheck": "tsc --noEmit"
45
45
  },
46
46
  "peerDependencies": {
47
- "@pyreon/core": "^0.11.3",
48
- "@pyreon/reactivity": "^0.11.3"
47
+ "@pyreon/core": "^0.11.5",
48
+ "@pyreon/reactivity": "^0.11.5"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@vitus-labs/tools-rolldown": "^1.15.3",
52
- "@pyreon/typescript": "^0.11.3"
52
+ "@pyreon/typescript": "^0.11.5"
53
53
  }
54
54
  }
@@ -114,12 +114,11 @@ const Transition = ({
114
114
  })
115
115
 
116
116
  const elementRef = createRef<HTMLElement>()
117
+ const childProps = (children.props ?? {}) as Record<string, unknown>
117
118
  const mergedRef = mergeRefs(
118
119
  elementRef,
119
120
  stateRef,
120
- (children.props as Record<string, unknown>)?.ref as
121
- | ((el: HTMLElement | null) => void)
122
- | undefined,
121
+ childProps.ref as ((el: HTMLElement | null) => void) | undefined,
123
122
  )
124
123
 
125
124
  const callbacks = {
@@ -198,9 +197,7 @@ const Transition = ({
198
197
  : cloneVNode(children, {
199
198
  ref: mergedRef,
200
199
  style: mergeStyles(
201
- (children.props as Record<string, unknown>)?.style as
202
- | Record<string, string | number | undefined>
203
- | undefined,
200
+ childProps.style as Record<string, string | number | undefined> | undefined,
204
201
  { display: "none" },
205
202
  ),
206
203
  })
@@ -8,7 +8,12 @@ export type TransitionGroupProps = ClassTransitionProps &
8
8
  TransitionCallbacks & {
9
9
  appear?: boolean | undefined
10
10
  timeout?: number | undefined
11
- children: VNode[]
11
+ /**
12
+ * Children can be a static array OR a reactive accessor `() => VNode[]`.
13
+ * When passed as an accessor, TransitionGroup tracks changes and
14
+ * animates entering/leaving children automatically.
15
+ */
16
+ children: VNode[] | (() => VNode[])
12
17
  }
13
18
 
14
19
  type KeyedChild = { key: string | number; element: VNode }
@@ -29,6 +34,14 @@ const getKeyedChildren = (children: VNode[]): KeyedChild[] => {
29
34
  return result
30
35
  }
31
36
 
37
+ /**
38
+ * Renders children with key-based enter/exit animations.
39
+ *
40
+ * In Pyreon, components run once. For TransitionGroup to detect children
41
+ * changes, pass children as a reactive accessor: `() => VNode[]`.
42
+ * The component uses a reactive accessor internally to diff previous vs
43
+ * current children and animate entries/exits.
44
+ */
32
45
  const TransitionGroup = ({
33
46
  children,
34
47
  appear = false,
@@ -38,71 +51,84 @@ const TransitionGroup = ({
38
51
  }: TransitionGroupProps): VNode | null => {
39
52
  const prevMap = new Map<string | number, VNode>()
40
53
  const leavingMap = new Map<string | number, VNode>()
41
- const forceUpdateSignal = signal(0)
54
+ const forceUpdate = signal(0)
42
55
 
43
- // Build current keyed children map
44
- const currentKeyed = getKeyedChildren(children)
45
- const currentMap = new Map<string | number, VNode>()
46
- for (const { key, element } of currentKeyed) {
47
- currentMap.set(key, element)
48
- }
49
-
50
- // Track initial keys to know which children were present on first render
51
- const initialKeys: Set<string | number> = new Set(currentMap.keys())
52
-
53
- // Detect leaving children (were in prev but not in current)
54
- for (const [key, child] of prevMap) {
55
- if (!currentMap.has(key)) {
56
- leavingMap.set(key, child)
57
- }
58
- }
56
+ // Normalize children to an accessor for uniform handling
57
+ const getChildren = typeof children === "function" ? (children as () => VNode[]) : () => children
59
58
 
60
- // If a leaving child reappears, stop leaving
61
- for (const key of currentMap.keys()) {
62
- leavingMap.delete(key)
63
- }
64
-
65
- // Update prev
66
- prevMap.clear()
67
- for (const [key, element] of currentMap) {
59
+ // Track initial keys for appear animation logic
60
+ const initialKeyed = getKeyedChildren(getChildren())
61
+ const initialKeys = new Set(initialKeyed.map((c) => c.key))
62
+ for (const { key, element } of initialKeyed) {
68
63
  prevMap.set(key, element)
69
64
  }
70
65
 
71
66
  const handleAfterLeave = (key: string | number) => {
72
67
  leavingMap.delete(key)
73
68
  onAfterLeave?.()
74
- forceUpdateSignal.update((c) => c + 1)
69
+ forceUpdate.update((c) => c + 1)
75
70
  }
76
71
 
77
- // Merge current + leaving, preserving insertion order
78
- const allEntries: KeyedChild[] = [...currentKeyed]
72
+ // Reactive accessor re-evaluates when children() or forceUpdate changes.
73
+ // The runtime mounts this via mountReactive + effect, creating a
74
+ // reactive scope that tracks signal reads.
75
+ return (() => {
76
+ // Read forceUpdate to re-evaluate when leaving children finish
77
+ forceUpdate()
78
+
79
+ const currentChildren = getChildren()
80
+ const currentKeyed = getKeyedChildren(currentChildren)
81
+ const currentMap = new Map<string | number, VNode>()
82
+ for (const { key, element } of currentKeyed) {
83
+ currentMap.set(key, element)
84
+ }
85
+
86
+ // Detect leaving children (were in prev but not in current)
87
+ for (const [key, child] of prevMap) {
88
+ if (!currentMap.has(key) && !leavingMap.has(key)) {
89
+ leavingMap.set(key, child)
90
+ }
91
+ }
79
92
 
80
- for (const [key, element] of leavingMap) {
81
- allEntries.push({ key, element })
82
- }
93
+ // If a leaving child reappears, cancel the leave
94
+ for (const key of currentMap.keys()) {
95
+ leavingMap.delete(key)
96
+ }
97
+
98
+ // Update prev for next diff
99
+ prevMap.clear()
100
+ for (const [key, element] of currentMap) {
101
+ prevMap.set(key, element)
102
+ }
103
+
104
+ // Merge current + leaving, preserving insertion order
105
+ const allEntries: KeyedChild[] = [...currentKeyed]
106
+ for (const [key, element] of leavingMap) {
107
+ allEntries.push({ key, element })
108
+ }
83
109
 
84
- return (
85
- <>
86
- {allEntries.map(({ key, element }) => {
87
- // New children (not in initial render) must appear with animation
88
- const isInitial = initialKeys.has(key)
89
- const isShowing = currentMap.has(key)
90
-
91
- return (
92
- <Transition
93
- key={key}
94
- show={() => isShowing}
95
- appear={isInitial ? appear : true}
96
- timeout={timeout}
97
- {...transitionProps}
98
- onAfterLeave={() => handleAfterLeave(key)}
99
- >
100
- {element}
101
- </Transition>
102
- )
103
- })}
104
- </>
105
- )
110
+ return (
111
+ <>
112
+ {allEntries.map(({ key, element }) => {
113
+ const isInitial = initialKeys.has(key)
114
+ const isShowing = currentMap.has(key)
115
+
116
+ return (
117
+ <Transition
118
+ key={key}
119
+ show={() => isShowing}
120
+ appear={isInitial ? appear : true}
121
+ timeout={timeout}
122
+ {...transitionProps}
123
+ onAfterLeave={() => handleAfterLeave(key)}
124
+ >
125
+ {element}
126
+ </Transition>
127
+ )
128
+ })}
129
+ </>
130
+ )
131
+ }) as unknown as VNode
106
132
  }
107
133
 
108
134
  export default TransitionGroup
@@ -40,6 +40,13 @@ const makeConfig = (overrides: Partial<KineticConfig> = {}): KineticConfig => ({
40
40
  ...overrides,
41
41
  })
42
42
 
43
+ /** Unwrap reactive accessors returned by GroupRenderer. */
44
+ const unwrap = (val: any): any => {
45
+ let result = val
46
+ while (typeof result === "function") result = result()
47
+ return result
48
+ }
49
+
43
50
  const makeKeyedChild = (key: string | number, text: string): VNode => ({
44
51
  type: "span",
45
52
  props: { "data-testid": `child-${key}` },
@@ -52,12 +59,14 @@ describe("GroupRenderer", () => {
52
59
  const config = makeConfig()
53
60
  const children = [makeKeyedChild("a", "Alpha"), makeKeyedChild("b", "Beta")]
54
61
 
55
- const vnode = GroupRenderer({
56
- config,
57
- htmlProps: {},
58
- callbacks: {},
59
- children,
60
- })
62
+ const vnode = unwrap(
63
+ GroupRenderer({
64
+ config,
65
+ htmlProps: {},
66
+ callbacks: {},
67
+ children,
68
+ }),
69
+ )
61
70
 
62
71
  expect(vnode).not.toBeNull()
63
72
  expect(vnode?.type).toBe("div")
@@ -67,12 +76,14 @@ describe("GroupRenderer", () => {
67
76
  const config = makeConfig({ tag: "ul" })
68
77
  const children = [makeKeyedChild("a", "Alpha")]
69
78
 
70
- const vnode = GroupRenderer({
71
- config,
72
- htmlProps: {},
73
- callbacks: {},
74
- children,
75
- })
79
+ const vnode = unwrap(
80
+ GroupRenderer({
81
+ config,
82
+ htmlProps: {},
83
+ callbacks: {},
84
+ children,
85
+ }),
86
+ )
76
87
 
77
88
  expect(vnode?.type).toBe("ul")
78
89
  })
@@ -81,12 +92,14 @@ describe("GroupRenderer", () => {
81
92
  const config = makeConfig()
82
93
  const children = [makeKeyedChild("a", "Alpha")]
83
94
 
84
- const vnode = GroupRenderer({
85
- config,
86
- htmlProps: { "data-testid": "group-wrapper", class: "my-group" },
87
- callbacks: {},
88
- children,
89
- })
95
+ const vnode = unwrap(
96
+ GroupRenderer({
97
+ config,
98
+ htmlProps: { "data-testid": "group-wrapper", class: "my-group" },
99
+ callbacks: {},
100
+ children,
101
+ }),
102
+ )
90
103
 
91
104
  const props = vnode?.props as Record<string, unknown>
92
105
  expect(props?.["data-testid"]).toBe("group-wrapper")
@@ -97,12 +110,14 @@ describe("GroupRenderer", () => {
97
110
  const config = makeConfig()
98
111
  const children = [makeKeyedChild("a", "Alpha"), makeKeyedChild("b", "Beta")]
99
112
 
100
- const vnode = GroupRenderer({
101
- config,
102
- htmlProps: {},
103
- callbacks: {},
104
- children,
105
- })
113
+ const vnode = unwrap(
114
+ GroupRenderer({
115
+ config,
116
+ htmlProps: {},
117
+ callbacks: {},
118
+ children,
119
+ }),
120
+ )
106
121
 
107
122
  // The wrapper div should have children that are TransitionItem VNodes
108
123
  const wrapperChildren = vnode?.children
@@ -122,21 +137,25 @@ describe("GroupRenderer", () => {
122
137
 
123
138
  // First render with initial children
124
139
  const initialChildren = [makeKeyedChild("a", "Alpha")]
125
- GroupRenderer({
126
- config,
127
- htmlProps: {},
128
- callbacks: {},
129
- children: initialChildren,
130
- })
140
+ unwrap(
141
+ GroupRenderer({
142
+ config,
143
+ htmlProps: {},
144
+ callbacks: {},
145
+ children: initialChildren,
146
+ }),
147
+ )
131
148
 
132
149
  // Second render with a new child added
133
150
  const updatedChildren = [makeKeyedChild("a", "Alpha"), makeKeyedChild("b", "Beta")]
134
- const vnode = GroupRenderer({
135
- config,
136
- htmlProps: {},
137
- callbacks: {},
138
- children: updatedChildren,
139
- })
151
+ const vnode = unwrap(
152
+ GroupRenderer({
153
+ config,
154
+ htmlProps: {},
155
+ callbacks: {},
156
+ children: updatedChildren,
157
+ }),
158
+ )
140
159
 
141
160
  const wrapperChildren = vnode?.children as VNode[]
142
161
  expect(wrapperChildren.length).toBe(2)
@@ -153,12 +172,14 @@ describe("GroupRenderer", () => {
153
172
  })
154
173
  const children = [makeKeyedChild("a", "Alpha")]
155
174
 
156
- const vnode = GroupRenderer({
157
- config,
158
- htmlProps: {},
159
- callbacks: {},
160
- children,
161
- })
175
+ const vnode = unwrap(
176
+ GroupRenderer({
177
+ config,
178
+ htmlProps: {},
179
+ callbacks: {},
180
+ children,
181
+ }),
182
+ )
162
183
 
163
184
  const wrapperChildren = vnode?.children as VNode[]
164
185
  const transitionItemVNode = wrapperChildren[0] as VNode
@@ -183,12 +204,14 @@ describe("GroupRenderer", () => {
183
204
  })
184
205
  const children = [makeKeyedChild("a", "Alpha")]
185
206
 
186
- const vnode = GroupRenderer({
187
- config,
188
- htmlProps: {},
189
- callbacks: {},
190
- children,
191
- })
207
+ const vnode = unwrap(
208
+ GroupRenderer({
209
+ config,
210
+ htmlProps: {},
211
+ callbacks: {},
212
+ children,
213
+ }),
214
+ )
192
215
 
193
216
  const wrapperChildren = vnode?.children as VNode[]
194
217
  const tiProps = wrapperChildren[0]?.props as Record<string, unknown>
@@ -205,12 +228,14 @@ describe("GroupRenderer", () => {
205
228
  const config = makeConfig({ appear: true })
206
229
  const children = [makeKeyedChild("a", "Alpha")]
207
230
 
208
- const vnode = GroupRenderer({
209
- config,
210
- htmlProps: {},
211
- callbacks: {},
212
- children,
213
- })
231
+ const vnode = unwrap(
232
+ GroupRenderer({
233
+ config,
234
+ htmlProps: {},
235
+ callbacks: {},
236
+ children,
237
+ }),
238
+ )
214
239
 
215
240
  const wrapperChildren = vnode?.children as VNode[]
216
241
  const tiProps = wrapperChildren[0]?.props as Record<string, unknown>
@@ -223,12 +248,14 @@ describe("GroupRenderer", () => {
223
248
  const config = makeConfig({ timeout: 2000 })
224
249
  const children = [makeKeyedChild("a", "Alpha")]
225
250
 
226
- const vnode = GroupRenderer({
227
- config,
228
- htmlProps: {},
229
- callbacks: {},
230
- children,
231
- })
251
+ const vnode = unwrap(
252
+ GroupRenderer({
253
+ config,
254
+ htmlProps: {},
255
+ callbacks: {},
256
+ children,
257
+ }),
258
+ )
232
259
 
233
260
  const wrapperChildren = vnode?.children as VNode[]
234
261
  const tiProps = wrapperChildren[0]?.props as Record<string, unknown>
@@ -240,12 +267,14 @@ describe("GroupRenderer", () => {
240
267
  const config = makeConfig()
241
268
  const children = [makeKeyedChild("a", "Alpha")]
242
269
 
243
- const vnode = GroupRenderer({
244
- config,
245
- htmlProps: {},
246
- callbacks: {},
247
- children,
248
- })
270
+ const vnode = unwrap(
271
+ GroupRenderer({
272
+ config,
273
+ htmlProps: {},
274
+ callbacks: {},
275
+ children,
276
+ }),
277
+ )
249
278
 
250
279
  const wrapperChildren = vnode?.children as VNode[]
251
280
  const tiProps = wrapperChildren[0]?.props as Record<string, unknown>
@@ -258,12 +287,14 @@ describe("GroupRenderer", () => {
258
287
  const keyedChild = makeKeyedChild("a", "Alpha")
259
288
  const unkeyedChild: VNode = { type: "span", props: {}, children: ["No key"], key: null }
260
289
 
261
- const vnode = GroupRenderer({
262
- config,
263
- htmlProps: {},
264
- callbacks: {},
265
- children: [keyedChild, unkeyedChild],
266
- })
290
+ const vnode = unwrap(
291
+ GroupRenderer({
292
+ config,
293
+ htmlProps: {},
294
+ callbacks: {},
295
+ children: [keyedChild, unkeyedChild],
296
+ }),
297
+ )
267
298
 
268
299
  // Only keyed child should be wrapped in TransitionItem
269
300
  const wrapperChildren = vnode?.children as VNode[]
@@ -274,13 +305,15 @@ describe("GroupRenderer", () => {
274
305
  const config = makeConfig({ appear: false })
275
306
  const children = [makeKeyedChild("a", "Alpha")]
276
307
 
277
- const vnode = GroupRenderer({
278
- config,
279
- htmlProps: {},
280
- appear: true,
281
- callbacks: {},
282
- children,
283
- })
308
+ const vnode = unwrap(
309
+ GroupRenderer({
310
+ config,
311
+ htmlProps: {},
312
+ appear: true,
313
+ callbacks: {},
314
+ children,
315
+ }),
316
+ )
284
317
 
285
318
  const wrapperChildren = vnode?.children as VNode[]
286
319
  const tiProps = wrapperChildren[0]?.props as Record<string, unknown>
@@ -292,13 +325,15 @@ describe("GroupRenderer", () => {
292
325
  const config = makeConfig({ timeout: 2000 })
293
326
  const children = [makeKeyedChild("a", "Alpha")]
294
327
 
295
- const vnode = GroupRenderer({
296
- config,
297
- htmlProps: {},
298
- timeout: 3000,
299
- callbacks: {},
300
- children,
301
- })
328
+ const vnode = unwrap(
329
+ GroupRenderer({
330
+ config,
331
+ htmlProps: {},
332
+ timeout: 3000,
333
+ callbacks: {},
334
+ children,
335
+ }),
336
+ )
302
337
 
303
338
  const wrapperChildren = vnode?.children as VNode[]
304
339
  const tiProps = wrapperChildren[0]?.props as Record<string, unknown>
@@ -311,12 +346,14 @@ describe("GroupRenderer", () => {
311
346
  const config = makeConfig()
312
347
  const children = [makeKeyedChild("a", "Alpha")]
313
348
 
314
- const vnode = GroupRenderer({
315
- config,
316
- htmlProps: {},
317
- callbacks: { onAfterLeave },
318
- children,
319
- })
349
+ const vnode = unwrap(
350
+ GroupRenderer({
351
+ config,
352
+ htmlProps: {},
353
+ callbacks: { onAfterLeave },
354
+ children,
355
+ }),
356
+ )
320
357
 
321
358
  // Each TransitionItem child gets an onAfterLeave that calls handleAfterLeave(key)
322
359
  const wrapperChildren = vnode?.children as VNode[]
@@ -333,12 +370,14 @@ describe("GroupRenderer", () => {
333
370
  const config = makeConfig()
334
371
  const children = [makeKeyedChild("a", "Alpha"), makeKeyedChild("b", "Beta")]
335
372
 
336
- const vnode = GroupRenderer({
337
- config,
338
- htmlProps: {},
339
- callbacks: {},
340
- children,
341
- })
373
+ const vnode = unwrap(
374
+ GroupRenderer({
375
+ config,
376
+ htmlProps: {},
377
+ callbacks: {},
378
+ children,
379
+ }),
380
+ )
342
381
 
343
382
  const wrapperChildren = vnode?.children as VNode[]
344
383
 
@@ -353,12 +392,14 @@ describe("GroupRenderer", () => {
353
392
  const config = makeConfig({ appear: false })
354
393
  const children = [makeKeyedChild("a", "Alpha")]
355
394
 
356
- const vnode = GroupRenderer({
357
- config,
358
- htmlProps: {},
359
- callbacks: {},
360
- children,
361
- })
395
+ const vnode = unwrap(
396
+ GroupRenderer({
397
+ config,
398
+ htmlProps: {},
399
+ callbacks: {},
400
+ children,
401
+ }),
402
+ )
362
403
 
363
404
  const wrapperChildren = vnode?.children as VNode[]
364
405
  const tiProps = wrapperChildren[0]?.props as Record<string, unknown>
@@ -371,12 +412,14 @@ describe("GroupRenderer", () => {
371
412
  const config = makeConfig()
372
413
  const children = [makeKeyedChild("a", "Alpha")]
373
414
 
374
- const vnode = GroupRenderer({
375
- config,
376
- htmlProps: {},
377
- callbacks: {},
378
- children,
379
- })
415
+ const vnode = unwrap(
416
+ GroupRenderer({
417
+ config,
418
+ htmlProps: {},
419
+ callbacks: {},
420
+ children,
421
+ }),
422
+ )
380
423
 
381
424
  const wrapperChildren = vnode?.children as VNode[]
382
425
  const tiVNode = wrapperChildren[0] as VNode
@@ -11,7 +11,12 @@ type GroupRendererProps = {
11
11
  appear?: boolean | undefined
12
12
  timeout?: number | undefined
13
13
  callbacks: Partial<TransitionCallbacks>
14
- children: VNode[]
14
+ /**
15
+ * Children can be a static array OR a reactive accessor `() => VNode[]`.
16
+ * When passed as an accessor, GroupRenderer tracks changes and
17
+ * animates entering/leaving children automatically.
18
+ */
19
+ children: VNode[] | (() => VNode[])
15
20
  }
16
21
 
17
22
  type KeyedChild = { key: string | number; element: VNode }
@@ -37,6 +42,9 @@ const getKeyedChildren = (children: VNode[]): KeyedChild[] => {
37
42
  * Children that appear (new key) animate in. Children that disappear
38
43
  * (removed key) stay in DOM during leave animation, then unmount.
39
44
  * config.tag wraps all children as a container element.
45
+ *
46
+ * In Pyreon, components run once. Pass children as a reactive accessor
47
+ * `() => VNode[]` for the group to detect changes and animate entries/exits.
40
48
  */
41
49
  const GroupRenderer = ({
42
50
  config,
@@ -51,74 +59,89 @@ const GroupRenderer = ({
51
59
 
52
60
  const prevMap = new Map<string | number, VNode>()
53
61
  const leavingMap = new Map<string | number, VNode>()
54
- const forceUpdateSignal = signal(0)
62
+ const forceUpdate = signal(0)
55
63
 
56
- const currentKeyed = getKeyedChildren(children)
57
- const currentMap = new Map<string | number, VNode>()
58
- for (const { key, element } of currentKeyed) {
59
- currentMap.set(key, element)
60
- }
61
-
62
- const initialKeys: Set<string | number> = new Set(currentMap.keys())
63
-
64
- // Detect leaving children
65
- for (const [key, child] of prevMap) {
66
- if (!currentMap.has(key)) {
67
- leavingMap.set(key, child)
68
- }
69
- }
64
+ // Normalize children to an accessor
65
+ const getChildren = typeof children === "function" ? (children as () => VNode[]) : () => children
70
66
 
71
- // If a leaving child reappears, stop leaving
72
- for (const key of currentMap.keys()) {
73
- leavingMap.delete(key)
74
- }
75
-
76
- prevMap.clear()
77
- for (const [key, element] of currentMap) {
67
+ // Track initial keys for appear animation logic
68
+ const initialKeyed = getKeyedChildren(getChildren())
69
+ const initialKeys = new Set(initialKeyed.map((c) => c.key))
70
+ for (const { key, element } of initialKeyed) {
78
71
  prevMap.set(key, element)
79
72
  }
80
73
 
81
74
  const handleAfterLeave = (key: string | number) => {
82
75
  leavingMap.delete(key)
83
76
  callbacks.onAfterLeave?.()
84
- forceUpdateSignal.update((c) => c + 1)
77
+ forceUpdate.update((c) => c + 1)
85
78
  }
86
79
 
87
- // Merge current + leaving
88
- const allEntries: KeyedChild[] = [...currentKeyed]
89
- for (const [key, element] of leavingMap) {
90
- allEntries.push({ key, element })
91
- }
80
+ // Reactive accessor re-evaluates when children() or forceUpdate changes
81
+ return (() => {
82
+ forceUpdate()
83
+
84
+ const currentChildren = getChildren()
85
+ const currentKeyed = getKeyedChildren(currentChildren)
86
+ const currentMap = new Map<string | number, VNode>()
87
+ for (const { key, element } of currentKeyed) {
88
+ currentMap.set(key, element)
89
+ }
90
+
91
+ // Detect leaving children
92
+ for (const [key, child] of prevMap) {
93
+ if (!currentMap.has(key) && !leavingMap.has(key)) {
94
+ leavingMap.set(key, child)
95
+ }
96
+ }
97
+
98
+ // Cancel leave if child reappears
99
+ for (const key of currentMap.keys()) {
100
+ leavingMap.delete(key)
101
+ }
102
+
103
+ // Update prev for next diff
104
+ prevMap.clear()
105
+ for (const [key, element] of currentMap) {
106
+ prevMap.set(key, element)
107
+ }
108
+
109
+ // Merge current + leaving
110
+ const allEntries: KeyedChild[] = [...currentKeyed]
111
+ for (const [key, element] of leavingMap) {
112
+ allEntries.push({ key, element })
113
+ }
92
114
 
93
- const groupedChildren = allEntries.map(({ key, element }) => {
94
- const isInitial = initialKeys.has(key)
95
- const isShowing = currentMap.has(key)
96
-
97
- return (
98
- <TransitionItem
99
- show={() => isShowing}
100
- appear={isInitial ? effectiveAppear : true}
101
- timeout={effectiveTimeout}
102
- enterStyle={config.enterStyle}
103
- enterToStyle={config.enterToStyle}
104
- enterTransition={config.enterTransition}
105
- leaveStyle={config.leaveStyle}
106
- leaveToStyle={config.leaveToStyle}
107
- leaveTransition={config.leaveTransition}
108
- enter={config.enter}
109
- enterFrom={config.enterFrom}
110
- enterTo={config.enterTo}
111
- leave={config.leave}
112
- leaveFrom={config.leaveFrom}
113
- leaveTo={config.leaveTo}
114
- onAfterLeave={() => handleAfterLeave(key)}
115
- >
116
- {element}
117
- </TransitionItem>
118
- )
119
- })
120
-
121
- return h(config.tag, { ...htmlProps }, ...groupedChildren)
115
+ const groupedChildren = allEntries.map(({ key, element }) => {
116
+ const isInitial = initialKeys.has(key)
117
+ const isShowing = currentMap.has(key)
118
+
119
+ return (
120
+ <TransitionItem
121
+ show={() => isShowing}
122
+ appear={isInitial ? effectiveAppear : true}
123
+ timeout={effectiveTimeout}
124
+ enterStyle={config.enterStyle}
125
+ enterToStyle={config.enterToStyle}
126
+ enterTransition={config.enterTransition}
127
+ leaveStyle={config.leaveStyle}
128
+ leaveToStyle={config.leaveToStyle}
129
+ leaveTransition={config.leaveTransition}
130
+ enter={config.enter}
131
+ enterFrom={config.enterFrom}
132
+ enterTo={config.enterTo}
133
+ leave={config.leave}
134
+ leaveFrom={config.leaveFrom}
135
+ leaveTo={config.leaveTo}
136
+ onAfterLeave={() => handleAfterLeave(key)}
137
+ >
138
+ {element}
139
+ </TransitionItem>
140
+ )
141
+ })
142
+
143
+ return h(config.tag, { ...htmlProps }, ...groupedChildren)
144
+ }) as unknown as VNode
122
145
  }
123
146
 
124
147
  export default GroupRenderer