import { isServer } from "@solidjs/web"; import { type Accessor, createRenderEffect, createSignal, untrack, } from "solid-js"; /** * Transition status for a mounting/unmounting surface. Mirrors Base UI * React's `TransitionStatus` union * (`packages/react/src/internals/useTransitionStatus.ts`). */ export type TransitionStatus = "starting" | "ending" | "idle" | undefined; export interface UseTransitionStatusReturn { mounted: Accessor; transitionStatus: Accessor; setMounted: (next: boolean) => void; } /** * Enter/exit state machine for animated surfaces (Collapsible, Dialog, * Popover, Tooltip). Ported from Base UI React's `useTransitionStatus`. * * ### Solid 2 port structure * * React's version writes during render and relies on synchronous re-renders * to re-evaluate the state-machine's `if` clauses. Solid 2 has neither * render-phase writes nor read-your-write inside a single effect apply * (microtask batching). Instead, the port uses layered `createRenderEffect`s * that mirror React's structure 1:1 — one effect per React `if`/ * `useIsoLayoutEffect` block, each tracking the exact dependencies it * references. Writes re-fire effects that track the written signal; the * `if`-guards ensure idempotence (the re-fire sees the new value and the * branch no longer matches, so the effect terminates after one write). * * Internal signals opt into `ownedWrite` because effects write to them from * owned scope; without the opt-in Solid 2 throws `SIGNAL_WRITE_IN_OWNED_SCOPE`. */ export function useTransitionStatus( openAccessor: Accessor, enableIdleStateAccessor: Accessor = () => false, deferEndingStateAccessor: Accessor = () => false, /** * When provided, enter settle (`starting` → `idle` / `undefined`) is * deferred until this returns `true`. Used by `createMountTransition` so * `--*-height` is measured in px before `[data-starting-style]` is removed. */ canSettleEnterAccessor: Accessor = () => true, ): UseTransitionStatusReturn { const initialOpen = untrack(openAccessor); const initialEnableIdle = untrack(enableIdleStateAccessor); const [transitionStatus, setTransitionStatus] = createSignal( initialOpen && initialEnableIdle ? "idle" : undefined, { ownedWrite: true, name: "transition-status.transitionStatus" }, ); const [mounted, setMounted] = createSignal(initialOpen, { ownedWrite: true, name: "transition-status.mounted", }); // Effect 1 — mount on open. Mirrors React line 23-26: // if (open && !mounted) { setMounted(true); setTransitionStatus("starting"); } createRenderEffect( () => openAccessor(), (isOpen) => { if (isOpen && !untrack(mounted)) { setMounted(true); setTransitionStatus("starting"); } }, ); // Effect 2 — ending on close (synchronous). Mirrors React line 28-30: // if (!open && mounted && status !== 'ending' && !defer) setStatus('ending'); createRenderEffect( () => ({ isOpen: openAccessor(), isMounted: mounted(), status: transitionStatus(), defer: deferEndingStateAccessor(), }), ({ isOpen, isMounted, status, defer }) => { if (!isOpen && isMounted && status !== "ending" && !defer) { setTransitionStatus("ending"); } }, ); // Effect 3 — reset status on unmount after ending. Mirrors React line 32-34: // if (!open && !mounted && status === 'ending') setStatus(undefined); createRenderEffect( () => ({ isOpen: openAccessor(), isMounted: mounted(), status: transitionStatus(), }), ({ isOpen, isMounted, status }) => { if (!isOpen && !isMounted && status === "ending") { setTransitionStatus(undefined); } }, ); // Effect 4 — deferred ending via rAF. Mirrors React useIsoLayoutEffect at // line 36-48: schedules `setStatus('ending')` on next frame when close // fires in defer mode. createRenderEffect( () => ({ isOpen: openAccessor(), isMounted: mounted(), status: transitionStatus(), defer: deferEndingStateAccessor(), }), ({ isOpen, isMounted, status, defer }) => { if (isServer) return undefined; if (!isOpen && isMounted && status !== "ending" && defer) { const id = requestAnimationFrame(() => { setTransitionStatus("ending"); }); return () => cancelAnimationFrame(id); } return undefined; }, ); // Effect 5 — settle "starting" → undefined (non-idle mode). Mirrors React // line 50-64, but uses **three** rAFs: Solid mounts conditional panel DOM // in the same commit as `starting`, and enter measurement is deferred // until layout is ready — idle must not run until after that paint. createRenderEffect( () => ({ isOpen: openAccessor(), enableIdle: enableIdleStateAccessor(), canSettle: canSettleEnterAccessor(), }), ({ isOpen, enableIdle, canSettle }) => { if (isServer) return undefined; if (!isOpen || enableIdle || !canSettle) { return undefined; } let middleId = 0; let innerId = 0; const outerId = requestAnimationFrame(() => { middleId = requestAnimationFrame(() => { innerId = requestAnimationFrame(() => { setTransitionStatus(undefined); }); }); }); return () => { cancelAnimationFrame(outerId); if (middleId !== 0) cancelAnimationFrame(middleId); if (innerId !== 0) cancelAnimationFrame(innerId); }; }, ); // Effect 6 — idle mode: "starting" → "idle" after rAF. Mirrors React line // 66-82. When enableIdle flips a mounted-open into "starting" and a frame // later drops to "idle". // // Both the synchronous `"starting"` write and the rAF-scheduled `"idle"` // write are gated on `status !== "idle"`: this effect re-fires whenever // any of the tracked deps (`status` included) change, and without the // rAF guard we would keep scheduling frames every time `status` flipped // — including the frame we ourselves scheduled that wrote `"idle"` (the // re-fire then sees status === "idle", no-ops the synchronous write, but // previously queued a brand-new rAF). Guarding both writes on the same // condition keeps the effect idempotent once settled. createRenderEffect( () => ({ isOpen: openAccessor(), isMounted: mounted(), status: transitionStatus(), enableIdle: enableIdleStateAccessor(), canSettle: canSettleEnterAccessor(), }), ({ isOpen, isMounted, status, enableIdle, canSettle }) => { if (isServer) return undefined; if (!isOpen || !enableIdle || !canSettle) { return undefined; } if (status === "idle") { return undefined; } if (isOpen && isMounted) { setTransitionStatus("starting"); } // One rAF, matching React (`useTransitionStatus` idle path) AND the // deferred-ending rAF in Effect 4: enter and exit transitions started // in the same flush (accordion item switch) begin on the SAME frame, // so paired heights interpolate in lockstep. `canSettle` already // guarantees the measured CSS vars + `[data-starting-style]` styles // were committed (with a forced reflow) before this effect runs, so // the browser has the 0-height style snapshot it needs — no extra // frames required. const outerId = requestAnimationFrame(() => { setTransitionStatus("idle"); }); return () => { cancelAnimationFrame(outerId); }; }, ); return { mounted, transitionStatus, setMounted, }; }