@graphprotocol/gds-react 0.2.0 → 0.2.1

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.
Files changed (129) hide show
  1. package/dist/GDSContext.d.ts +13 -0
  2. package/dist/GDSContext.d.ts.map +1 -0
  3. package/dist/GDSContext.js +4 -0
  4. package/dist/GDSContext.js.map +1 -0
  5. package/dist/GDSProvider.d.ts +1 -9
  6. package/dist/GDSProvider.d.ts.map +1 -1
  7. package/dist/GDSProvider.js +4 -3
  8. package/dist/GDSProvider.js.map +1 -1
  9. package/dist/components/Avatar.d.ts.map +1 -1
  10. package/dist/components/Avatar.js +2 -2
  11. package/dist/components/Avatar.js.map +1 -1
  12. package/dist/components/Breadcrumbs.parts.js +1 -1
  13. package/dist/components/Breadcrumbs.parts.js.map +1 -1
  14. package/dist/components/Button.d.ts.map +1 -1
  15. package/dist/components/Button.js +69 -69
  16. package/dist/components/Button.js.map +1 -1
  17. package/dist/components/Card.js +2 -2
  18. package/dist/components/Card.js.map +1 -1
  19. package/dist/components/CodeBlock.d.ts +1 -1
  20. package/dist/components/CodeBlock.parts.d.ts +1 -1
  21. package/dist/components/CopyButton.d.ts +1 -1
  22. package/dist/components/CopyButton.d.ts.map +1 -1
  23. package/dist/components/CopyButton.js +46 -19
  24. package/dist/components/CopyButton.js.map +1 -1
  25. package/dist/components/Input.js +2 -2
  26. package/dist/components/Input.js.map +1 -1
  27. package/dist/components/Link.js +2 -2
  28. package/dist/components/Link.js.map +1 -1
  29. package/dist/components/Menu.parts.d.ts +4 -5
  30. package/dist/components/Menu.parts.d.ts.map +1 -1
  31. package/dist/components/Menu.parts.js +49 -44
  32. package/dist/components/Menu.parts.js.map +1 -1
  33. package/dist/components/Modal.parts.d.ts.map +1 -1
  34. package/dist/components/Modal.parts.js +17 -21
  35. package/dist/components/Modal.parts.js.map +1 -1
  36. package/dist/components/Pane.d.ts +9 -0
  37. package/dist/components/Pane.d.ts.map +1 -0
  38. package/dist/components/Pane.js +8 -0
  39. package/dist/components/Pane.js.map +1 -0
  40. package/dist/components/Pane.meta.d.ts +20 -0
  41. package/dist/components/Pane.meta.d.ts.map +1 -0
  42. package/dist/components/Pane.meta.js +30 -0
  43. package/dist/components/Pane.meta.js.map +1 -0
  44. package/dist/components/Pane.parts.d.ts +77 -0
  45. package/dist/components/Pane.parts.d.ts.map +1 -0
  46. package/dist/components/Pane.parts.js +412 -0
  47. package/dist/components/Pane.parts.js.map +1 -0
  48. package/dist/components/Search.js +1 -1
  49. package/dist/components/Tooltip.parts.d.ts +13 -4
  50. package/dist/components/Tooltip.parts.d.ts.map +1 -1
  51. package/dist/components/Tooltip.parts.js +51 -63
  52. package/dist/components/Tooltip.parts.js.map +1 -1
  53. package/dist/components/base/ButtonOrLink.d.ts +1 -1
  54. package/dist/components/base/ButtonOrLink.d.ts.map +1 -1
  55. package/dist/components/base/ButtonOrLink.parts.d.ts +10 -3
  56. package/dist/components/base/ButtonOrLink.parts.d.ts.map +1 -1
  57. package/dist/components/base/ButtonOrLink.parts.js +27 -35
  58. package/dist/components/base/ButtonOrLink.parts.js.map +1 -1
  59. package/dist/components/base/MaybeButtonOrLink.d.ts +19 -2
  60. package/dist/components/base/MaybeButtonOrLink.d.ts.map +1 -1
  61. package/dist/components/base/MaybeButtonOrLink.js +5 -3
  62. package/dist/components/base/MaybeButtonOrLink.js.map +1 -1
  63. package/dist/components/base/Presence.d.ts +157 -0
  64. package/dist/components/base/Presence.d.ts.map +1 -0
  65. package/dist/components/base/Presence.js +808 -0
  66. package/dist/components/base/Presence.js.map +1 -0
  67. package/dist/components/base/index.d.ts +1 -0
  68. package/dist/components/base/index.d.ts.map +1 -1
  69. package/dist/components/base/index.js +1 -0
  70. package/dist/components/base/index.js.map +1 -1
  71. package/dist/components/index.d.ts +2 -0
  72. package/dist/components/index.d.ts.map +1 -1
  73. package/dist/components/index.js +2 -0
  74. package/dist/components/index.js.map +1 -1
  75. package/dist/hooks/useCSSProp.js +1 -1
  76. package/dist/hooks/useCSSProp.js.map +1 -1
  77. package/dist/hooks/useControlled.d.ts.map +1 -1
  78. package/dist/hooks/useControlled.js +6 -4
  79. package/dist/hooks/useControlled.js.map +1 -1
  80. package/dist/hooks/useGDS.js +1 -1
  81. package/dist/hooks/useGDS.js.map +1 -1
  82. package/dist/hooks/useStyleObserver.js +1 -1
  83. package/dist/hooks/useStyleObserver.js.map +1 -1
  84. package/dist/tailwind-plugin.d.ts.map +1 -1
  85. package/dist/tailwind-plugin.js +3 -0
  86. package/dist/tailwind-plugin.js.map +1 -1
  87. package/dist/utils/InlineCounter.d.ts +3 -0
  88. package/dist/utils/InlineCounter.d.ts.map +1 -0
  89. package/dist/utils/InlineCounter.js +7 -0
  90. package/dist/utils/InlineCounter.js.map +1 -0
  91. package/dist/utils/RenderCount.d.ts +3 -0
  92. package/dist/utils/RenderCount.d.ts.map +1 -0
  93. package/dist/utils/RenderCount.js +7 -0
  94. package/dist/utils/RenderCount.js.map +1 -0
  95. package/dist/utils/index.d.ts +2 -0
  96. package/dist/utils/index.d.ts.map +1 -1
  97. package/dist/utils/index.js +2 -0
  98. package/dist/utils/index.js.map +1 -1
  99. package/package.json +14 -14
  100. package/src/GDSContext.ts +16 -0
  101. package/src/GDSProvider.tsx +20 -31
  102. package/src/components/Avatar.tsx +3 -2
  103. package/src/components/Breadcrumbs.parts.tsx +1 -1
  104. package/src/components/Button.tsx +113 -107
  105. package/src/components/Card.tsx +2 -2
  106. package/src/components/CopyButton.tsx +49 -25
  107. package/src/components/Input.tsx +1 -1
  108. package/src/components/Link.tsx +2 -2
  109. package/src/components/Menu.parts.tsx +75 -72
  110. package/src/components/Modal.parts.tsx +26 -31
  111. package/src/components/Pane.meta.ts +31 -0
  112. package/src/components/Pane.parts.tsx +713 -0
  113. package/src/components/Pane.tsx +17 -0
  114. package/src/components/Search.tsx +1 -1
  115. package/src/components/Tooltip.parts.tsx +95 -80
  116. package/src/components/base/ButtonOrLink.parts.tsx +71 -51
  117. package/src/components/base/ButtonOrLink.tsx +1 -0
  118. package/src/components/base/MaybeButtonOrLink.tsx +26 -5
  119. package/src/components/base/Presence.tsx +1375 -0
  120. package/src/components/base/index.ts +1 -0
  121. package/src/components/index.ts +10 -0
  122. package/src/hooks/useCSSProp.ts +1 -1
  123. package/src/hooks/useControlled.ts +16 -8
  124. package/src/hooks/useGDS.ts +1 -1
  125. package/src/hooks/useStyleObserver.ts +1 -1
  126. package/src/tailwind-plugin.ts +3 -0
  127. package/src/utils/InlineCounter.tsx +17 -0
  128. package/src/utils/RenderCount.tsx +7 -0
  129. package/src/utils/index.ts +2 -0
@@ -0,0 +1,1375 @@
1
+ 'use client'
2
+
3
+ import {
4
+ Children,
5
+ createContext,
6
+ isValidElement,
7
+ useCallback,
8
+ useContext,
9
+ useEffect,
10
+ useLayoutEffect,
11
+ useMemo,
12
+ useRef,
13
+ useState,
14
+ type Key,
15
+ type ReactNode,
16
+ type RefObject,
17
+ } from 'react'
18
+ import { flushSync } from 'react-dom'
19
+
20
+ import type { SetRequiredNonNullable } from '@graphprotocol/gds-utils'
21
+
22
+ import { Render, type RenderProp } from './Render.tsx'
23
+
24
+ type VisibleKey = Key | Key[] | null
25
+
26
+ type GetChildKey = (child: ReactNode, index: number) => Key | undefined
27
+
28
+ /**
29
+ * - "entering": just added / made visible, transitioning in.
30
+ * - "visible": present and not transitioning.
31
+ * - "exiting": just removed / made hidden, transitioning out.
32
+ * - "hidden": kept mounted but not shown (persistent mode only).
33
+ */
34
+ type Status = 'entering' | 'visible' | 'exiting' | 'hidden'
35
+
36
+ interface Item {
37
+ key: Key
38
+ element: ReactNode
39
+ status: Status
40
+ /**
41
+ * The previous status before the most recent change, or `null` if status hasn't just changed.
42
+ * Cleared after next frame.
43
+ */
44
+ previousStatus: Status | null
45
+ /** Whether this item was present on mount and hasn't re-entered since. */
46
+ initial: boolean
47
+ }
48
+
49
+ type PresenceItemState = Omit<Item, 'element'>
50
+
51
+ type PresenceWrapperState = {
52
+ /** Whether at least one item is currently transitioning (entering or exiting). */
53
+ transitioning: boolean
54
+ /** Whether at least one item is present (entering or visible). */
55
+ hasPresent: boolean
56
+ /** Array of all currently rendered items (entering, visible, exiting, and hidden). */
57
+ items: Item[]
58
+ }
59
+
60
+ interface PresenceContextValue {
61
+ /** The status of the nearest `Presence` item ancestor. */
62
+ status: Status
63
+ /**
64
+ * Whether all `Presence` item ancestors are present (entering or visible). If any ancestor is
65
+ * exiting or hidden, this will be `false`.
66
+ */
67
+ present: boolean
68
+ }
69
+
70
+ const PresenceContext = createContext<PresenceContextValue | null>(null)
71
+
72
+ /**
73
+ * Hook to access presence information from the nearest `Presence` item ancestor.
74
+ *
75
+ * @returns An object with `status` and `present`, or `null` if not inside a `Presence` component.
76
+ */
77
+ export function usePresence() {
78
+ return useContext(PresenceContext)
79
+ }
80
+
81
+ interface BasePresenceProps {
82
+ /**
83
+ * How long, in milliseconds, children stay in the "entering" status before switching to
84
+ * "visible".
85
+ *
86
+ * @default 0
87
+ */
88
+ enterMs?: number | undefined
89
+ /**
90
+ * How long, in milliseconds, children stay in the "exiting" status before being unmounted (or
91
+ * switching to "hidden", in persistent mode).
92
+ *
93
+ * @default 0
94
+ */
95
+ exitMs?: number | undefined
96
+ /**
97
+ * Whether children that are present on mount should start in the "entering" status, instead of
98
+ * "visible".
99
+ *
100
+ * @default false
101
+ */
102
+ initial?: boolean | undefined
103
+ /**
104
+ * Element or render function for the outer wrapper. By default, renders a fragment (no wrapper).
105
+ *
106
+ * Receives state with `transitioning`, `hasPresent`, and `items`.
107
+ */
108
+ render?: RenderProp<PresenceWrapperState> | undefined
109
+ /**
110
+ * Element or render function for each child's inner wrapper. By default, renders a `div`.
111
+ *
112
+ * Receives props (including `hidden`, `inert`, `data-key`, `data-status`, `data-starting-style`,
113
+ * and `data-initial`) and state (`key`, `status`, `previousStatus`, and `initial`).
114
+ */
115
+ renderChild?: RenderProp<PresenceItemState> | undefined
116
+ /**
117
+ * Function to extract a key from a child node. Keys determine identity: when a child's key
118
+ * changes, the old one exits and the new one enters (triggering exit/enter transitions). If not
119
+ * provided, falls back to the child's `key` prop (with React's internal prefixes stripped) or the
120
+ * index.
121
+ */
122
+ getChildKey?: GetChildKey | undefined
123
+ /**
124
+ * Called synchronously during state update, before React commits DOM changes. Exiting elements
125
+ * are still in their original positions; entering elements don't exist yet (only keys provided).
126
+ * Useful for measuring layout before transitions begin.
127
+ *
128
+ * `previouslyTransitioning` contains items that were already in `entering` or `exiting` status
129
+ * before this transition started (useful for detecting interrupted transitions).
130
+ */
131
+ onBeforeTransitionStart?:
132
+ | ((changes: {
133
+ entering: Set<Key>
134
+ exiting: Map<Key, HTMLElement>
135
+ previouslyTransitioning: Map<Key, 'entering' | 'exiting'>
136
+ }) => void)
137
+ | undefined
138
+ /**
139
+ * Called in a layout effect after React commits DOM changes but before browser paint. Both
140
+ * entering and exiting elements are in the DOM. Useful for triggering CSS transitions.
141
+ *
142
+ * `previouslyTransitioning` contains items that were already in `entering` or `exiting` status
143
+ * before this transition started (useful for detecting interrupted transitions).
144
+ */
145
+ onTransitionStart?:
146
+ | ((changes: {
147
+ entering: Map<Key, HTMLElement>
148
+ exiting: Map<Key, HTMLElement>
149
+ previouslyTransitioning: Map<Key, 'entering' | 'exiting'>
150
+ }) => void)
151
+ | undefined
152
+ /**
153
+ * Called synchronously during state update, before React commits DOM changes. Both entered and
154
+ * exited elements are still in the DOM. This is the last chance to access exited elements.
155
+ */
156
+ onBeforeTransitionEnd?:
157
+ | ((changes: { entered: Map<Key, HTMLElement>; exited: Map<Key, HTMLElement> }) => void)
158
+ | undefined
159
+ /**
160
+ * Called in a layout effect after React commits DOM changes but before browser paint. Entered
161
+ * elements are still in the DOM; exited elements have been removed (only keys provided).
162
+ */
163
+ onTransitionEnd?:
164
+ | ((changes: { entered: Map<Key, HTMLElement>; exited: Set<Key> }) => void)
165
+ | undefined
166
+ children?: ReactNode
167
+ }
168
+
169
+ /** Unmounting mode: delays unmounting for exit transitions (similar to Motion's `AnimatePresence`). */
170
+ interface UnmountingPresenceProps extends BasePresenceProps {
171
+ visibleKey?: undefined
172
+ }
173
+
174
+ /** Persistent mode: keeps children mounted, but toggles their visibility with a delay. */
175
+ interface PersistentPresenceProps extends BasePresenceProps {
176
+ /**
177
+ * Key(s) of children that should be visible. Accepts:
178
+ *
179
+ * - Single key: `"foo"`
180
+ * - Array of keys: `["foo", "bar"]`
181
+ * - `null`: no child is visible.
182
+ */
183
+ visibleKey?: VisibleKey
184
+ }
185
+
186
+ export type PresenceProps = UnmountingPresenceProps | PersistentPresenceProps
187
+
188
+ /**
189
+ * Presence.
190
+ *
191
+ * - If you pass no `visibleKey` → unmounting mode.
192
+ * - If you pass `visibleKey` → persistent mode.
193
+ */
194
+ export function Presence({
195
+ visibleKey,
196
+ enterMs = 0,
197
+ exitMs = 0,
198
+ initial = false,
199
+ render,
200
+ renderChild = <div />,
201
+ getChildKey,
202
+ onBeforeTransitionStart,
203
+ onTransitionStart,
204
+ onBeforeTransitionEnd,
205
+ onTransitionEnd,
206
+ children,
207
+ }: PresenceProps) {
208
+ if (visibleKey !== undefined) {
209
+ return (
210
+ <PersistentPresence
211
+ visibleKey={visibleKey}
212
+ enterMs={enterMs}
213
+ exitMs={exitMs}
214
+ initial={initial}
215
+ render={render}
216
+ renderChild={renderChild}
217
+ getChildKey={getChildKey}
218
+ onBeforeTransitionStart={onBeforeTransitionStart}
219
+ onTransitionStart={onTransitionStart}
220
+ onBeforeTransitionEnd={onBeforeTransitionEnd}
221
+ onTransitionEnd={onTransitionEnd}
222
+ >
223
+ {children}
224
+ </PersistentPresence>
225
+ )
226
+ }
227
+
228
+ return (
229
+ <UnmountingPresence
230
+ enterMs={enterMs}
231
+ exitMs={exitMs}
232
+ initial={initial}
233
+ render={render}
234
+ renderChild={renderChild}
235
+ getChildKey={getChildKey}
236
+ onBeforeTransitionStart={onBeforeTransitionStart}
237
+ onTransitionStart={onTransitionStart}
238
+ onBeforeTransitionEnd={onBeforeTransitionEnd}
239
+ onTransitionEnd={onTransitionEnd}
240
+ >
241
+ {children}
242
+ </UnmountingPresence>
243
+ )
244
+ }
245
+
246
+ /* -------------------------------------------------------------------------- */
247
+ /* Unmounting mode */
248
+ /* -------------------------------------------------------------------------- */
249
+
250
+ interface UnmountingItem extends Item {
251
+ status: Exclude<Status, 'hidden'>
252
+ }
253
+
254
+ interface UnmountingPresenceImplProps extends SetRequiredNonNullable<
255
+ BasePresenceProps,
256
+ 'enterMs' | 'exitMs' | 'initial' | 'renderChild'
257
+ > {
258
+ render?: BasePresenceProps['render']
259
+ getChildKey?: GetChildKey | undefined
260
+ onBeforeTransitionStart?: BasePresenceProps['onBeforeTransitionStart']
261
+ onTransitionStart?: BasePresenceProps['onTransitionStart']
262
+ onBeforeTransitionEnd?: BasePresenceProps['onBeforeTransitionEnd']
263
+ onTransitionEnd?: BasePresenceProps['onTransitionEnd']
264
+ }
265
+
266
+ /**
267
+ * UnmountingPresence.
268
+ *
269
+ * Mental model:
270
+ *
271
+ * - Build a map of all items (previous items + current children).
272
+ * - Update their statuses ("entering" → "visible" when added, "visible" → "exiting" when unmounted,
273
+ * "exiting" → "entering" when re-added).
274
+ * - Sort: streaming merge that walks through previous items, using survivors as anchors. For each
275
+ * survivor, flush all unprocessed current children up to and including it. Exiting items are
276
+ * inserted at their previous position.
277
+ */
278
+ function UnmountingPresence({
279
+ enterMs,
280
+ exitMs,
281
+ initial,
282
+ render,
283
+ renderChild,
284
+ getChildKey,
285
+ onBeforeTransitionStart,
286
+ onTransitionStart,
287
+ onBeforeTransitionEnd,
288
+ onTransitionEnd,
289
+ children,
290
+ }: UnmountingPresenceImplProps) {
291
+ const getChildKeyRef = useRef(getChildKey)
292
+ getChildKeyRef.current = getChildKey
293
+
294
+ const { setElementRef, fireBeforeTransitionEnd, fireCallbacks } = usePresenceCallbacks(
295
+ onBeforeTransitionStart,
296
+ onTransitionStart,
297
+ onBeforeTransitionEnd,
298
+ onTransitionEnd,
299
+ )
300
+
301
+ const [items, setItems] = useState<UnmountingItem[]>(() => {
302
+ const { array } = buildChildMap(children, getChildKey)
303
+ const isEntering = initial && enterMs > 0
304
+ return array.map((child, index) => ({
305
+ key: getStableKey(child, index, getChildKey),
306
+ element: child,
307
+ status: isEntering ? 'entering' : 'visible',
308
+ previousStatus: null,
309
+ initial: true,
310
+ }))
311
+ })
312
+
313
+ const { timeoutsRef, addEntered, addExited, flushCallbackRef } = useTransitionTimeouts()
314
+ // Memoize to avoid re-running `useLayoutEffect` on every render (all callbacks are stable)
315
+ const callbacks = useMemo(
316
+ () => ({ setElementRef, fireBeforeTransitionEnd, fireCallbacks }),
317
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- all callbacks are stable (useCallback with [])
318
+ [],
319
+ )
320
+
321
+ // Set up the flush callback to handle batched transition ends
322
+ flushCallbackRef.current = (entered: Set<Key>, exited: Set<Key>) => {
323
+ callbacks.fireBeforeTransitionEnd(entered, exited)
324
+ setItems((current) =>
325
+ current
326
+ .filter((item) => !exited.has(item.key))
327
+ .map((item) =>
328
+ entered.has(item.key)
329
+ ? { ...item, status: 'visible', previousStatus: item.status }
330
+ : item,
331
+ ),
332
+ )
333
+ }
334
+
335
+ useInitialTransitions(
336
+ initial,
337
+ enterMs,
338
+ () => {
339
+ const keys = new Set<Key>()
340
+ items.forEach((item) => {
341
+ if (item.status === 'entering' && !timeoutsRef.current.has(item.key)) {
342
+ keys.add(item.key)
343
+ }
344
+ })
345
+ return keys
346
+ },
347
+ timeoutsRef,
348
+ callbacks,
349
+ (key) =>
350
+ setItems((curr) =>
351
+ curr.map((i) =>
352
+ i.key === key ? { ...i, status: 'visible', previousStatus: i.status } : i,
353
+ ),
354
+ ),
355
+ )
356
+
357
+ useLayoutEffect(() => {
358
+ const { map: presentByKey, keys: childKeys } = buildChildMap(children, getChildKeyRef.current)
359
+ const nextKeySet = new Set<Key>(childKeys)
360
+
361
+ setItems((prevItems) => {
362
+ const keysToScheduleExitEnd = new Set<Key>()
363
+ const keysToScheduleEnterEnd = new Set<Key>()
364
+ const keysEntering = new Set<Key>()
365
+ const keysExiting = new Set<Key>()
366
+
367
+ // Build map of items that are currently transitioning (before this update)
368
+ const previouslyTransitioning = new Map<Key, 'entering' | 'exiting'>()
369
+ const prevByKey = new Map<Key, UnmountingItem>()
370
+ prevItems.forEach((item) => {
371
+ prevByKey.set(item.key, item)
372
+ if (item.status === 'entering' || item.status === 'exiting') {
373
+ previouslyTransitioning.set(item.key, item.status)
374
+ }
375
+ })
376
+
377
+ const nextItems: UnmountingItem[] = []
378
+
379
+ // Process current children
380
+ childKeys.forEach((key) => {
381
+ const prev = prevByKey.get(key)
382
+ const present = presentByKey.get(key)!
383
+
384
+ if (prev?.status === 'exiting') {
385
+ // Item coming back from exiting
386
+ cancelTimeout(key, timeoutsRef)
387
+ const status = enterMs > 0 ? 'entering' : 'visible'
388
+ nextItems.push({
389
+ key,
390
+ element: present.element,
391
+ status,
392
+ previousStatus: prev.status,
393
+ initial: false,
394
+ })
395
+ keysEntering.add(key)
396
+ if (status === 'entering') {
397
+ keysToScheduleEnterEnd.add(key)
398
+ }
399
+ } else if (prev) {
400
+ // Item already exists, keep its status (but instant-transition "entering" → "visible" if `enterMs` changed to 0)
401
+ const status = prev.status === 'entering' && enterMs <= 0 ? 'visible' : prev.status
402
+ const statusChanged = status !== prev.status
403
+ nextItems.push({
404
+ key,
405
+ element: present.element,
406
+ status,
407
+ previousStatus: statusChanged ? prev.status : prev.previousStatus,
408
+ initial: prev.initial,
409
+ })
410
+ } else {
411
+ // New item
412
+ const status = enterMs > 0 ? 'entering' : 'visible'
413
+ nextItems.push({
414
+ key,
415
+ element: present.element,
416
+ status,
417
+ previousStatus: 'hidden',
418
+ initial: false,
419
+ })
420
+ keysEntering.add(key)
421
+ if (status === 'entering') {
422
+ keysToScheduleEnterEnd.add(key)
423
+ }
424
+ }
425
+ })
426
+
427
+ // Process items being removed
428
+ prevItems.forEach((item) => {
429
+ if (!nextKeySet.has(item.key)) {
430
+ const wasAlreadyExiting = item.status === 'exiting'
431
+ if (!wasAlreadyExiting) {
432
+ cancelTimeout(item.key, timeoutsRef)
433
+ keysExiting.add(item.key)
434
+ if (exitMs > 0) {
435
+ keysToScheduleExitEnd.add(item.key)
436
+ }
437
+ }
438
+ // Keep exiting items if:
439
+ // - `exitMs` > 0: allows exit transition
440
+ // - already exiting: let it finish (e.g., children changed while item was mid-exit)
441
+ if (exitMs > 0 || wasAlreadyExiting) {
442
+ nextItems.push({
443
+ ...item,
444
+ status: 'exiting',
445
+ previousStatus: wasAlreadyExiting ? item.previousStatus : item.status,
446
+ })
447
+ }
448
+ }
449
+ })
450
+
451
+ // Fire callbacks (handles both timed and instant transitions)
452
+ callbacks.fireCallbacks(
453
+ children,
454
+ enterMs,
455
+ exitMs,
456
+ keysEntering,
457
+ keysExiting,
458
+ previouslyTransitioning,
459
+ )
460
+
461
+ // Sort: preserve order by merging current children with previous items
462
+ const itemsByKey = new Map(nextItems.map((item) => [item.key, item]))
463
+ const childIndexByKey = new Map(childKeys.map((key, index) => [key, index]))
464
+ const result: UnmountingItem[] = []
465
+ const added = new Set<Key>()
466
+
467
+ let childIndex = 0
468
+
469
+ const addChild = (key: Key) => {
470
+ if (!added.has(key)) {
471
+ const item = itemsByKey.get(key)
472
+ if (item) {
473
+ result.push(item)
474
+ added.add(key)
475
+ }
476
+ }
477
+ }
478
+
479
+ // Walk through previous items, inserting current children at their positions
480
+ prevItems.forEach((prevItem) => {
481
+ const item = itemsByKey.get(prevItem.key)
482
+ if (!item) return
483
+
484
+ if (item.status === 'exiting') {
485
+ // Exiting items stay at their previous position
486
+ result.push(item)
487
+ added.add(item.key)
488
+ } else {
489
+ // Current child - add all unprocessed children up to and including it
490
+ const indexInChildren = childIndexByKey.get(prevItem.key)
491
+ if (indexInChildren !== undefined) {
492
+ while (childIndex <= indexInChildren) {
493
+ addChild(childKeys[childIndex]!)
494
+ childIndex++
495
+ }
496
+ }
497
+ }
498
+ })
499
+
500
+ // Add any remaining new children
501
+ while (childIndex < childKeys.length) {
502
+ addChild(childKeys[childIndex]!)
503
+ childIndex++
504
+ }
505
+
506
+ // Schedule async transitions (after state update completes)
507
+ scheduleTransitionTimeouts(
508
+ enterMs,
509
+ exitMs,
510
+ keysToScheduleEnterEnd,
511
+ keysToScheduleExitEnd,
512
+ timeoutsRef,
513
+ addEntered,
514
+ addExited,
515
+ )
516
+
517
+ return result
518
+ })
519
+ }, [children, enterMs, exitMs, timeoutsRef, addEntered, addExited, callbacks])
520
+
521
+ // Clear `previousStatus` after the browser has painted the new styles
522
+ useAfterPaint(
523
+ items.some((item) => item.previousStatus !== null),
524
+ () => {
525
+ flushSync(() => {
526
+ setItems((current) =>
527
+ current.map((item) =>
528
+ item.previousStatus !== null ? { ...item, previousStatus: null } : item,
529
+ ),
530
+ )
531
+ })
532
+ },
533
+ )
534
+
535
+ return (
536
+ <PresenceWrapper render={render} items={items}>
537
+ {items.map((item) => (
538
+ <PresenceItemProvider key={item.key} status={item.status}>
539
+ <Render
540
+ ref={(el: HTMLElement | null) => callbacks.setElementRef(item.key, el)}
541
+ render={renderChild}
542
+ inert={item.status === 'exiting'}
543
+ data-key={item.key}
544
+ data-status={item.status}
545
+ data-starting-style={item.previousStatus !== null || undefined}
546
+ data-initial={item.initial || undefined}
547
+ state={{
548
+ key: item.key,
549
+ status: item.status,
550
+ previousStatus: item.previousStatus,
551
+ initial: item.initial,
552
+ }}
553
+ >
554
+ {item.element}
555
+ </Render>
556
+ </PresenceItemProvider>
557
+ ))}
558
+ </PresenceWrapper>
559
+ )
560
+ }
561
+
562
+ /* -------------------------------------------------------------------------- */
563
+ /* Persistent (show/hide) mode */
564
+ /* -------------------------------------------------------------------------- */
565
+
566
+ interface PersistentItem extends Item {
567
+ order: number
568
+ }
569
+
570
+ interface PersistentPresenceImplProps extends SetRequiredNonNullable<
571
+ BasePresenceProps,
572
+ 'enterMs' | 'exitMs' | 'initial' | 'renderChild'
573
+ > {
574
+ visibleKey: VisibleKey
575
+ render?: BasePresenceProps['render']
576
+ getChildKey?: GetChildKey | undefined
577
+ onBeforeTransitionStart?: BasePresenceProps['onBeforeTransitionStart']
578
+ onTransitionStart?: BasePresenceProps['onTransitionStart']
579
+ onBeforeTransitionEnd?: BasePresenceProps['onBeforeTransitionEnd']
580
+ onTransitionEnd?: BasePresenceProps['onTransitionEnd']
581
+ }
582
+
583
+ function normalizeVisibleKey(input: VisibleKey): Set<Key> {
584
+ if (input === null) {
585
+ return new Set()
586
+ }
587
+
588
+ if (Array.isArray(input)) {
589
+ return new Set(input)
590
+ }
591
+
592
+ // Single key - TypeScript can't narrow the union type here
593
+ return new Set([input])
594
+ }
595
+
596
+ /**
597
+ * PersistentPresence.
598
+ *
599
+ * - Tracks children by key in a `Map`.
600
+ * - Items are never unmounted _unless_ you stop rendering them at all.
601
+ * - When a key enters `visibleKey`:
602
+ *
603
+ * - status goes "hidden" → "entering" for `enterMs`
604
+ * - then "visible"
605
+ * - When a key leaves `visibleKey`:
606
+ *
607
+ * - status goes "visible" → "exiting" for `exitMs`
608
+ * - then "hidden" (still mounted, but `hidden` attribute on wrapper)
609
+ */
610
+ function PersistentPresence({
611
+ visibleKey,
612
+ enterMs,
613
+ exitMs,
614
+ initial,
615
+ render,
616
+ renderChild,
617
+ getChildKey,
618
+ onBeforeTransitionStart,
619
+ onTransitionStart,
620
+ onBeforeTransitionEnd,
621
+ onTransitionEnd,
622
+ children,
623
+ }: PersistentPresenceImplProps) {
624
+ const getChildKeyRef = useRef(getChildKey)
625
+ getChildKeyRef.current = getChildKey
626
+
627
+ const { setElementRef, fireBeforeTransitionEnd, fireCallbacks } = usePresenceCallbacks(
628
+ onBeforeTransitionStart,
629
+ onTransitionStart,
630
+ onBeforeTransitionEnd,
631
+ onTransitionEnd,
632
+ )
633
+
634
+ const [itemsMap, setItemsMap] = useState<Map<Key, PersistentItem>>(() => {
635
+ const { array } = buildChildMap(children, getChildKey)
636
+ const visibleSet = normalizeVisibleKey(visibleKey)
637
+ const map = new Map<Key, PersistentItem>()
638
+
639
+ array.forEach((child, index) => {
640
+ const key = getStableKey(child, index, getChildKey)
641
+ const status = getInitialStatus(visibleSet.has(key), initial, enterMs)
642
+ map.set(key, {
643
+ key,
644
+ element: child,
645
+ status,
646
+ previousStatus: null,
647
+ initial: true,
648
+ order: index,
649
+ })
650
+ })
651
+
652
+ return map
653
+ })
654
+
655
+ const { timeoutsRef, addEntered, addExited, flushCallbackRef } = useTransitionTimeouts()
656
+ // Memoize to avoid re-running `useLayoutEffect` on every render (all callbacks are stable)
657
+ const callbacks = useMemo(
658
+ () => ({ setElementRef, fireBeforeTransitionEnd, fireCallbacks }),
659
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- all callbacks are stable (useCallback with [])
660
+ [],
661
+ )
662
+
663
+ // Set up the flush callback to handle batched transition ends
664
+ flushCallbackRef.current = (entered: Set<Key>, exited: Set<Key>) => {
665
+ callbacks.fireBeforeTransitionEnd(entered, exited)
666
+ setItemsMap((current) => {
667
+ const copy = new Map(current)
668
+ exited.forEach((key) => {
669
+ const item = copy.get(key)
670
+ if (item) copy.set(key, { ...item, status: 'hidden', previousStatus: item.status })
671
+ })
672
+ entered.forEach((key) => {
673
+ const item = copy.get(key)
674
+ if (item) copy.set(key, { ...item, status: 'visible', previousStatus: item.status })
675
+ })
676
+ return copy
677
+ })
678
+ }
679
+
680
+ useInitialTransitions(
681
+ initial,
682
+ enterMs,
683
+ () => {
684
+ const keys = new Set<Key>()
685
+ itemsMap.forEach((item) => {
686
+ if (item.status === 'entering' && !timeoutsRef.current.has(item.key)) {
687
+ keys.add(item.key)
688
+ }
689
+ })
690
+ return keys
691
+ },
692
+ timeoutsRef,
693
+ callbacks,
694
+ (key) =>
695
+ setItemsMap((curr) => {
696
+ const copy = new Map(curr)
697
+ const item = copy.get(key)
698
+ if (item?.status === 'entering') {
699
+ copy.set(key, { ...item, status: 'visible', previousStatus: item.status })
700
+ }
701
+ return copy
702
+ }),
703
+ )
704
+
705
+ // Sync items with children and handle visibility transitions
706
+ useLayoutEffect(() => {
707
+ const { map: presentByKey } = buildChildMap(children, getChildKeyRef.current)
708
+ const visibleSet = normalizeVisibleKey(visibleKey)
709
+
710
+ setItemsMap((prevMap) => {
711
+ const nextMap = new Map(prevMap)
712
+ const keysToScheduleExitEnd = new Set<Key>()
713
+ const keysToScheduleEnterEnd = new Set<Key>()
714
+ const keysEntering = new Set<Key>()
715
+ const keysExiting = new Set<Key>()
716
+
717
+ // Build map of items that are currently transitioning (before this update)
718
+ const previouslyTransitioning = new Map<Key, 'entering' | 'exiting'>()
719
+ prevMap.forEach((item) => {
720
+ if (item.status === 'entering' || item.status === 'exiting') {
721
+ previouslyTransitioning.set(item.key, item.status)
722
+ }
723
+ })
724
+
725
+ // 1) Update / add all present keys
726
+ presentByKey.forEach(({ element, index }, key) => {
727
+ const existing = nextMap.get(key)
728
+ if (existing) {
729
+ nextMap.set(key, { ...existing, element, order: index })
730
+ } else {
731
+ const isVisible = visibleSet.has(key)
732
+ const status = getInitialStatus(isVisible, false, enterMs)
733
+ nextMap.set(key, {
734
+ key,
735
+ element,
736
+ status,
737
+ previousStatus: null,
738
+ initial: false,
739
+ order: index,
740
+ })
741
+ // Track new entering items (for callbacks, even if instant)
742
+ if (isVisible) {
743
+ keysEntering.add(key)
744
+ if (status === 'entering') {
745
+ keysToScheduleEnterEnd.add(key)
746
+ }
747
+ }
748
+ }
749
+ })
750
+
751
+ // 2) Remove keys that no longer appear in children
752
+ nextMap.forEach((_item, key) => {
753
+ if (!presentByKey.has(key)) nextMap.delete(key)
754
+ })
755
+
756
+ // 3) Handle visibility transitions for existing items
757
+ nextMap.forEach((item, key) => {
758
+ // Skip newly added items (already handled above)
759
+ if (!prevMap.has(key)) return
760
+
761
+ const shouldBeVisible = visibleSet.has(key)
762
+
763
+ if (shouldBeVisible) {
764
+ if (item.status === 'exiting' || item.status === 'hidden') {
765
+ cancelTimeout(key, timeoutsRef)
766
+ const status = enterMs > 0 ? 'entering' : 'visible'
767
+ nextMap.set(key, { ...item, status, previousStatus: item.status, initial: false })
768
+ keysEntering.add(key)
769
+ if (status === 'entering') {
770
+ keysToScheduleEnterEnd.add(key)
771
+ }
772
+ } else if (item.status === 'entering' && enterMs <= 0) {
773
+ nextMap.set(key, { ...item, status: 'visible', previousStatus: item.status })
774
+ }
775
+ } else if (item.status === 'visible' || item.status === 'entering') {
776
+ cancelTimeout(key, timeoutsRef)
777
+ const status = exitMs > 0 ? 'exiting' : 'hidden'
778
+ nextMap.set(key, { ...item, status, previousStatus: item.status })
779
+ keysExiting.add(key)
780
+ if (status === 'exiting') {
781
+ keysToScheduleExitEnd.add(key)
782
+ }
783
+ } else if (item.status === 'exiting' && exitMs <= 0) {
784
+ nextMap.set(key, { ...item, status: 'hidden', previousStatus: item.status })
785
+ }
786
+ })
787
+
788
+ // Fire callbacks (handles both timed and instant transitions)
789
+ callbacks.fireCallbacks(
790
+ visibleKey,
791
+ enterMs,
792
+ exitMs,
793
+ keysEntering,
794
+ keysExiting,
795
+ previouslyTransitioning,
796
+ )
797
+
798
+ // Schedule async transitions (after state update completes)
799
+ scheduleTransitionTimeouts(
800
+ enterMs,
801
+ exitMs,
802
+ keysToScheduleEnterEnd,
803
+ keysToScheduleExitEnd,
804
+ timeoutsRef,
805
+ addEntered,
806
+ addExited,
807
+ )
808
+
809
+ return nextMap
810
+ })
811
+ }, [children, visibleKey, enterMs, exitMs, timeoutsRef, addEntered, addExited, callbacks])
812
+
813
+ const items = Array.from(itemsMap.values()).sort((a, b) => a.order - b.order)
814
+
815
+ // Clear `previousStatus` after the browser has painted the new styles
816
+ useAfterPaint(
817
+ items.some((item) => item.previousStatus !== null),
818
+ () => {
819
+ flushSync(() => {
820
+ setItemsMap((current) => {
821
+ const copy = new Map(current)
822
+ copy.forEach((item, key) => {
823
+ if (item.previousStatus !== null) {
824
+ copy.set(key, { ...item, previousStatus: null })
825
+ }
826
+ })
827
+ return copy
828
+ })
829
+ })
830
+ },
831
+ )
832
+
833
+ return (
834
+ <PresenceWrapper render={render} items={items}>
835
+ {items.map((item) => (
836
+ <PresenceItemProvider key={item.key} status={item.status}>
837
+ <Render
838
+ ref={(el: HTMLElement | null) => callbacks.setElementRef(item.key, el)}
839
+ render={renderChild}
840
+ hidden={item.status === 'hidden'}
841
+ inert={item.status === 'exiting'}
842
+ data-key={item.key}
843
+ data-status={item.status}
844
+ data-starting-style={item.previousStatus !== null || undefined}
845
+ data-initial={item.initial || undefined}
846
+ state={{
847
+ key: item.key,
848
+ status: item.status,
849
+ previousStatus: item.previousStatus,
850
+ initial: item.initial,
851
+ }}
852
+ >
853
+ {item.element}
854
+ </Render>
855
+ </PresenceItemProvider>
856
+ ))}
857
+ </PresenceWrapper>
858
+ )
859
+ }
860
+
861
+ /* -------------------------------------------------------------------------- */
862
+ /* Helper components and utilities */
863
+ /* -------------------------------------------------------------------------- */
864
+
865
+ /** Renders a wrapper with computed state if the `render` prop is passed. */
866
+ function PresenceWrapper({
867
+ render,
868
+ items,
869
+ children,
870
+ }: {
871
+ render: BasePresenceProps['render']
872
+ items: Item[]
873
+ children: ReactNode
874
+ }) {
875
+ if (!render) {
876
+ return children
877
+ }
878
+
879
+ return (
880
+ <Render
881
+ render={render}
882
+ state={{
883
+ transitioning: items.some(
884
+ (item) => item.status === 'entering' || item.status === 'exiting',
885
+ ),
886
+ hasPresent: items.some((item) => item.status === 'entering' || item.status === 'visible'),
887
+ items,
888
+ }}
889
+ >
890
+ {children}
891
+ </Render>
892
+ )
893
+ }
894
+
895
+ /**
896
+ * Provider component that wraps each `Presence` item to provide presence context to descendants.
897
+ * Computes `present` by checking if this item and all ancestor items are present.
898
+ */
899
+ function PresenceItemProvider({ status, children }: { status: Status; children: ReactNode }) {
900
+ const parentContext = useContext(PresenceContext)
901
+
902
+ // Current item is present if it's entering or visible
903
+ const isPresent = status === 'entering' || status === 'visible'
904
+
905
+ // Overall present state is true only if this item AND all ancestors are present
906
+ const present = isPresent && (parentContext?.present ?? true)
907
+
908
+ return <PresenceContext value={{ status, present }}>{children}</PresenceContext>
909
+ }
910
+
911
+ /**
912
+ * Get a stable key for our internal arrays.
913
+ *
914
+ * - If a custom `getChildKey` function is provided, use it.
915
+ * - Otherwise, use the element's `key` prop (with React's internal prefixes stripped).
916
+ * - Fall back to the index if no key is available.
917
+ */
918
+ function getStableKey(child: ReactNode, index: number, getChildKey?: GetChildKey): Key {
919
+ // Use custom key function if provided and if it returns a value for this child
920
+ if (getChildKey) {
921
+ const customKey = getChildKey(child, index)
922
+ if (customKey !== undefined) {
923
+ return customKey
924
+ }
925
+ }
926
+
927
+ // For valid React elements, use their `key` prop
928
+ if (isValidElement(child)) {
929
+ if (child.key !== null) {
930
+ // Undo prefix added by `Children.toArray` for explicit keys: ".$foo" → "foo"
931
+ if (typeof child.key === 'string' && child.key.startsWith('.$')) {
932
+ const slicedKey = child.key.slice(2)
933
+ if (/^-?\d+(\.\d+)?$/.test(slicedKey)) {
934
+ return Number(slicedKey)
935
+ }
936
+ return slicedKey
937
+ }
938
+ return child.key
939
+ }
940
+ }
941
+
942
+ // Fallback to index for anything else
943
+ return index
944
+ }
945
+
946
+ /** Build a map of child keys to their elements and indices. */
947
+ function buildChildMap(children: ReactNode, getChildKey?: GetChildKey) {
948
+ const array = Children.toArray(children)
949
+ const map = new Map<Key, { element: ReactNode; index: number }>()
950
+ const keys: Key[] = []
951
+
952
+ array.forEach((child, index) => {
953
+ const key = getStableKey(child, index, getChildKey)
954
+ keys.push(key)
955
+ map.set(key, { element: child, index })
956
+ })
957
+
958
+ return { array, map, keys }
959
+ }
960
+
961
+ /** Cancel a pending timeout and remove it from the timeouts map. */
962
+ function cancelTimeout(key: Key, timeoutsRef: RefObject<Map<Key, number>>) {
963
+ const timeoutId = timeoutsRef.current.get(key)
964
+ if (timeoutId !== undefined) {
965
+ window.clearTimeout(timeoutId)
966
+ timeoutsRef.current.delete(key)
967
+ }
968
+ }
969
+
970
+ /**
971
+ * Hook that runs a callback after the browser has painted. Uses double `requestAnimationFrame`: the
972
+ * first rAF schedules before the next paint, the second runs after that paint completes.
973
+ */
974
+ function useAfterPaint(shouldRun: boolean, callback: () => void) {
975
+ useLayoutEffect(() => {
976
+ if (!shouldRun) return
977
+
978
+ let outerFrameId: number
979
+ let innerFrameId: number
980
+ outerFrameId = requestAnimationFrame(() => {
981
+ innerFrameId = requestAnimationFrame(() => {
982
+ callback()
983
+ })
984
+ })
985
+ return () => {
986
+ cancelAnimationFrame(outerFrameId)
987
+ cancelAnimationFrame(innerFrameId)
988
+ }
989
+ })
990
+ }
991
+
992
+ /** Determine initial status for an item based on visibility and transition settings. */
993
+ function getInitialStatus(
994
+ isVisible: boolean,
995
+ initial: boolean,
996
+ enterMs: number,
997
+ ): 'entering' | 'visible' | 'hidden' {
998
+ if (!isVisible) return 'hidden'
999
+ return initial && enterMs > 0 ? 'entering' : 'visible'
1000
+ }
1001
+
1002
+ /**
1003
+ * Hook to manage transition timeouts with batching. Each key gets its own timeout (so cancelling
1004
+ * one doesn't affect others), but callbacks are batched together when timeouts fire in the same
1005
+ * event loop tick.
1006
+ */
1007
+ function useTransitionTimeouts() {
1008
+ const timeoutsRef = useRef<Map<Key, number>>(new Map())
1009
+ const pendingEnteredRef = useRef<Set<Key>>(new Set())
1010
+ const pendingExitedRef = useRef<Set<Key>>(new Set())
1011
+ const flushScheduledRef = useRef(false)
1012
+ const flushCallbackRef = useRef<((entered: Set<Key>, exited: Set<Key>) => void) | null>(null)
1013
+
1014
+ useEffect(() => {
1015
+ const timeouts = timeoutsRef.current
1016
+ return () => {
1017
+ for (const id of timeouts.values()) {
1018
+ window.clearTimeout(id)
1019
+ }
1020
+ timeouts.clear()
1021
+ }
1022
+ }, [])
1023
+
1024
+ const flush = useCallback(() => {
1025
+ flushScheduledRef.current = false
1026
+ const pendingEntered = pendingEnteredRef.current
1027
+ const pendingExited = pendingExitedRef.current
1028
+ if (pendingEntered.size === 0 && pendingExited.size === 0) return
1029
+
1030
+ const entered = new Set(pendingEntered)
1031
+ const exited = new Set(pendingExited)
1032
+ pendingEntered.clear()
1033
+ pendingExited.clear()
1034
+
1035
+ flushCallbackRef.current?.(entered, exited)
1036
+ }, [])
1037
+
1038
+ const scheduleFlush = useCallback(() => {
1039
+ if (flushScheduledRef.current) return
1040
+ flushScheduledRef.current = true
1041
+ // Use setTimeout(0) instead of queueMicrotask because each setTimeout callback is its own
1042
+ // macrotask. Microtasks run at the end of the CURRENT macrotask, so queueMicrotask would flush
1043
+ // before other setTimeout callbacks with the same delay have a chance to run.
1044
+ window.setTimeout(flush, 0)
1045
+ }, [flush])
1046
+
1047
+ const addEntered = useCallback(
1048
+ (key: Key) => {
1049
+ timeoutsRef.current.delete(key)
1050
+ pendingEnteredRef.current.add(key)
1051
+ scheduleFlush()
1052
+ },
1053
+ [scheduleFlush],
1054
+ )
1055
+
1056
+ const addExited = useCallback(
1057
+ (key: Key) => {
1058
+ timeoutsRef.current.delete(key)
1059
+ pendingExitedRef.current.add(key)
1060
+ scheduleFlush()
1061
+ },
1062
+ [scheduleFlush],
1063
+ )
1064
+
1065
+ return { timeoutsRef, addEntered, addExited, flushCallbackRef }
1066
+ }
1067
+
1068
+ /**
1069
+ * Schedule timeouts for entering/exiting transitions. Each key gets its own timeout to ensure
1070
+ * cancelling one key's transition doesn't affect others. Callbacks are batched when timeouts fire
1071
+ * in the same event loop tick.
1072
+ */
1073
+ function scheduleTransitionTimeouts(
1074
+ enterMs: number,
1075
+ exitMs: number,
1076
+ keysToScheduleEnterEnd: Set<Key>,
1077
+ keysToScheduleExitEnd: Set<Key>,
1078
+ timeoutsRef: RefObject<Map<Key, number>>,
1079
+ addEntered: (key: Key) => void,
1080
+ addExited: (key: Key) => void,
1081
+ ) {
1082
+ if (exitMs > 0) {
1083
+ keysToScheduleExitEnd.forEach((key) => {
1084
+ if (timeoutsRef.current.has(key)) return
1085
+ const timeoutId = window.setTimeout(() => addExited(key), exitMs)
1086
+ timeoutsRef.current.set(key, timeoutId)
1087
+ })
1088
+ }
1089
+ if (enterMs > 0) {
1090
+ keysToScheduleEnterEnd.forEach((key) => {
1091
+ if (timeoutsRef.current.has(key)) return
1092
+ const timeoutId = window.setTimeout(() => addEntered(key), enterMs)
1093
+ timeoutsRef.current.set(key, timeoutId)
1094
+ })
1095
+ }
1096
+ }
1097
+
1098
+ /** Hook to handle initial "entering" → "visible" transitions on mount (when `initial={true}`). */
1099
+ function useInitialTransitions(
1100
+ initial: boolean,
1101
+ enterMs: number,
1102
+ getEnteringKeys: () => Set<Key>,
1103
+ timeoutsRef: RefObject<Map<Key, number>>,
1104
+ callbacks: ReturnType<typeof usePresenceCallbacks>,
1105
+ updateItemStatus: (key: Key) => void,
1106
+ ) {
1107
+ const initialCallbacksFiredRef = useRef(false)
1108
+
1109
+ // Fire `onBeforeTransitionStart` and `onTransitionStart` for initial transitions
1110
+ useLayoutEffect(() => {
1111
+ if (!initial || enterMs <= 0 || initialCallbacksFiredRef.current) return
1112
+
1113
+ const enteringKeys = getEnteringKeys()
1114
+ if (enteringKeys.size === 0) return
1115
+
1116
+ initialCallbacksFiredRef.current = true
1117
+ // For initial transitions, elements already exist, so we can fire callbacks directly
1118
+ // Use `enterMs > 0` to ensure `fireCallbacks` treats this as a timed transition
1119
+ // No previously transitioning items on initial mount
1120
+ callbacks.fireCallbacks({}, enterMs, 0, enteringKeys, new Set(), new Map())
1121
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- only run on mount
1122
+ }, [])
1123
+
1124
+ // Schedule timeouts for "entering" → "visible" transitions
1125
+ // This must be a separate effect without a guard because React Strict Mode clears timeouts
1126
+ // on cleanup, and they need to be re-scheduled on the second run.
1127
+ useEffect(() => {
1128
+ if (!initial || enterMs <= 0) return
1129
+
1130
+ const enteringKeys = getEnteringKeys()
1131
+ if (enteringKeys.size === 0) return
1132
+
1133
+ enteringKeys.forEach((key) => {
1134
+ const timeoutId = window.setTimeout(() => {
1135
+ callbacks.fireBeforeTransitionEnd(new Set([key]), new Set())
1136
+ updateItemStatus(key)
1137
+ timeoutsRef.current.delete(key)
1138
+ }, enterMs)
1139
+ timeoutsRef.current.set(key, timeoutId)
1140
+ })
1141
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- only run on mount
1142
+ }, [])
1143
+ }
1144
+
1145
+ /**
1146
+ * Pending callbacks to be fired in `useLayoutEffect`. Using a single object makes the flow clearer
1147
+ * and avoids complex merging logic.
1148
+ */
1149
+ interface PendingCallbacks {
1150
+ /** Keys for `onTransitionStart` (entering items) - fires in `useLayoutEffect` after DOM commit. */
1151
+ transitionStartEntering: Set<Key>
1152
+ /** Keys for `onTransitionStart` (exiting items) - fires in `useLayoutEffect` after DOM commit. */
1153
+ transitionStartExiting: Set<Key>
1154
+ /** Previously transitioning items for `onTransitionStart`. */
1155
+ transitionStartPreviously: Map<Key, 'entering' | 'exiting'>
1156
+ /**
1157
+ * Keys for `onBeforeTransitionEnd` (entered items) - fires in `useLayoutEffect` for instant
1158
+ * enters.
1159
+ */
1160
+ beforeEndEntered: Set<Key>
1161
+ /** Keys for `onTransitionEnd` (entered items) - fires in `useLayoutEffect` after DOM commit. */
1162
+ transitionEndEntered: Set<Key>
1163
+ /** Keys for `onTransitionEnd` (exited items) - fires in `useLayoutEffect` after DOM commit. */
1164
+ transitionEndExited: Set<Key>
1165
+ }
1166
+
1167
+ function createEmptyPending(): PendingCallbacks {
1168
+ return {
1169
+ transitionStartEntering: new Set(),
1170
+ transitionStartExiting: new Set(),
1171
+ transitionStartPreviously: new Map(),
1172
+ beforeEndEntered: new Set(),
1173
+ transitionEndEntered: new Set(),
1174
+ transitionEndExited: new Set(),
1175
+ }
1176
+ }
1177
+
1178
+ /** Hook to manage callback-related refs and helpers for `Presence` components. */
1179
+ function usePresenceCallbacks(
1180
+ onBeforeTransitionStart: BasePresenceProps['onBeforeTransitionStart'],
1181
+ onTransitionStart: BasePresenceProps['onTransitionStart'],
1182
+ onBeforeTransitionEnd: BasePresenceProps['onBeforeTransitionEnd'],
1183
+ onTransitionEnd: BasePresenceProps['onTransitionEnd'],
1184
+ ) {
1185
+ // Store callbacks in refs to avoid re-creating `useCallback` functions
1186
+ const callbackRefs = useRef({
1187
+ onBeforeTransitionStart,
1188
+ onTransitionStart,
1189
+ onBeforeTransitionEnd,
1190
+ onTransitionEnd,
1191
+ })
1192
+ callbackRefs.current = {
1193
+ onBeforeTransitionStart,
1194
+ onTransitionStart,
1195
+ onBeforeTransitionEnd,
1196
+ onTransitionEnd,
1197
+ }
1198
+
1199
+ const elementRefsRef = useRef<Map<Key, HTMLElement>>(new Map())
1200
+ const pendingRef = useRef<PendingCallbacks>(createEmptyPending())
1201
+ // Guard to prevent double-firing in React Strict Mode
1202
+ // Initialize with a unique symbol to avoid collision with any valid `children` value (including `null`)
1203
+ const guardRef = useRef<unknown>(Symbol('unset'))
1204
+
1205
+ const setElementRef = useCallback((key: Key, element: HTMLElement | null) => {
1206
+ if (element) {
1207
+ elementRefsRef.current.set(key, element)
1208
+ } else {
1209
+ elementRefsRef.current.delete(key)
1210
+ }
1211
+ }, [])
1212
+
1213
+ /** Resolves keys to elements from refs. */
1214
+ const resolveElements = useCallback((keys: Set<Key>) => {
1215
+ const map = new Map<Key, HTMLElement>()
1216
+ keys.forEach((key) => {
1217
+ const element = elementRefsRef.current.get(key)
1218
+ if (element) map.set(key, element)
1219
+ })
1220
+ return map
1221
+ }, [])
1222
+
1223
+ /**
1224
+ * Fires `onBeforeTransitionEnd` callback and tracks keys for `onTransitionEnd`. Called from:
1225
+ *
1226
+ * - Render phase for instant exits (`exitMs <= 0`)
1227
+ * - `useLayoutEffect` for instant enters (`enterMs <= 0`)
1228
+ * - `flushCallbackRef.current` for timed transitions (via `useTransitionTimeouts` batching)
1229
+ */
1230
+ const fireBeforeTransitionEnd = useCallback(
1231
+ (entered: Set<Key>, exited: Set<Key>) => {
1232
+ if (entered.size === 0 && exited.size === 0) return
1233
+
1234
+ // Track for the `onTransitionEnd` callback (will fire in `useLayoutEffect`)
1235
+ entered.forEach((key) => pendingRef.current.transitionEndEntered.add(key))
1236
+ exited.forEach((key) => pendingRef.current.transitionEndExited.add(key))
1237
+
1238
+ // Fire the `onBeforeTransitionEnd` callback synchronously
1239
+ callbackRefs.current.onBeforeTransitionEnd?.({
1240
+ entered: resolveElements(entered),
1241
+ exited: resolveElements(exited),
1242
+ })
1243
+ },
1244
+ [resolveElements],
1245
+ )
1246
+
1247
+ /**
1248
+ * Main entry point for firing callbacks during state updates. Handles both timed and instant
1249
+ * transitions:
1250
+ *
1251
+ * - `onBeforeTransitionStart`: Always fires synchronously (entering keys + exiting elements)
1252
+ * - `onTransitionStart` for timed: Tracks for `useLayoutEffect`.
1253
+ * - `onTransitionStart` for instant exits: Fires synchronously (element exists, will be unmounted)
1254
+ * - `onTransitionStart` for instant enters: Tracks for `useLayoutEffect` (element doesn't exist
1255
+ * yet)
1256
+ * - `onBeforeTransitionEnd` for instant exits: Fires synchronously.
1257
+ * - `onBeforeTransitionEnd` for instant enters: Tracks for `useLayoutEffect`.
1258
+ */
1259
+ const fireCallbacks = useCallback(
1260
+ (
1261
+ guardValue: unknown,
1262
+ enterMs: number,
1263
+ exitMs: number,
1264
+ entering: Set<Key>,
1265
+ exiting: Set<Key>,
1266
+ previouslyTransitioning: Map<Key, 'entering' | 'exiting'>,
1267
+ ) => {
1268
+ // Guard against React Strict Mode double-firing
1269
+ if (guardRef.current === guardValue) return
1270
+ if (entering.size === 0 && exiting.size === 0) return
1271
+ guardRef.current = guardValue
1272
+
1273
+ const pending = pendingRef.current
1274
+ const hasInstantExits = exitMs <= 0 && exiting.size > 0
1275
+ const hasInstantEnters = enterMs <= 0 && entering.size > 0
1276
+
1277
+ // 1. Fire `onBeforeTransitionStart` synchronously (always)
1278
+ callbackRefs.current.onBeforeTransitionStart?.({
1279
+ entering,
1280
+ exiting: resolveElements(exiting),
1281
+ previouslyTransitioning,
1282
+ })
1283
+
1284
+ // 2. Handle `onTransitionStart`
1285
+ // - All enters go to `useLayoutEffect` (element may not exist yet)
1286
+ // - Timed exits go to `useLayoutEffect`
1287
+ // - Instant exits fire synchronously (element exists, will be unmounted)
1288
+ entering.forEach((key) => pending.transitionStartEntering.add(key))
1289
+ if (exitMs > 0) {
1290
+ exiting.forEach((key) => pending.transitionStartExiting.add(key))
1291
+ }
1292
+ previouslyTransitioning.forEach((status, key) => {
1293
+ if (!pending.transitionStartPreviously.has(key)) {
1294
+ pending.transitionStartPreviously.set(key, status)
1295
+ }
1296
+ })
1297
+
1298
+ if (hasInstantExits) {
1299
+ // Fire `onTransitionStart` synchronously for instant exits only
1300
+ callbackRefs.current.onTransitionStart?.({
1301
+ entering: new Map(),
1302
+ exiting: resolveElements(exiting),
1303
+ previouslyTransitioning,
1304
+ })
1305
+ }
1306
+
1307
+ // 3. Handle `onBeforeTransitionEnd`
1308
+ // - Instant exits: fire synchronously (element exists)
1309
+ // - Instant enters: track for `useLayoutEffect` (element doesn't exist yet)
1310
+ if (hasInstantExits) {
1311
+ fireBeforeTransitionEnd(new Set(), exiting)
1312
+ }
1313
+ if (hasInstantEnters) {
1314
+ entering.forEach((key) => pending.beforeEndEntered.add(key))
1315
+ }
1316
+ },
1317
+ [resolveElements, fireBeforeTransitionEnd],
1318
+ )
1319
+
1320
+ // Fire pending callbacks in `useLayoutEffect` (after DOM commit, before paint)
1321
+ useLayoutEffect(() => {
1322
+ const pending = pendingRef.current
1323
+ const hasStart =
1324
+ pending.transitionStartEntering.size > 0 || pending.transitionStartExiting.size > 0
1325
+ const hasBeforeEnd = pending.beforeEndEntered.size > 0
1326
+ const hasEnd = pending.transitionEndEntered.size > 0 || pending.transitionEndExited.size > 0
1327
+
1328
+ if (!hasStart && !hasBeforeEnd && !hasEnd) return
1329
+
1330
+ // Capture pending state before resetting
1331
+ const {
1332
+ transitionStartEntering,
1333
+ transitionStartExiting,
1334
+ transitionStartPreviously,
1335
+ beforeEndEntered,
1336
+ transitionEndEntered,
1337
+ transitionEndExited,
1338
+ } = pending
1339
+ pendingRef.current = createEmptyPending()
1340
+
1341
+ // Fire `onTransitionStart`
1342
+ if (hasStart) {
1343
+ callbackRefs.current.onTransitionStart?.({
1344
+ entering: resolveElements(transitionStartEntering),
1345
+ exiting: resolveElements(transitionStartExiting),
1346
+ previouslyTransitioning: transitionStartPreviously,
1347
+ })
1348
+ }
1349
+
1350
+ // Fire `onBeforeTransitionEnd` for instant enters (this also tracks for `onTransitionEnd`)
1351
+ if (hasBeforeEnd) {
1352
+ fireBeforeTransitionEnd(beforeEndEntered, new Set())
1353
+ }
1354
+
1355
+ // Merge any new entries added by `fireBeforeTransitionEnd` above
1356
+ pendingRef.current.transitionEndEntered.forEach((key) => transitionEndEntered.add(key))
1357
+ pendingRef.current.transitionEndExited.forEach((key) => transitionEndExited.add(key))
1358
+ pendingRef.current.transitionEndEntered = new Set()
1359
+ pendingRef.current.transitionEndExited = new Set()
1360
+
1361
+ // Fire `onTransitionEnd` (includes keys from both synchronous and `useLayoutEffect` paths)
1362
+ if (transitionEndEntered.size > 0 || transitionEndExited.size > 0) {
1363
+ callbackRefs.current.onTransitionEnd?.({
1364
+ entered: resolveElements(transitionEndEntered),
1365
+ exited: transitionEndExited,
1366
+ })
1367
+ }
1368
+ })
1369
+
1370
+ return {
1371
+ setElementRef,
1372
+ fireBeforeTransitionEnd,
1373
+ fireCallbacks,
1374
+ }
1375
+ }