import { type Accessor, onCleanup, untrack } from "solid-js"; import { createStableCallback } from "./stable-callback"; import { useAnimationFrame } from "./use-animation-frame"; /** * A `data-starting-style` marker attribute — matches the convention used by * Base UI React's `TransitionStatusDataAttributes.startingStyle`. Kept inline * here so this primitive doesn't need to depend on a * `stateAttributesMapping` module that may not yet exist in the Solid port. */ const STARTING_STYLE_ATTR = "data-starting-style"; /** * The element source accepted by {@link useAnimationsFinished}. Consumers may * pass either: * * - An accessor that returns the element (preferred — re-reading it lets the * primitive pick up late-mounted elements). * - A plain `HTMLElement | null` reference captured via `ref`. */ export type ElementSource = Accessor | HTMLElement | null; function resolveElement(source: ElementSource): HTMLElement | null { if (source === null) return null; if (typeof source === "function") return source(); return source; } /** * Returns a function that, given a callback, waits for all CSS * animations/transitions on the target element to settle before invoking it. * * Mirrors Base UI React's `useAnimationsFinished` * (`packages/react/src/internals/useAnimationsFinished.ts`), but without * `ReactDOM.flushSync` — Solid 2 uses `flush()` for synchronous application, * and consumers that need a synchronous apply can wrap the callback * themselves. The vast majority of callers just want "run after animations", * so we invoke the callback directly. * * Options: * * - `waitForStartingStyleRemoved`: if `true`, waits for * `[data-starting-style]` to be removed from the element before probing * `getAnimations()`. This matches React's behavior for "open" animations * that aren't registered until the starting-style attribute is cleared. * - `treatAbortedAsFinished`: if `false`, when an animation is aborted the * primitive re-probes for new animations instead of treating it as * finished. * * The returned function is stabilized via {@link createStableCallback} so its * identity never changes — safe to pass through directives or capture in * long-lived subscriptions. * * Solid 2 notes: * * - Must be called from an owned scope (the internal `useAnimationFrame` * registers an `onCleanup`). * - No DOM reads happen during setup; the element is resolved lazily each * time the returned function is called, so it's safe to pass an accessor * that returns `null` on the first call. */ export function useAnimationsFinished( elementSource: ElementSource, waitForStartingStyleRemoved = false, treatAbortedAsFinished = true, ): (fnToExecute: () => void, signal?: AbortSignal | null) => void { const frame = useAnimationFrame(); // Owner-dispose cleanup. The frame-scheduler + any external `signal` each // clean themselves up, but a MutationObserver kicked off in the // `waitForStartingStyleRemoved` branch has no owner tie: if the owning // scope disposes before the attribute is removed (and no signal is // passed), the observer would leak. We track the live observer and the // live per-call AbortController so `onCleanup` can tear everything down // in one shot. let activeObserver: MutationObserver | null = null; let activeInternalAbort: AbortController | null = null; onCleanup(() => { activeObserver?.disconnect(); activeObserver = null; activeInternalAbort?.abort(); activeInternalAbort = null; }); const runWhenAnimationsFinished = ( fnToExecute: () => void, signal: AbortSignal | null = null, ): void => // Side-effect helper — re-runs are scheduled by the caller, not by // this body's reads. Wrapping the entry in `untrack` keeps the // elementSource accessor read AND the consumer callback invocation // from tripping `[STRICT_READ_UNTRACKED]` when the helper is invoked // from an effect's apply scope (e.g. `useOpenChangeComplete`). untrack(() => { frame.cancel(); // New invocation — stop any prior observer/listener we may have set // up for a previous call's `waitForStartingStyleRemoved` cycle AND // abort any in-flight `Promise.all(animations.finished)` from a // previous call, so a stale `.then` can't fire `fnToExecute` after // we've started a fresh cycle (stale state writes on rapid toggle). activeObserver?.disconnect(); activeObserver = null; activeInternalAbort?.abort(); activeInternalAbort = null; const element = resolveElement(elementSource); if (element == null) return; const resolvedElement = element; // Tests and SSR-like environments may omit `getAnimations`; and the // global kill-switch disables animation waiting entirely. if ( typeof resolvedElement.getAnimations !== "function" || (globalThis as { QUOIN_ANIMATIONS_DISABLED?: boolean }) .QUOIN_ANIMATIONS_DISABLED ) { // `untrack` so consumer callbacks reading reactive state during // the synchronous fast-path don't trip `[STRICT_READ_UNTRACKED]` // when this helper is invoked from inside an effect's apply scope // (e.g. `useOpenChangeComplete`'s setOpen-driven effect calling // the popup's `onComplete` straight through). The async path // below already runs after a microtask so it's outside the apply // scope by definition; wrapping `done()` consistently keeps the // synchronous and async semantics identical from the consumer's // perspective. untrack(fnToExecute); return; } // Fresh internal abort controller for this invocation. The // `Promise.all` chain below checks `cycleAbort.signal.aborted` // before calling `done()`, so a later invocation that fires // `cycleAbort.abort()` cancels this cycle's callback. Without this, // a rapid open/close cycle can land the previous close's // `fnToExecute` AFTER the new open's measure has committed. const cycleAbort = new AbortController(); activeInternalAbort = cycleAbort; const done = (): void => { untrack(fnToExecute); }; function exec(): void { Promise.all( resolvedElement .getAnimations() .map((animation) => animation.finished), ) .then(() => { if (cycleAbort.signal.aborted) return; if (!signal?.aborted) done(); }) .catch(() => { if (cycleAbort.signal.aborted) return; if (treatAbortedAsFinished) { if (!signal?.aborted) done(); return; } const currentAnimations = resolvedElement.getAnimations(); if ( !signal?.aborted && currentAnimations.length > 0 && currentAnimations.some( (animation) => animation.pending || animation.playState !== "finished", ) ) { // Animation was aborted because an underlying property changed // mid-flight; re-probe so we wait on the replacement. exec(); } }); } if (waitForStartingStyleRemoved) { // If the attribute isn't present at call-time, fall back to waiting // one frame so late-registered "open" animations get a chance to // show up in `getAnimations()`. if (!resolvedElement.hasAttribute(STARTING_STYLE_ATTR)) { frame.request(exec); return; } const attributeObserver = new MutationObserver(() => { if (!resolvedElement.hasAttribute(STARTING_STYLE_ATTR)) { attributeObserver.disconnect(); if (activeObserver === attributeObserver) activeObserver = null; exec(); } }); attributeObserver.observe(resolvedElement, { attributes: true, attributeFilter: [STARTING_STYLE_ATTR], }); // Track so owner-dispose can disconnect us. `cycleAbort` is this // invocation's abort controller — aborting it (on owner dispose // or on the next invocation of `runWhenAnimationsFinished`) // tears down the observer, and the `Promise.all` branch above // short-circuits `done()` when `cycleAbort.signal.aborted`. activeObserver = attributeObserver; const teardown = () => { attributeObserver.disconnect(); if (activeObserver === attributeObserver) activeObserver = null; }; cycleAbort.signal.addEventListener("abort", teardown, { once: true }); signal?.addEventListener("abort", teardown, { once: true }); return; } frame.request(exec); }); // Expose via `createStableCallback` so the returned function's identity is // stable (matches React's `useStableCallback` contract). The inner // function is already a single closure for this hook's lifetime, but we // keep the trampoline so call-site shape matches React. return createStableCallback( () => runWhenAnimationsFinished, ); }