import { beginViewTransition, closeViewTransitionRegistration, endViewTransition, getAnimateViews, setViewTransitionCleanup } from "./context.mjs"; import { animateViewLayers } from "./animate-view-layers.mjs"; import { nextTick } from "vue"; import { noop, warnOnce } from "motion-utils"; //#region src/components/animate-view/start-transition.ts /** * View transitions are non-interruptible: starting one while another is * playing makes the browser skip the running transition, and its mid-flight * pseudo-element images leak into the new transition's old snapshot (visual * ghost stacking). React serializes transitions instead — updates committed * during playback wait, then flush as a single batched transition. This * queue mirrors that behavior. */ var isTransitionRunning = false; var updateQueue = []; /** * Run a state update inside a native View Transition. Any `` * component that enters, exits, updates or shares an element during the * update will animate with its configured animations. * * ```ts * startTransition(() => { show.value = !show.value }, { types: ['toggle'] }) * ``` * * If a transition is already playing, the update is queued and flushed — * batched with any others — once it finishes. Queued calls return * `undefined` and their state changes apply only when the queue drains * (React `startTransition` semantics). * * Returns the native `ViewTransition`, or `undefined` when queued, or when * the View Transitions API is unavailable (SSR or unsupported browsers — * the update still runs, without animation). */ function startTransition(update, options = {}) { if (!(typeof document !== "undefined" && typeof document.startViewTransition === "function")) { if (process.env.NODE_ENV !== "production" && typeof document !== "undefined") warnOnce(true, "startTransition: the View Transitions API is not supported in this browser. The update ran without animation."); update(); return; } updateQueue.push({ update, types: options.types ?? [], root: options.root }); if (isTransitionRunning) return void 0; return flushUpdateQueue(); } /** * Drain the queue as a single batched transition. Calls itself recursively * from `finished` to flush updates that arrived during playback; with an * empty queue it just marks the runner idle. */ function flushUpdateQueue() { const batch = updateQueue.splice(0); if (batch.length === 0) { isTransitionRunning = false; return; } isTransitionRunning = true; const types = [...new Set(batch.flatMap((item) => item.types))]; const root = batch.some((item) => item.root); const transition = beginViewTransition(types); /** * Apply layer names on every mounted instance *before* the update runs, * so elements that are about to exit already carry their * `view-transition-name` when the browser captures the old snapshot. */ for (const instance of getAnimateViews()) instance.applyLayerNames(); /** * Suppress the root *before* startViewTransition: the browser captures the * old snapshot synchronously when the transition starts, so doing this in * the update callback would leave the root in the old snapshot — an * old-only layer that fades out as a full-page ghost instead of a hard cut. */ if (!root) suppressRootLayer(); const viewTransition = document.startViewTransition(async () => { if (!root) zeroSizeOverlayPassThrough(); for (const item of batch) await item.update(); await nextTick(); /** * The DOM update has flushed, so every mount/update/unmount belonging * to this transition has happened. Close registration now — the window * must not stay open while the animation plays (which can take * seconds), or unrelated lifecycle events would be misregistered. */ closeViewTransitionRegistration(transition); }); viewTransition.ready.then(() => startParticipantAnimations(transition)).catch(noop); viewTransition.finished.catch(noop).finally(() => { restoreRootLayer(); endViewTransition(transition); flushUpdateQueue(); }); return viewTransition; } var overlayPassThroughAnimation = null; /** * Zero-size the `::view-transition` overlay so pointer events reach the live * document during the transition; running animation groups keep their size * and still block clicks over their snapshots. WAAPI scoped to this * transition's pseudo tree, not a global stylesheet (React's technique). * Must run inside the update callback: animations created before * `startViewTransition()` never bind to the pseudo tree. */ function zeroSizeOverlayPassThrough() { const animation = document.documentElement.animate({ width: [0, 0], height: [0, 0] }, { duration: 0, fill: "forwards", pseudoElement: "::view-transition" }); if (animation.effect?.pseudoElement !== "::view-transition") { animation.cancel(); return; } overlayPassThroughAnimation = animation; } /** * Exclude the document root from the transition (React * `cancelRootViewTransitionName` parity): `view-transition-name: none` * before the transition starts skips the root in both snapshots, so unnamed * content changes hard-cut instead of crossfading the whole page. */ function suppressRootLayer() { const root = document.documentElement; if (root.style.getPropertyValue("view-transition-name") !== "") return; root.style.setProperty("view-transition-name", "none"); } /** Undo `suppressRootLayer`/`zeroSizeOverlayPassThrough` after the transition. */ function restoreRootLayer() { const root = document.documentElement; if (root.style.getPropertyValue("view-transition-name") === "none") root.style.removeProperty("view-transition-name"); overlayPassThroughAnimation?.cancel(); overlayPassThroughAnimation = null; } /** * Pair shared-element participants, then take over the browser's * pseudo-element animations for every participant's layer. Called once the * transition is `ready` — the pseudo tree now exists and its animations can * be retimed or replaced. */ function startParticipantAnimations(transition) { pairSharedParticipants(transition); const seen = /* @__PURE__ */ new Set(); for (const participant of transition.participants) { if (participant.consumed) continue; for (const layerName of participant.layerNames) { if (seen.has(layerName)) continue; seen.add(layerName); /** * Keep the cancel function so an instance unmounted mid-playback can * cancel its layer animation (and suppress `onAnimationComplete`), * mirroring the cleanup React's `` performs. */ const cleanup = animateViewLayers(layerName, participant.type, participant.props, transition.types); setViewTransitionCleanup(participant.handle, cleanup); } } } /** * Share pairing: an exiting and an entering instance with the same `name` * within this transition animate as a shared element, configured by the * entering side's props. The exiting participant is marked `consumed` so * the run loop skips it — the browser has already paired both sides under * one layer name. * * Note: components that neither enter, exit nor re-render are not detected * as participants even if the browser still morphs their position (e.g. * list reorder without a re-render) — their group layer plays with the * browser's default timing. Unlike React's ``, Vue has no * reconciler-level signal for this. */ function pairSharedParticipants(transition) { const exits = transition.participants.filter((p) => p.type === "exit" && p.shareName); for (const participant of transition.participants) { if (participant.type !== "enter" || !participant.shareName) continue; const exit = exits.find((p) => p.shareName === participant.shareName); if (exit) { participant.type = "share"; exit.consumed = true; } } } //#endregion export { startTransition };