solidjs-motion 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/LICENSE +21 -0
  3. package/README.md +140 -0
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.js +2627 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/src/default-values.d.ts +6 -0
  8. package/dist/src/index.d.ts +16 -0
  9. package/dist/src/motion-config.d.ts +14 -0
  10. package/dist/src/motion-proxy.d.ts +103 -0
  11. package/dist/src/presence-context.d.ts +4 -0
  12. package/dist/src/presence.d.ts +95 -0
  13. package/dist/src/primitives/createDrag.d.ts +16 -0
  14. package/dist/src/primitives/createDragControls.d.ts +30 -0
  15. package/dist/src/primitives/createGestures.d.ts +8 -0
  16. package/dist/src/primitives/createInView.d.ts +51 -0
  17. package/dist/src/primitives/createMotion.d.ts +82 -0
  18. package/dist/src/primitives/createPan.d.ts +83 -0
  19. package/dist/src/primitives/createScroll.d.ts +40 -0
  20. package/dist/src/primitives/gesture-state.d.ts +108 -0
  21. package/dist/src/primitives/motion-value.d.ts +111 -0
  22. package/dist/src/primitives/value-registry.d.ts +34 -0
  23. package/dist/src/reduced-motion.d.ts +29 -0
  24. package/dist/src/style.d.ts +79 -0
  25. package/dist/src/types.d.ts +374 -0
  26. package/dist/src/use-motion.d.ts +35 -0
  27. package/dist/src/variants.d.ts +64 -0
  28. package/package.json +78 -0
  29. package/src/default-values.ts +52 -0
  30. package/src/index.ts +60 -0
  31. package/src/motion-config.tsx +37 -0
  32. package/src/motion-proxy.tsx +377 -0
  33. package/src/presence-context.ts +32 -0
  34. package/src/presence.tsx +466 -0
  35. package/src/primitives/createDrag.ts +670 -0
  36. package/src/primitives/createDragControls.ts +101 -0
  37. package/src/primitives/createGestures.ts +145 -0
  38. package/src/primitives/createInView.ts +124 -0
  39. package/src/primitives/createMotion.ts +638 -0
  40. package/src/primitives/createPan.ts +338 -0
  41. package/src/primitives/createScroll.ts +101 -0
  42. package/src/primitives/gesture-state.ts +772 -0
  43. package/src/primitives/motion-value.ts +328 -0
  44. package/src/primitives/value-registry.ts +114 -0
  45. package/src/reduced-motion.ts +51 -0
  46. package/src/style.ts +266 -0
  47. package/src/types.ts +538 -0
  48. package/src/use-motion.tsx +412 -0
  49. package/src/variants.ts +134 -0
@@ -0,0 +1,466 @@
1
+ import { resolveElements } from "@solid-primitives/refs"
2
+ import { createListTransition, createSwitchTransition } from "@solid-primitives/transition-group"
3
+ import {
4
+ type Component,
5
+ createEffect,
6
+ createMemo,
7
+ createSignal,
8
+ type JSX,
9
+ Match,
10
+ onCleanup,
11
+ Switch,
12
+ } from "solid-js"
13
+ import { PresenceContext } from "./presence-context"
14
+ import type { MotionElement, PresenceContextValue } from "./types"
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // <Presence> — exit-animation coordinator for motion children.
18
+ //
19
+ // Architecture (see ADR 0003):
20
+ // - Wraps a conditional JSX subtree (typically `<Show>`, `<For>`, or
21
+ // `<Index>` containing `<motion.*>` elements with `exit` declared).
22
+ // - Resolves children via @solid-primitives/refs (`resolveFirst` /
23
+ // `resolveElements`), then routes them through
24
+ // @solid-primitives/transition-group's `createSwitchTransition` (single)
25
+ // or `createListTransition` (list) which keeps exiting elements in the
26
+ // DOM until we explicitly call `done()`.
27
+ // - Each motion child registers a `runExit` callable via PresenceContext
28
+ // when its `createMotion` runs. The callable flips the state machine's
29
+ // `exit` slot and resolves when the resulting animate settles.
30
+ // - On removal, transition-group calls our `onExit(el, done)`. We look up
31
+ // the registered `runExit` and chain `done` onto its promise. If no
32
+ // `runExit` is registered (a non-motion child, or a motion child with no
33
+ // `exit` prop), `done()` fires immediately and the element disappears.
34
+ //
35
+ // SSR: same JSX shape as client (Provider + PresenceCore + Switch/Match).
36
+ // transition-group's helpers are SSR-safe — they pass children through when
37
+ // no DOM refs have fired — so structural divergence (which would break
38
+ // Solid's hydration marker alignment) is avoided. Each child's initial
39
+ // style is still emitted via the existing `useMotion` SSR contract.
40
+ //
41
+ // Single-vs-list dispatch is decided at first resolution and stable for the
42
+ // Presence instance's lifetime — switching mid-life would require torn-down
43
+ // transition-group state, which neither helper supports. Document the
44
+ // constraint (rare in practice; conditional rendering rarely flips between
45
+ // "one item" and "many items" without unmount).
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Internal helper: maps the public `mode` prop to the value
50
+ * `createSwitchTransition` expects. `popLayout` is intentionally deferred
51
+ * (layout animations are v0.2+); `wait` maps to `out-in`; `sync` is the
52
+ * default and corresponds to transition-group's `parallel`.
53
+ */
54
+ function switchMode(mode: PresenceProps["mode"]): "out-in" | "parallel" {
55
+ return mode === "wait" ? "out-in" : "parallel"
56
+ }
57
+
58
+ /**
59
+ * Find every motion child registered under `root` (or root itself) and
60
+ * return their [element, runExit] pairs. Walks the `runExits` map and
61
+ * tests containment via `Node.contains`, which is O(depth) per check —
62
+ * cheap because n is bounded by the number of motion children Presence
63
+ * is tracking. Order isn't load-bearing; callers Promise.all the runExits.
64
+ */
65
+ function collectSubtreeExits(
66
+ root: MotionElement,
67
+ runExits: Map<MotionElement, () => Promise<void>>,
68
+ ): Array<[MotionElement, () => Promise<void>]> {
69
+ const out: Array<[MotionElement, () => Promise<void>]> = []
70
+ for (const [el, fn] of runExits) {
71
+ if (el === root || root.contains(el)) out.push([el, fn])
72
+ }
73
+ return out
74
+ }
75
+
76
+ export type PresenceProps = {
77
+ /**
78
+ * Exit/enter coordination.
79
+ * - `"sync"` (default) — exit and enter overlap (transition-group's
80
+ * `parallel`). Best for list animations and independent items.
81
+ * - `"wait"` — old child fully exits before the new one enters
82
+ * (transition-group's `out-in`). Single-child only; ignored (with a
83
+ * dev-mode warning) when wrapping a list.
84
+ */
85
+ mode?: "sync" | "wait"
86
+ /**
87
+ * Animate children on first mount. Defaults to `true` (matches motion-
88
+ * react). Set `false` to suppress the entry animation for the very first
89
+ * child(ren); subsequent mounts mid-life still animate.
90
+ */
91
+ initial?: boolean
92
+ /**
93
+ * List-path only — controls what transition-group does with an exiting
94
+ * element while its `exit` animation is playing. Forwarded directly to
95
+ * `@solid-primitives/transition-group`'s `createListTransition`.
96
+ *
97
+ * - `"move-to-end"` (default) — the exiting element is appended to the
98
+ * end of the rendered array so its DOM position changes during exit.
99
+ * Fine for grids/cascades; surprising for vertically-stacked toasts
100
+ * because surviving siblings JUMP up while the dismissed item is
101
+ * still fading out below them.
102
+ * - `"keep-index"` — the exiting element stays at its original index
103
+ * until exit completes. Surviving siblings don't reflow until the
104
+ * slot is released. Best default for notification stacks.
105
+ * - `"remove"` — no exit transition; the element is gone from the
106
+ * rendered array immediately. Useful when the exit is purely visual
107
+ * on the child (e.g., it self-animates via opacity transitions
108
+ * instead of `exit`).
109
+ */
110
+ exitMethod?: "move-to-end" | "keep-index" | "remove"
111
+ children: JSX.Element
112
+ }
113
+
114
+ /**
115
+ * Wraps a conditional or iterated JSX subtree and runs the descendants'
116
+ * `exit` targets before they unmount. Matches motion-react's
117
+ * `<AnimatePresence>` shape but with Solid's `<Show>` / `<For>` /
118
+ * `<Index>` patterns instead of conditional children.
119
+ *
120
+ * Nested motion children are first-class: when an ancestor unmounts,
121
+ * Presence walks the subtree from each resolved child and fires every
122
+ * registered `runExit` it finds in parallel — including motion children
123
+ * nested inside plain wrappers, or descendants whose `exit` label was
124
+ * cascaded down via `m.Provider`. Each motion descendant animates with
125
+ * its own variant/target; transition-group only releases the DOM once
126
+ * the combined `Promise.all` settles. Mirrors motion-react's behavior
127
+ * where a `<motion.div exit={...}>` inside an `<AnimatePresence>` boundary
128
+ * animates correctly regardless of depth.
129
+ *
130
+ * @example Single (conditional unmount)
131
+ * <Presence>
132
+ * <Show when={open()}>
133
+ * <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
134
+ * saved
135
+ * </motion.div>
136
+ * </Show>
137
+ * </Presence>
138
+ *
139
+ * @example List (items entering and exiting independently)
140
+ * <Presence>
141
+ * <For each={items()}>
142
+ * {(item) => (
143
+ * <motion.li exit={{ opacity: 0, x: 20 }}>{item.text}</motion.li>
144
+ * )}
145
+ * </For>
146
+ * </Presence>
147
+ */
148
+ export const Presence: Component<PresenceProps> = (props) => {
149
+ // SSR: render the SAME JSX shape as the client (Provider + PresenceCore +
150
+ // <Switch>+<Match>) so Solid's hydration markers align. The transition-
151
+ // group helpers ARE SSR-safe — they return the source elements unchanged
152
+ // when no DOM refs have fired (i.e. during renderToString) — so calling
153
+ // them server-side is harmless and avoids the structural divergence that
154
+ // would otherwise produce hydration mismatches.
155
+ //
156
+ // The only server-time wrinkle is `<Presence initial={false}>`:
157
+ // useMotion's first-paint composition reads `PresenceContext.initial`
158
+ // and, when false, emits the ANIMATE target style instead of the
159
+ // initial. We honor that by setting the signal's seed value below.
160
+ //
161
+ // ---------- PresenceContext value supplied to descendants ----------
162
+ const runExits = new Map<MotionElement, () => Promise<void>>()
163
+ // Enter callbacks — symmetric to runExits. createMotion registers a
164
+ // `runEnter` when it's inside this Presence; we fire it the moment the
165
+ // element is actually inserted into the DOM (from transition-group's
166
+ // onEnter / onChange.added). One-shot — deleted after firing so we don't
167
+ // re-trigger the enter animate on subsequent renders.
168
+ const runEnters = new Map<MotionElement, () => void>()
169
+ // `initial` is read at construction. We flip it to `true` after the first
170
+ // microtask so mid-life inserts DO animate even if the user set
171
+ // `initial={false}`. Matches motion-react's behavior.
172
+ const [presenceInitial, setPresenceInitial] = createSignal(props.initial ?? true)
173
+ queueMicrotask(() => setPresenceInitial(true))
174
+
175
+ const ctx: PresenceContextValue = {
176
+ register: (el, runExit) => {
177
+ runExits.set(el, runExit)
178
+ },
179
+ unregister: (el) => {
180
+ runExits.delete(el)
181
+ },
182
+ beforeUnmount: (el) => {
183
+ // Walk the subtree rooted at `el` and fire every registered runExit
184
+ // we find — the root's own (if registered) plus every descendant
185
+ // that's a motion child. They all run concurrently; we await the
186
+ // combined Promise.all so transition-group only releases the DOM
187
+ // once every motion descendant has finished its exit, then prune
188
+ // the entries from the registry.
189
+ //
190
+ // This is the mechanism that makes motion-react's parent-cascade
191
+ // exit pattern work for us: when an ancestor unmounts, each nested
192
+ // motion child still runs its OWN exit (its runExit closure already
193
+ // captures the element, `getOpts`, and the state-machine handles,
194
+ // so it doesn't matter that Solid has disposed the surrounding
195
+ // owner). Callers don't need to unregister individually — this
196
+ // method finishes the bookkeeping for the whole subtree.
197
+ const exiting = collectSubtreeExits(el, runExits)
198
+ if (exiting.length === 0) return Promise.resolve()
199
+ return Promise.all(exiting.map((pair) => pair[1]())).then(() => {
200
+ for (const [exitedEl] of exiting) runExits.delete(exitedEl)
201
+ })
202
+ },
203
+ registerEnter: (el, runEnter) => {
204
+ runEnters.set(el, runEnter)
205
+ },
206
+ beforeMount: (el) => {
207
+ const fn = runEnters.get(el)
208
+ runEnters.delete(el)
209
+ fn?.()
210
+ },
211
+ initial: presenceInitial,
212
+ }
213
+
214
+ // CRITICAL — the children-resolution dance lives INSIDE the Provider's
215
+ // JSX scope so that:
216
+ // (a) motion descendants' refs fire under the Provider's owner and
217
+ // `usePresenceContext` resolves to our `ctx` (not the no-op).
218
+ // (b) resolveElements is called from a stable, single-execution scope.
219
+ // solid-motionone's Presence (1.0.4) achieves this by inlining the
220
+ // transition-group call as the Provider's only child; we extend
221
+ // that to two paths by routing through a tiny `PresenceCore`
222
+ // subcomponent so the resolveElements memo isn't recreated.
223
+ return (
224
+ <PresenceContext.Provider value={ctx}>
225
+ <PresenceCore
226
+ source={() => props.children}
227
+ mode={props.mode}
228
+ exitMethod={props.exitMethod}
229
+ appear={presenceInitial}
230
+ ctx={ctx}
231
+ />
232
+ </PresenceContext.Provider>
233
+ )
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Internal subcomponent that owns the transition-group machinery. Running
238
+ // the resolution + transition-group setup inside a Solid component (rather
239
+ // than inline JSX or an IIFE) gives us a clean single-execution scope: the
240
+ // component's body runs ONCE per <Presence> instance, the resolveElements
241
+ // memo is set up ONCE, and the transition's createComputed is owned by this
242
+ // component's lifetime. The Provider's value still flows down because this
243
+ // component is rendered AS A CHILD of the Provider.
244
+ // ---------------------------------------------------------------------------
245
+ type PresenceCoreProps = {
246
+ source: () => JSX.Element
247
+ mode: PresenceProps["mode"]
248
+ exitMethod: PresenceProps["exitMethod"]
249
+ appear: () => boolean
250
+ ctx: PresenceContextValue
251
+ }
252
+
253
+ const PresenceCore: Component<PresenceCoreProps> = (p) => {
254
+ // One resolveElements call — both paths read from it. Calling it twice
255
+ // (e.g. resolveFirst + resolveElements) would create two children-
256
+ // resolution memos and mount the motion descendants twice.
257
+ const resolved = resolveElements(p.source)
258
+
259
+ // Sticky single-vs-list decision — DEFERRED until the source resolves
260
+ // to actual data. The earlier "decide at construction" approach silently
261
+ // broke any `<For>` that started empty: `Array.isArray(null)` is false,
262
+ // so we'd lock into switch mode and drop every list item past the first.
263
+ // Using a memo with a self-prev short-circuit makes the decision sticky
264
+ // once a non-null value arrives, while still reacting to the first
265
+ // populated read whenever that happens.
266
+ const path = createMemo<"switch" | "list" | null>((prev) => {
267
+ if (prev) return prev
268
+ const v = resolved()
269
+ if (v == null) return null
270
+ return Array.isArray(v) ? "list" : "switch"
271
+ })
272
+
273
+ if (process.env.NODE_ENV !== "production") {
274
+ createEffect(() => {
275
+ if (path() === "list" && p.mode === "wait") {
276
+ console.warn(
277
+ '[solidjs-motion] <Presence mode="wait"> has no meaningful effect with a list of children — "wait" sequences a single exiting element before a single entering one. Use it with `<Show>`-style conditional rendering.',
278
+ )
279
+ }
280
+ })
281
+ }
282
+
283
+ // transition-group's helpers return Accessor<Element[]>. Solid renders
284
+ // accessor functions inline (calls them in a tracking scope), but TS
285
+ // sees Accessor, not JSX.Element. The cast is a no-op at runtime.
286
+ //
287
+ // <Switch> + keyed <Match> renders nothing until the path is decided
288
+ // (the children render as null when both Match `when`s are false). The
289
+ // child function form on Match guarantees the create*Transition call
290
+ // happens AT MOST ONCE, on the branch that wins — both paths can't be
291
+ // set up against the same resolveElements memo.
292
+ return (
293
+ <Switch>
294
+ <Match when={path() === "switch"} keyed>
295
+ {(_v) =>
296
+ createSwitchTransition(
297
+ () => {
298
+ const v = resolved()
299
+ return Array.isArray(v) ? (v[0] ?? null) : v
300
+ },
301
+ {
302
+ appear: p.appear(),
303
+ mode: switchMode(p.mode),
304
+ onExit(el, done) {
305
+ // Disable pointer events on the exiting element. In sync
306
+ // mode transition-group keeps the old node in the DOM as
307
+ // a sibling of the new one (with the old one LATER in
308
+ // source order, putting it on top in z-stacking). Without
309
+ // this, the exiting node — even at opacity:0 mid-exit —
310
+ // intercepts pointer events intended for the incoming
311
+ // card, breaking drag and hover on the new element until
312
+ // the exit settles.
313
+ const motionEl = el as MotionElement
314
+ if (motionEl instanceof HTMLElement || motionEl instanceof SVGElement) {
315
+ ;(motionEl.style as CSSStyleDeclaration).pointerEvents = "none"
316
+ }
317
+ // beforeUnmount walks the subtree, fires every descendant
318
+ // motion child's runExit in parallel, awaits the combined
319
+ // Promise.all, AND prunes the registry. We just chain
320
+ // done() onto it.
321
+ p.ctx.beforeUnmount(motionEl).then(done)
322
+ },
323
+ onEnter(el, done) {
324
+ // The element was just inserted into the DOM via
325
+ // setReturned (transition-group's createSwitchTransition
326
+ // does this synchronously before invoking onEnter). Fire
327
+ // the child's registered runEnter callback so its state
328
+ // machine can dispatch the first animate against a
329
+ // connected element — without this step, motion's
330
+ // `animate()` would have already completed off-DOM during
331
+ // the surrounding exit (or before the appear-driven
332
+ // insertion). Then unblock transition-group.
333
+ p.ctx.beforeMount?.(el as MotionElement)
334
+ done()
335
+ },
336
+ },
337
+ ) as unknown as JSX.Element
338
+ }
339
+ </Match>
340
+ <Match when={path() === "list"} keyed>
341
+ {(_v) =>
342
+ createListTransition(() => resolved.toArray(), {
343
+ appear: p.appear(),
344
+ exitMethod: p.exitMethod,
345
+ onChange({ added, removed, finishRemoved }) {
346
+ // Fire enter callbacks for added elements first —
347
+ // createListTransition has already updated the source array
348
+ // (and Solid's render diff has inserted the new nodes)
349
+ // before `onChange` runs, so this is the analogue of the
350
+ // switch path's onEnter timing. Without it the new motion
351
+ // children's first animate would have dispatched off-DOM at
352
+ // template instantiation and lost their commitStyles.
353
+ for (const el of added) {
354
+ p.ctx.beforeMount?.(el as MotionElement)
355
+ }
356
+ if (removed.length === 0) return
357
+ // Disable pointer events on every exiting node (see
358
+ // switch-path onExit for the rationale — same z-stacking
359
+ // trap applies when a removed list item lingers as a
360
+ // sibling of new/unchanged ones).
361
+ for (const el of removed as MotionElement[]) {
362
+ if (el instanceof HTMLElement || el instanceof SVGElement) {
363
+ ;(el.style as CSSStyleDeclaration).pointerEvents = "none"
364
+ }
365
+ }
366
+ // beforeUnmount handles the subtree walk + unregister
367
+ // bookkeeping per root.
368
+ Promise.all((removed as MotionElement[]).map((el) => p.ctx.beforeUnmount(el))).then(
369
+ () => finishRemoved(removed),
370
+ )
371
+ },
372
+ }) as unknown as JSX.Element
373
+ }
374
+ </Match>
375
+ </Switch>
376
+ )
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // useAnimatePresence — imperative hook variant for library authors.
381
+ //
382
+ // Returns `{ Provider, exit }`. The user wraps their own conditional
383
+ // rendering in the returned Provider and calls `exit()` to trigger exit
384
+ // animations on every motion child currently registered. Resolves when all
385
+ // settle.
386
+ //
387
+ // Use this when:
388
+ // - You're a library author whose internal mount state can't be a Solid
389
+ // `<Show>` (e.g., route transitions controlled by an external state
390
+ // machine; toast queues with non-Solid lifecycle).
391
+ // - You need imperative control over WHEN exits trigger (e.g., await
392
+ // network completion before unmounting).
393
+ //
394
+ // For 95% of application code, prefer `<Presence>` — it handles the
395
+ // children-resolver dance and list semantics automatically.
396
+ // ---------------------------------------------------------------------------
397
+
398
+ export type UseAnimatePresenceOptions = {
399
+ /** Same semantics as `<Presence initial>`. Defaults to `true`. */
400
+ initial?: boolean
401
+ }
402
+
403
+ export type UseAnimatePresenceResult = {
404
+ /**
405
+ * Provider component to wrap your conditional rendering. Every motion
406
+ * descendant inside this Provider registers with the hook's internal
407
+ * registry and is reachable via `exit()`.
408
+ */
409
+ Provider: Component<{ children: JSX.Element }>
410
+ /**
411
+ * Trigger exit on every motion child currently registered with this
412
+ * hook. Resolves when all exit animations have settled. Calling `exit()`
413
+ * does NOT unmount anything — the caller is responsible for flipping
414
+ * their mount signal once the promise resolves.
415
+ */
416
+ exit: () => Promise<void>
417
+ }
418
+
419
+ export function useAnimatePresence(options?: UseAnimatePresenceOptions): UseAnimatePresenceResult {
420
+ const runExits = new Map<MotionElement, () => Promise<void>>()
421
+ const [presenceInitial, setPresenceInitial] = createSignal(options?.initial ?? true)
422
+ // Flip to true after the first microtask so mid-life mounts always animate,
423
+ // regardless of the initial setting.
424
+ queueMicrotask(() => setPresenceInitial(true))
425
+
426
+ const ctx: PresenceContextValue = {
427
+ register: (el, runExit) => {
428
+ runExits.set(el, runExit)
429
+ },
430
+ unregister: (el) => {
431
+ runExits.delete(el)
432
+ },
433
+ beforeUnmount: (el) => {
434
+ // Mirrors `<Presence>`'s subtree-walk semantics (see its
435
+ // beforeUnmount JSDoc). The hook's own `exit()` API instead
436
+ // iterates the full registry directly — but anyone who hands
437
+ // this ctx to a transition-coordinator that calls `beforeUnmount`
438
+ // gets the same descendant-cascade behavior.
439
+ const exiting = collectSubtreeExits(el, runExits)
440
+ if (exiting.length === 0) return Promise.resolve()
441
+ return Promise.all(exiting.map((pair) => pair[1]())).then(() => {
442
+ for (const [exitedEl] of exiting) runExits.delete(exitedEl)
443
+ })
444
+ },
445
+ initial: presenceInitial,
446
+ }
447
+
448
+ // Free the registry on owner disposal. Motion children deliberately do NOT
449
+ // unregister in their own onCleanup (see ADR 0003 — that would race ahead
450
+ // of the exit window), so when the hook's owner unmounts without ever
451
+ // calling exit(), this is the only path that drops the entries.
452
+ onCleanup(() => runExits.clear())
453
+
454
+ const Provider: Component<{ children: JSX.Element }> = (p) => (
455
+ <PresenceContext.Provider value={ctx}>{p.children}</PresenceContext.Provider>
456
+ )
457
+
458
+ const exit = async (): Promise<void> => {
459
+ // Snapshot the registry at call time so concurrent registrations during
460
+ // the await don't get added to this batch.
461
+ const snapshot = [...runExits.values()]
462
+ await Promise.all(snapshot.map((fn) => fn()))
463
+ }
464
+
465
+ return { Provider, exit }
466
+ }