Skip to content

Commit 39bf8bf

Browse files
JonathonRPclaudecoderabbitai[bot]
authored
Fix AnimatePresence race conditions and double callbacks (#53)
* fix(AnimatePresence): resolve presenceAffectsLayout no-op, isPresent race condition, and double safeToRemove - PresenceChild: import untrack and use it in Effect 2 so that only `refresh` (which equals `isPresent` when presenceAffectsLayout=false, or undefined when true) drives re-runs — props inside memoContext() are no longer tracked as dependencies, making presenceAffectsLayout actually control which effect owns context updates - PresenceChild: guard memoContext.onExitComplete with an early return when the child is already marked complete, preventing a double call to the onExitComplete prop when both the layout-animation path (layoutSafeToRemove) and the exit-animation path resolve for the same element - UseVisualElement: add a direct presenceContext.subscribe() call (legacy-mode store pattern, matching AnimationState.svelte) that synchronously keeps element.isPresent in sync; the deferred $: block cannot update isPresent fast enough before framesync's batcher reads it on the next animation frame https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * chore: add pnpm-lock.yaml Generated by pnpm install during build verification. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * examples: add AnimatePresence playground demo for svelte.dev/playground Self-contained App.svelte to paste into the Svelte playground to verify the three bug fixes on this branch: - Bug 1: presenceAffectsLayout checkbox controls whether siblings A & B shift during exit (untrack fix in PresenceChild Effect 2) - Bug 2: rapid Hide/Show — exit animation always fires (synchronous presenceContext.subscribe fix in UseVisualElement) - Bug 3: onExitComplete counter increments by exactly 1 per hide, never double (early-return guard in memoContext.onExitComplete) https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * chore: add changeset and pkg.pr.new preview publishing - .changeset/fix-animate-presence-bugs.md: patch bump entry describing the three AnimatePresence fixes; will trigger 0.1.20 → 0.1.21 when merged via the changesets/action release PR - .github/workflows/main.yaml: add Preview job that builds the package and runs pkg-pr-new publish on every push, so each commit gets an installable preview URL posted as a PR comment - examples/AnimatePresenceDemo.svelte: update import to use the pkg.pr.new URL pattern so the playground tests the actual fixed code from the branch rather than the published 0.1.20 https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * build: add CDN pre-compiled bundle as default export for pkg.pr.new The Svelte playground resolves 'motion-start' by package name through its Svelte-aware CDN which compiles .svelte files. But a direct pkg.pr.new URL is a plain fetch — it hits dist/index.js which re-exports raw .svelte files, causing 'Unexpected character \u{1f}'. Fix: add vite.lib.config.ts using @sveltejs/vite-plugin-svelte to pre-compile all Svelte components to plain JS. Output: dist/cdn/index.js (~190 kB gzipped ~51 kB, svelte-runtime external, deps bundled). - package.json default export now points to dist/cdn/index.js so the bare pkg.pr.new URL serves pre-compiled JS automatically - Svelte-aware bundlers still pick the svelte condition and get source .svelte files for their own compilation — no regression for library users - Build order: svelte-package first (cleans dist/), CDN build second, publint last so the default export exists when it validates https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix(examples): use explicit /dist/cdn/index.js path in playground import The playground requests with the svelte export condition which still resolves to dist/index.js (.svelte re-exports). Appending the explicit file path bypasses export-map resolution entirely and serves the pre-compiled CDN bundle directly. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix(cdn): suppress svelte/internal bare specifiers in CDN bundle - Add discloseVersion:false to svelte compiler options to remove the svelte/internal/disclose-version side-effect import entirely - Narrow rollupOptions.external so svelte/internal/flags/legacy is bundled (tiny stateless flags module) rather than left as a bare specifier - svelte/internal/client remains external (shared lifecycle singleton required for setContext/getContext to work across component boundary) The playground's Rollup bundler can now resolve the CDN bundle without hitting unknown bare specifiers, fixing the "Could not load" error. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix(cdn): make CDN bundle fully self-contained (no bare svelte specifiers) Remove rollupOptions.external entirely so svelte runtime (svelte/internal/client, svelte/store, etc.) is bundled directly into dist/cdn/index.js. The Svelte playground's Rollup resolver cannot resolve svelte/internal/* bare specifiers from external CDN modules — a fully self-contained bundle sidesteps this entirely. discloseVersion:false is retained to trim unnecessary code. Bundle grows from 190KB to 254KB (71KB gzip) — acceptable for playground use. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix(demo): use bare pkg.pr.new URL (no deep path suffix) pkg.pr.new resolves package imports through the exports map — deep file paths like /dist/cdn/index.js are not supported. The bare URL https://pkg.pr.new/motion-start@{sha} resolves via the "default" export condition which already points to dist/cdn/index.js (the self-contained CDN bundle). https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix(demo): switch to jsDelivr GitHub CDN for playground import pkg.pr.new serves bare package URLs as tarballs (not ES modules) and doesn't support deep file path access reliably. Switch to jsDelivr's GitHub CDN which serves any committed file with proper CORS headers and application/javascript content type. Changes: - Add !/dist/cdn/ exception to .gitignore so the pre-compiled bundle is committed to the branch (jsDelivr reads directly from git objects) - Commit dist/cdn/index.js (254KB self-contained bundle, no bare specifiers) - Update playground import to use jsdelivr.net/gh/... URL format - Update demo instructions: use the git commit SHA, no CI wait needed https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix(cdn): externalize svelte/internal/client to share playground runtime Bundling svelte/internal/client caused the "Cannot read properties of null (reading 'f')" crash: the playground mounts components through its own Svelte instance, but our components checked flags ($.f) in a separate bundled copy. Fix: keep svelte/internal/client as a bare specifier so the playground's Rollup resolver maps it to the same instance used by user code. jsDelivr serves the file; the playground then resolves the bare specifier and deduplicates it with user code's import — one shared runtime, component flags work correctly. svelte/internal/flags/legacy is bundled (tiny, no cross-instance state). discloseVersion:false already removes svelte/internal/disclose-version. Remaining bare specifiers: svelte, svelte/store, svelte/internal/client. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix(PresenceChild): defer context.set() past effect execution to avoid internal_set error Calling context.set(memoContext()) synchronously inside $effect causes the store subscriber to fire while the effect is still on the call stack. The subscriber updates a Svelte 5 reactive signal via $.mutate → $.internal_set, which Svelte 5 treats as an unsafe mutation and throws. Fix: capture the context value synchronously (preserving reactive tracking of isPresent / refresh as $effect dependencies) then defer the actual store.set() call with tick().then() so it runs after the current effect completes. This unblocks the toggle — the context update now reaches UseVisualElement's presenceContext subscription cleanly, setting visualElement.isPresent = false before animateChanges() fires on the next tick. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * ci: publish ./dist folder with pkg-pr-new preview Pass './dist' explicitly so pkg-pr-new includes the built output (both the standard library build and the CDN bundle) in the preview package, making the pkg.pr.new install URL resolve to the compiled files rather than source. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * ci: remove quotes from pkg-pr-new publish path https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * ci: move preview to dedicated workflow, remove CDN build artifacts - Extract Preview job from CI workflow into .github/workflows/preview.yaml - Remove vite.lib.config.ts (CDN-specific build config no longer needed) - Remove dist/cdn/index.js (committed CDN bundle no longer needed) - pkg-pr-new now publishes ./dist so the built output is included in the preview package automatically on every push https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * revert: restore package.json to pre-CDN state, remove pnpm-lock.yaml - Revert default export back to ./dist/index.js (was changed to ./dist/cdn/index.js) - Restore build script to bun --bun package only - Restore package script to include publint --strict - Remove build:cdn script - Remove pnpm-lock.yaml (project uses bun) https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Apply suggestion from @JonathonRP * fix: update paths in package.json and add configVersion to bun.lock * fix: update paths in package.json and preview workflow for correct file references * fix: format build step in preview workflow for better readability * fix: correct export paths in preview workflow for publishing from dist folder * fix: streamline build and publish steps in preview workflow * fix: update package.json exports and paths for consistency and clarity * fix: add template option to publish step in preview workflow * feat: add StackBlitz template for AnimatePresence demo * fix: resolve AnimatePresence bugs and improve demo functionality * refactor: update AnimatePresence demo for Svelte 5 syntax * fix: add motion-start peer dependencies and tslib to StackBlitz template * fix: move tslib to dependencies (runtime requirement) * fix: add tslib as dependency (required by framesync, popmotion, style-value-types) * fix: add Vite optimizeDeps for StackBlitz compatibility * chore: update lockfile * fix: add tsconfig files and tslib to StackBlitz template * fix: remove tslib from root package.json (not needed, peer dep only) * fix: update @sveltejs/vite-plugin-svelte to v6 for Svelte 5 support * fix: simplify template to only require motion-start dependency * fix: initialize PresenceChild context synchronously to fix exit animations Root cause of all three AnimatePresence bugs: context.set(memoContext()) was deferred via tick(), so children calling usePresence() during their own initialization saw a null store. This caused: - id = undefined (counter never incremented) - no registration in presenceChildren map - usePresence returned AlwaysPresent ([true, null]) always - exit animations never fired (isPresent always true in Exit.svelte) - onExitComplete fired immediately on every hide (presenceChildren empty) Fix: call context.set(memoContext()) synchronously in script body before setContext(), so children see the real PresenceContextProps on init. The tick()-deferred effects still handle reactive updates when props change. Also update examples/AnimatePresenceDemo.svelte to use standard 'motion-start' package import instead of a CDN URL. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix: synchronize context initialization in PresenceChild to resolve exit animation issues * fix: adjust workflow triggers for pull requests and push events in preview.yaml * fix: register presence children via subscription instead of one-shot get() The root cause: PresenceChild defers context.set() via tick(), so when children call usePresence() during initialization the store is still null. The old code did get(context) at init → always null → id=undefined, onMount register was also null → no registration, AlwaysPresent returned, exit animations never fired, presenceChildren empty → onExitComplete fired immediately (via keyset fallback) on every hide. Fix: replace the get()+onMount pattern with a store subscription that registers the child as soon as the context becomes non-null. Because Effect 2 in PresenceChild is declared before Effect 3 (keyset), both tick()-deferred callbacks fire FIFO — context.set() fires first, the subscription registers the child, then the keyset check fires and sees presenceChildren.size > 0, so the premature onExitComplete call is skipped. Also revert the synchronous context.set(memoContext()) added in the previous commit — that caused effect_update_depth_exceeded because setting a store during Svelte 5 component initialization triggers the reactive graph while effects are still being collected, creating a cycle. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix: update concurrency group to use head_ref for better context handling * fix: wrap context.subscribe() in untrack() to break reactive cycle Exit.svelte uses $derived(usePresence(isCustom)). Without untrack(), calling context.subscribe() inside usePresence() makes Svelte 5 track the context store as a reactive dependency of that $derived. This means every context change re-runs usePresence() entirely — creating a new subscription, incrementing the id counter, and returning a new derived store. The new store is a different object, so $presence changes, the $effect re-runs _effect(), setActive() fires, which feeds back into the context, completing the cycle → effect_update_depth_exceeded. Wrapping the subscribe() call in untrack() makes it invisible to Svelte's reactive graph during $derived computation. The returned derived(context,…) store still tracks context changes correctly for $presence updates, but usePresence() itself is only ever called once per component lifetime. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix: remove push event triggers from preview workflow * fix: set PresenceChild context synchronously so usePresence can use get() The previous subscribe()-based approach in usePresence created a reactive cycle: $derived(usePresence()) in Exit.svelte tracked context.subscribe() as a dependency, causing usePresence() to re-run on every context change → new subscription → new store → $presence changes → $effect re-runs → setActive() → context changes → repeat → effect_update_depth_exceeded. Root fix: PresenceChild now calls context.set(memoContext()) synchronously during script initialization (before setContext). In Svelte 5, parent scripts run before children initialize, so when a child calls usePresence() and does get(context), the store already holds the real value — no subscription or untrack() needed. usePresence() is back to the simple get() → register → derived pattern. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix: add push event triggers to preview workflow for better branch handling * fix: update concurrency group to use event number or SHA for better context handling * fix: optimize context update logic in PresenceChild component * fix: prevent unnecessary updates in PresenceChild context handling * new example * new example show in PR comment * fix: call getContext and usePresence at initialization in Exit.svelte In Svelte 5, $derived is lazily evaluated — computed on first access, not at script initialization. Since getContext() must be called during component initialization to work correctly, wrapping it in $derived caused it to run inside an effect (after mount), returning undefined and falling back to a new writable(null). usePresence() then saw a null context, returned AlwaysPresent, never registered with PresenceChild, leaving presenceChildren empty — so onExitComplete fired immediately on the tick check, removing items without playing exit animations. Fix: call getContext() and usePresence() directly in the script body so they run synchronously during initialization. Both are Svelte 4 stores; $presence and $presenceContext auto-subscriptions in the $effect still work correctly. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix: use type-only import for Config in svelte.config.ts `import { Config }` was a type import from @sveltejs/kit but the runtime module doesn't export it as a value. Switching to `import type` makes the dev server and build work correctly. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix: resolve exit animation and presenceAffectsLayout bugs Two bugs fixed: 1. Demo App.svelte: the `{#if data}` guard looked up the item via `items.find()`, which returns null the instant the item is removed from state — BEFORE AnimatePresence has a chance to keep it mounted for the exit animation. Fixed by embedding the full item data (color, text) directly in the `list` prop so the slot snippet always has what it needs from `item` and never needs to query the already-updated `items` array. 2. PresenceChild.svelte: bare reactive reads (`isPresent;`) inside a Svelte 5 `$effect` are not guaranteed to register as tracked dependencies — Svelte 5 may elide the read as a no-op expression. Replaced with explicit `const` assignments so the signal is reliably captured and the effect re-runs when `isPresent` changes, regardless of the `presenceAffectsLayout` value. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix: implement layout animations via $effect.pre + LayoutEpochContext Root cause: Svelte 4's beforeUpdate fires *after* parent components have already updated the DOM (components are flushed FIFO). By the time Measure.svelte snapshotted positions, the layout had already shifted — making FLIP impossible. Fix: - Add LayoutEpochContext (a writable counter) that AnimatePresence provides and increments at the top of every non-initial children update, before any DOM changes are committed. - MeasureContextProvider subscribes and passes the epoch as the `update` prop to Measure.svelte. - Convert Measure.svelte to Svelte 5 runes and replace beforeUpdate with $effect.pre. Svelte 5's $effect.pre runs before ALL DOM updates in the current batch — so the snapshot is taken while the old layout is still intact (the FLIP "from" position). $effect (post-DOM) then flushes the batcher to measure the new positions and kick off the FLIP animations. Also revert the PresenceChild void-expression tracking experiment; the original if/else + refresh-derived pattern is kept. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * feat: add card stack demo with drag interactions and animations * fix: refactor card animation logic and improve layout handling in App.svelte * fix: update workflow triggers and improve card animation structure in App.svelte * fix: enhance item management and layout handling in App.svelte * fix: add initial state to front card variants for improved animation handling * fix: synchronous snapshot callbacks for layout animations + card-stack fixes Layout animations (presence-affects-layout): - Root cause: $effect.pre in Measure.svelte (Svelte 5) fires AFTER AnimatePresence (Svelte 4) has already committed its DOM changes, because the two schedulers are independent. The epoch store update triggers a Svelte 4 re-render cycle that propagates the new `update` prop to Measure only after the DOM is updated. - Fix: Add LayoutSnapshotContext — a Set<() => void> provided by AnimatePresence via setContext. Measure.svelte registers its updater() in this set. Inside AnimatePresence's $: reactive block (which runs synchronously before the Svelte 4 DOM diff is applied), we call snapshotCallbacks.forEach(fn => fn()) FIRST, then increment layoutEpoch, then update childrenToRender. This guarantees the snapshot captures the old layout positions for correct FLIP animation. - Keep $effect.pre as a fallback for standalone layout animations (no AnimatePresence). The updated flag in updater() prevents double-execution. Card-stack exit animation: - Root cause 1: x and rotate MotionValues were declared once in App.svelte and shared between the exiting card (key N) and the entering card (key N+1). When the exit animation animated x to ±250, the entering card moved too. - Fix: Extract card into Card.svelte so each instance owns its own x/rotate. - Root cause 2: currentCard.color/emoji referenced the already-updated index, so the exiting card displayed the new card's content during exit animation. - Fix: Embed full card data ({ color, emoji, label }) in the AnimatePresence list item. AnimatePresence stores the item snapshot, so the exiting card slot always renders the correct (old) card data. - Add dragElastic={1} to let cards be dragged past the zero constraint without an immediate spring-back that would fight the exit animation. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix: card-stack onDragEnd + both-cards-in-AnimatePresence; presenceAffectsLayout position:absolute Card-stack: - Fix Card.svelte: rename ondragend→onDragEnd (camelCase matches VisualElementDragControls) - Add isFront to card item data; Card uses it for drag, animate target, z-index class - Inline exit objects (avoid function-variant timing dependency) - Rewrite App.svelte: both front+back cards managed by AnimatePresence via visibleList where key=index (front) and key=index+1 (back); drag increments index presenceAffectsLayout=true: - Add LayoutPositionContext (Map<presenceKey, applyAbsoluteFn>) provided by AnimatePresence - Add presenceKey field to PresenceContextProps and PresenceChildProps - AnimatePresence passes presenceKey={child.key} to each PresenceChild - AnimatePresence calls positionRegistry.get(exitingKey)?.() after snapshot, before epoch update — removes exiting element from layout flow so remaining elements' FLIP "to" positions are measured correctly - AnimateLayoutContextProvider registers applyAbsolute callback (sets position:absolute with pinned offsetTop/Left/Width/Height) keyed by presenceKey https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix: presenceAffectsLayout — FLIP on true, snap on false (no position:absolute) presenceAffectsLayout=true (default): snapshot callbacks + epoch increment fire so Measure.svelte captures "from" positions and FLIP plays for remaining items. presenceAffectsLayout=false: snapshot and epoch are skipped entirely so Measure never snapshots and remaining items simply snap to their new positions when the exiting item is removed from the list (no layout animation). Removes the LayoutPositionContext / position:absolute approach — that was an over-engineering of the feature. The simple snapshot gate is sufficient. Also reverts AnimateLayoutContextProvider to its original form. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix: elements always unmount regardless of presenceAffectsLayout Root cause: AnimateLayoutContextProvider and Exit.svelte each call usePresence, registering separate presence IDs. Both must call safeToRemove for the element to unmount. Animate.svelte was calling notifyLayoutAnimationComplete() instead of safeToRemove() when the layout animation completed — leaving the layout feature's presence ID permanently unresolved for non-shared layout. Animate.svelte: call safeToRemove?.() after layout animation resolves so the layout feature's presence registration (id2) is always completed. The guard in PresenceChild prevents double-counting if shared layout also calls layoutSafeToRemove. AnimatePresence.svelte: always increment epoch (so afterU/flush fires and all layout elements go through animateF → safeToRemove). Only call snapshotCallbacks when presenceAffectsLayout=true. When false, $effect.pre fires after the Svelte 4 DOM update (mixed-mode timing), capturing new positions as "from" → hasMoved=false → no FLIP → remaining items snap to new positions after exit completes. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix: move card content styles into Card.svelte .card-content, .emoji, and .card-label were scoped to App.svelte but the elements live in Card.svelte, so they never applied. Moved to Card.svelte's own style block so Svelte's scoping works correctly. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix: mobile touch dragging and swipe direction detection touch-action: none on .card prevents the browser from intercepting touch events for scrolling, which was blocking finger drag entirely. handleDragEnd now also checks velocity (< -500 / > 500 px/s) in addition to offset (< -80 / > 80 px) so short, fast mobile swipes are correctly detected and exit in the swiped direction. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * revert: restore Measure.svelte to beforeUpdate/afterUpdate (non-runes) The runes-based $effect.pre/$effect approach in Measure.svelte and the LayoutEpochContext/LayoutSnapshotContext machinery in AnimatePresence were not needed — the original beforeUpdate/afterUpdate lifecycle works correctly at the expected Svelte peer version. Reverts: - Measure.svelte: back to beforeUpdate/afterUpdate, export let props, no runes - MeasureContextProvider.svelte: remove LayoutEpochContext subscription - AnimatePresence.svelte: remove LayoutEpochContext/LayoutSnapshotContext setup and the synchronous snapshot callback invocations https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * feat: add PresenceAffectsLayout demo component Creates the missing PresenceAffectsLayout.svelte demo that was already exported in index.ts but never created. Shows a flex row of colored items that can be individually removed: - presenceAffectsLayout=true: remaining items animate (FLIP) to fill the gap - presenceAffectsLayout=false: remaining items snap immediately to new positions Each item uses layout={true} to enable FLIP via Measure/syncLayout. Exit animation uses opacity+scale fade. Includes a reset button and live label showing the current mode. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix: restore snapshot/epoch mechanism for presenceAffectsLayout FLIP Problem: Svelte 4 flushes DOM updates FIFO (parent before child), so beforeUpdate in Measure fires after AnimatePresence has already committed its DOM changes — making FLIP impossible (from == to). Fix: - Measure.svelte: register updater() in LayoutSnapshotContext (3 lines, no changes to core beforeUpdate/afterUpdate logic) - MeasureContextProvider: subscribe to LayoutEpochContext and pass epoch as fallback update prop, so remaining Measures re-render after exit and afterU flush is triggered - AnimatePresence: when presenceAffectsLayout=true, call snapshot callbacks synchronously before childrenToRender mutates (correct "from" position); always increment epoch so afterU fires regardless of presenceAffectsLayout This makes the behavior match framer-motion v4: - presenceAffectsLayout=true: remaining items FLIP-animate to new positions - presenceAffectsLayout=false: remaining items snap to new positions Also removes the PresenceAffectsLayout demo component (wrong place) and its export, pointing users to examples/presence-affects-layout instead. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix: remove Presence Affects Layout test card from functional tests * fix: exclude /dist/cdn/ from .gitignore * fix: simplify presenceContext subscription in UseVisualElement Move presenceContext.subscribe() to top-level initialization instead of nesting it inside the $: if (visualElement) block with a one-time guard. The subscribe callback fires synchronously when the store changes, keeping visualElement.isPresent in sync before framesync's batcher reads it on the next animation frame. $: reactive blocks are deferred (queued flush), which is why they can't be used here — by the time they run, the exit animation has already tried to call safeToRemove on an element whose isPresent is stale, causing "safeToRemove is not a function". https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * refactor: collapse snapshot+epoch into single LayoutEpochContext Previously used two contexts (LayoutSnapshotContext as a Set<()=>void> + LayoutEpochContext as Writable<number>) and fired both on every list change. Now: one context (LayoutEpochContext), fired only in forceRender (when exit actually completes, not on every user-list change): - AnimatePresence: drop LayoutSnapshotContext entirely; move epoch increment into forceRender, gated on presenceAffectsLayout, before _list mutates - Measure: subscribe to LayoutEpochContext directly — store.update() fires subscribers synchronously so updater() snapshots positions before the DOM changes; skip the initial subscribe call with a ready flag The epoch store now serves both roles: 1. Synchronous snapshot trigger (via subscribe in Measure) 2. Async afterU flush trigger (via MeasureContextProvider's $: epochUpdate) https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * Fix AnimatePresence presence tracking and FLIP layout animations - AnimatePresence: always increment layoutEpoch on list change so afterU → syncLayout.flush() fires for layout elements (animateF path → layoutSafeToRemove), allowing exiting elements to be removed from DOM. Also increment epoch in forceRender when presenceAffectsLayout=true so Measure snapshots positions synchronously before _list changes (FLIP). - UseVisualElement: remove redundant isPresent/isPresenceRoot assignments from $: reactive block — the top-level presenceContext.subscribe() callback already sets them synchronously, which is required to avoid the safeToRemove-is-not-a-function race with framesync's batcher. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * Fix presenceAffectsLayout FLIP and restore sync+reactive isPresent pattern LayoutEpochContext: change store value from number to {n, snapshot} so AnimatePresence can signal whether Measure should take a FLIP snapshot. AnimatePresence: - $: if (!isInitialRender): snapshot=false — triggers afterU flush for layout elements (animateF → safeToRemove) without FLIP for siblings - forceRender when presenceAffectsLayout=true: snapshot=true — full FLIP MeasureContextProvider: only pass epochUpdate when snapshot=true so the $: updater(update) + afterUpdate(afterU) path is reserved for FLIP cycles. Measure: subscribe callback now branches on snapshot flag: - snapshot=true: full updater() for all elements (FLIP) - snapshot=false, !isPresent: add exiting element to syncLayout + afterU() directly so animateF fires → safeToRemove; siblings are skipped (snap) UseVisualElement: restore isPresent/isPresenceRoot lines in the $: block using $presenceContext auto-subscribe — same sync-subscribe + reactive pattern used elsewhere; sync subscribe handles timing-critical framesync reads, $: block keeps values current for any subsequent reactive flush. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix: convert Exit.svelte to legacy syntax and fix presenceAffectsLayout add animation Exit.svelte: - Remove <svelte:options runes /> — no runes APIs needed; children is never passed by the feature renderer so the {@render} was dead code - export let instead of $props(), $: custom = props?.custom instead of $derived - $: _effect($presence) instead of $effect(() => _effect($presence)), letting Svelte's legacy auto-subscribe ($presenceContext, $presence) handle reactivity AnimatePresence.svelte — fix epoch logic in $: if (!isInitialRender): - Compute hasRemovals/hasAdditions from presentKeys vs targetKeys before firing the epoch, so we can choose snapshot correctly - Removals: snapshot=false (unchanged) — safeToRemove path; forceRender fires snapshot=true after exit completes for sibling FLIP - Additions + presenceAffectsLayout=true: snapshot=true so existing siblings snapshot their positions before DOM shifts them, enabling FLIP - No-change re-runs (forceRender's _list=[..._list] trigger): no epoch fires, preventing the second epoch from cancelling forceRender's snapshot=true https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * refactor: extract snapshotLayout() from forceRender, fix shared-layout gap on additions Previously the addition path in $: if (!isInitialRender) directly called layoutEpoch.update(snapshot:true) but missed $layoutContext.forceUpdate() for shared-layout trees — forceRender did both but the addition case did not. Fixes: - Extract snapshotLayout() (epoch snapshot=true + shared-layout forceUpdate) from forceRender so both callers share the same logic - Addition case calls snapshotLayout() instead of duplicating the epoch call; presenceAffectsLayout guard moved inside snapshotLayout() (drops the && presenceAffectsLayout from the else-if, cleaner) - forceRender becomes snapshotLayout() + _list=[..._list], intent now obvious - Comment explicitly documents the no-diff (forceRender re-run) case and why no epoch fires there (would cancel forceRender's already-fired snapshot) https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix: resolve layoutSafeToRemove timing race for layout elements in AnimatePresence Elements with `layout` prop inside `<AnimatePresence>` got stuck in the DOM because the layout feature's usePresence registration (id2) never called onExitComplete. Two interleaved timing issues: 1. The `snapshot=false` epoch in AnimatePresence fired synchronously within Svelte 4's reactive block, before PresenceChild's Svelte 5 `$effect` had a chance to update PresenceContext. Measure.svelte's epoch handler checks `!visualElement.isPresent`, which was still `true` at that point, so the exiting element was never added to syncLayout → animateF never ran → safeToRemove (id2) was never called. Fix: defer the `snapshot=false` epoch via `tick().then(...)` so that PresenceChild's $effect has fired and `visualElement.isPresent` reflects the new `isPresent=false` before the epoch handler executes. 2. Even when animateF did fire, the `safeToRemove` prop in Animate.svelte could still hold its stale `undefined` value for instant animations (no FLIP movement), because Svelte's reactive batching had not yet propagated the updated `$presence[1]` through AnimateLayoutContextProvider before the Promise.then() microtask executed. Similarly, `layoutSafeToRemove` (called from render/index.ts pointTo) could throw when `safeToRemove` was still undefined. Fix: replace the `safeToRemove` prop with the `presence` store itself. AnimateLayoutContextProvider now passes `{presence}` and Animate.svelte calls `get(presence)` at resolution time — always the freshest value, bypassing Svelte's render-cycle lag entirely. https://claude.ai/code/session_01Arc7afwmXF632byLua4k1z * fix: update Animate component to use safeToRemove prop from presence store * fix: remove synchronous subscription to presenceContext to prevent timing issues with isPresent updates * fix: remove unused LayoutEpochContext and related logic from Measure and MeasureContextProvider * fix: remove unused LayoutPositionContext and LayoutSnapshotContext, update Measure and MeasureContextProvider to use LayoutEpochContext for improved FLIP animation timing * chore: remove animate-presence-demo and card-stack examples, along with presence-affects-layout example files * fix: update package.json to correct paths for context, motion, render, and value modules * fix: remove unused export patterns for JavaScript and Svelte files in package.json * fix: refactor Card and AnimatePresenceStack components for improved drag handling and exit animations * feat: add AnimatePresenceAffectsLayout component and integrate into test page --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent dbec23a commit 39bf8bf

File tree

22 files changed

+729
-453
lines changed

22 files changed

+729
-453
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"motion-start": patch
3+
---
4+
5+
Fix AnimatePresence `presenceAffectsLayout` no-op, `isPresent` race condition with framesync batcher, and double `safeToRemove` guard

.github/workflows/preview.yaml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Preview
2+
3+
on: [push, pull_request]
4+
5+
concurrency:
6+
group: ${{ github.workflow }}-${{github.head_ref || github.ref_name}}
7+
cancel-in-progress: true
8+
9+
jobs:
10+
Preview:
11+
name: Publish pkg.pr.new preview
12+
runs-on: ubuntu-latest
13+
permissions:
14+
contents: read
15+
pull-requests: write
16+
steps:
17+
- uses: actions/checkout@v4
18+
- uses: oven-sh/setup-bun@v2
19+
with:
20+
bun-version: latest
21+
22+
- name: Install Dependencies
23+
run: bun --bun install --frozen-lockfile
24+
25+
- name: Build
26+
run: bun --bun run build
27+
28+
- name: Publish preview
29+
run: npx pkg-pr-new publish . --template './examples/*'

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ bun.lockb
88
.vercel
99
/.svelte-kit
1010
/build
11-
/dist
11+
/dist/*
1212

1313
# OS
1414
.DS_Store

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
".": {
1313
"types": "./dist/index.d.ts",
1414
"svelte": "./dist/index.js",
15+
"import": "./dist/index.js",
16+
"module": "./dist/index.js",
1517
"default": "./dist/index.js"
1618
},
1719
"./package.json": "./package.json",
@@ -38,26 +40,18 @@
3840
"svelte": "./dist/value/index.js",
3941
"import": "./dist/value/index.js",
4042
"default": "./dist/value/index.js"
41-
},
42-
"./src/*.js": {
43-
"types": "./dist/*.d.ts",
44-
"svelte": "./dist/*.js",
45-
"import": "./dist/*.js",
46-
"default": "./dist/*.js"
47-
},
48-
"./src/*.svelte": {
49-
"types": "./dist/*.d.ts",
50-
"svelte": "./dist/*.svelte",
51-
"default": "./dist/*.svelte"
5243
}
5344
},
5445
"files": [
5546
"dist",
5647
"!dist/**/*.test.*",
5748
"!dist/**/*.spec.*"
5849
],
50+
"main": "./dist/index.js",
51+
"module": "./dist/index.js",
5952
"svelte": "./dist/index.js",
6053
"types": "./dist/index.d.ts",
54+
"browser": "./dist/index.js",
6155
"type": "module",
6256
"scripts": {
6357
"dev": "vite dev",

src/lib/components/Card.svelte

Lines changed: 40 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,49 @@
11
<script lang="ts">
2-
import { Motion, useMotionValue, useTransform } from "$lib/motion-start";
3-
let exitX = 0;
4-
const x = useMotionValue(0);
5-
const scale = useTransform(x, [-150, 0, 150], [0.5, 1, 0.5]);
6-
const rotate = useTransform(x, [-150, 0, 150], [-45, 0, 45], {
7-
clamp: false,
8-
});
9-
export let drag: any = false;
10-
export let frontCard = false;
11-
export let index: any = 0;
12-
const variantsFrontCard = {
13-
animate: { scale: 1, y: 0, opacity: 1 },
14-
exit: (custom: any) => ({ x: custom, opacity: 0, scale: 0.5 }),
15-
};
16-
const variantsBackCard = {
17-
initial: { scale: 0.3, y: 105, opacity: 0 },
18-
animate: { scale: 0.75, y: 30, opacity: 0.5 },
19-
};
20-
$: isFront = frontCard ? variantsFrontCard : variantsBackCard;
2+
import { Motion, useMotionValue, useTransform } from "$lib/motion-start";
213
22-
function handleDragEnd(_: any, info: { offset: { x: number } }) {
23-
// console.log("info", info);
24-
if (info.offset.x < -100) {
25-
// setExitX(-250);
26-
exitX = -250;
27-
// props.setIndex(index + 1);
28-
index = index + 1;
29-
}
30-
if (info.offset.x > 100) {
31-
exitX = 250;
32-
// props.setIndex(index + 1);
33-
index = index + 1;
34-
// console.log("trigger");
35-
}
36-
}
37-
let i = 0;
4+
const x = useMotionValue(0);
5+
const scale = useTransform(x, [-150, 0, 150], [0.5, 1, 0.5]);
6+
const rotate = useTransform(x, [-150, 0, 150], [-45, 0, 45], {
7+
clamp: false,
8+
});
9+
10+
export let drag: any = false;
11+
export let frontCard = false;
12+
export let index: any = 0;
13+
export let custom;
14+
export let onDragEnd;
15+
16+
const variantsFrontCard = {
17+
animate: { scale: 1, y: 0, opacity: 1 },
18+
exit: (custom: any) => ({ x: custom, opacity: 0, scale: 0.5 }),
19+
};
20+
const variantsBackCard = {
21+
initial: { scale: 0.3, y: 105, opacity: 0 },
22+
animate: { scale: 0.75, y: 30, opacity: 0.5 },
23+
};
24+
$: isFront = frontCard ? variantsFrontCard : variantsBackCard;
3825
</script>
3926

4027
<!-- Animate Presence Stack -->
4128

4229
<Motion.div
43-
style={{
44-
x,
45-
rotate,
46-
}}
47-
{drag}
48-
dragConstraints={{ top: 0, right: 0, bottom: 0, left: 0 }}
49-
variants={isFront}
50-
initial="initial"
51-
animate="animate"
52-
onDragEnd={handleDragEnd}
53-
exit="exit"
54-
custom={exitX}
55-
transition={frontCard
56-
? { type: "spring", stiffness: 300, damping: 20 }
57-
: { scale: { duration: 0.2 }, opacity: { duration: 0.4 } }}
58-
class="w-32 h-32 top-10 bg-white rouned-xl absolute rounded-xl text-black flex justify-center items-center select-none"
30+
style={{
31+
x,
32+
rotate,
33+
}}
34+
{drag}
35+
dragConstraints={{ top: 0, right: 0, bottom: 0, left: 0 }}
36+
variants={isFront}
37+
initial="initial"
38+
animate="animate"
39+
{onDragEnd}
40+
exit="exit"
41+
{custom}
42+
transition={frontCard
43+
? { type: "spring", stiffness: 300, damping: 20 }
44+
: { scale: { duration: 0.2 }, opacity: { duration: 0.4 } }}
45+
class="w-32 h-32 top-10 bg-white rouned-xl absolute rounded-xl text-black flex justify-center items-center select-none touch-none
46+
{frontCard ? 'z-10 cursor-grab active:cursor-grabbing' : 'z-0 pointer-none'}"
5947
>
60-
{index}
48+
{index}
6149
</Motion.div>
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<script>
2+
import {
3+
Motion,
4+
AnimatePresence,
5+
layoutAnimation,
6+
} from "$lib/motion-start";
7+
8+
let nextId = 5;
9+
let tasks = [
10+
{ key: 1, text: "Learn React" },
11+
{ key: 2, text: "Prototype with Framer" },
12+
{ key: 3, text: "Get Superpowers" },
13+
{ key: 4, text: "Conquer the universe" },
14+
];
15+
let presenceAffectsLayout = true;
16+
17+
function removeTask(key) {
18+
tasks = tasks.filter((t) => t.key !== key);
19+
}
20+
21+
function addTask() {
22+
tasks = [...tasks, { key: nextId, text: `Task ${nextId++}` }];
23+
}
24+
25+
/**
26+
* layout prop is needed for presenceAffectsLayout to work, but can be used without it to simply animate presence without affecting layout
27+
* you can choose to have tracking depend on signals etc.
28+
* it is not necessary to track every change that might affect layout, just the ones you care about.
29+
* currently this works without tracking, but that is because the tasklist is already tracked by AnimatePresence.
30+
* you can use the layoutAnimation.track() function to create a new function reference whenever something changes that might affect layout.
31+
* for layout prop or custom={signal} or layoutDependency={signal}, you could track the signal itself, but you could also track something else that changes at the same time as the signal, like a list of items that will be re-rendered when the signal changes.
32+
*/
33+
// Re-creates the function reference each time tasks changes, signalling
34+
// motion elements to re-measure and FLIP to their new positions.
35+
// $: layout = layoutAnimation.track(() => tasks);
36+
</script>
37+
38+
<div class="w-64 bg-gray-700/40 rounded-lg p-3 flex flex-col gap-2">
39+
<section class="flex items-center justify-center gap-3">
40+
<label class="text-xs text-white/60 hover:text-white cursor-pointer">
41+
<input
42+
type="checkbox"
43+
bind:checked={presenceAffectsLayout}
44+
class="mr-1"
45+
/>
46+
presenceAffectsLayout
47+
</label>
48+
</section>
49+
<hr class="border-gray-600" />
50+
<ul class="flex flex-col gap-1">
51+
<AnimatePresence {presenceAffectsLayout} list={tasks} let:item>
52+
<Motion.li
53+
layout
54+
initial={{ opacity: 0, y: -8 }}
55+
animate={{ opacity: 1, y: 0 }}
56+
exit={{ opacity: 0, x: -60 }}
57+
transition={{ layout: { duration: 0.8 }, duration: 0.2 }}
58+
class="flex items-center justify-between bg-white/10 rounded px-3 py-2 text-white text-sm select-none"
59+
>
60+
<span>{item.text}</span>
61+
<button
62+
onclick={() => removeTask(item.key)}
63+
class="ml-2 text-white/40 hover:text-white leading-none"
64+
>✕</button
65+
>
66+
</Motion.li>
67+
</AnimatePresence>
68+
</ul>
69+
<button
70+
onclick={addTask}
71+
class="text-xs text-white/60 hover:text-white py-1 px-2 rounded border border-white/20 hover:border-white/40 transition-colors"
72+
>+ Add task</button
73+
>
74+
</div>
Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
<script>
2-
import { Motion, AnimatePresence } from "$lib/motion-start";
3-
import Card from "$lib/components/Card.svelte";
4-
let index = 0;
5-
$: mint = index + 1;
6-
</script>
2+
import { AnimatePresence } from "$lib/motion-start";
3+
import Card from "$lib/components/Card.svelte";
4+
5+
let index = 0;
6+
let exitX = 0;
7+
8+
$: mint = index + 1;
79
10+
function handleDragEnd(_, info) {
11+
if (info.offset.x < -100) {
12+
exitX = -250;
13+
index = index + 1;
14+
}
15+
if (info.offset.x > 100) {
16+
exitX = 250;
17+
index = index + 1;
18+
}
19+
}
20+
21+
/**
22+
* This example demonstrates how to create a stack of cards that can be dragged and dismissed using Animate
823
<div
924
class="w-64 h-64 relative bg-gray-700/40 rounded-lg flex justify-center items-center"
1025
>
@@ -13,3 +28,26 @@
1328
<Card bind:index drag="x" frontCard={true} />
1429
</AnimatePresence>
1530
</div>
31+
*/
32+
</script>
33+
34+
<div
35+
class="w-64 h-64 relative bg-gray-700/40 rounded-lg flex justify-center items-center"
36+
>
37+
<AnimatePresence
38+
initial={false}
39+
let:item
40+
list={[
41+
{ key: index, isFront: true },
42+
{ key: mint, isFront: false },
43+
]}
44+
>
45+
<Card
46+
bind:index={item.key}
47+
drag={item.isFront ? "x" : false}
48+
frontCard={item.isFront}
49+
onDragEnd={item.isFront ? handleDragEnd : undefined}
50+
custom={exitX}
51+
/>
52+
</AnimatePresence>
53+
</div>

src/lib/components/motion/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// @ts-nocheck
22
export { default as AnimateLayout } from './AnimateLayout.svelte';
3+
export { default as AnimatePresenceAffectsLayout } from './AnimatePresenceAffectsLayout.svelte';
34
export { default as AnimatePresenceStack } from './AnimatePresenceStack.svelte';
45
export { default as AnimationSequence } from './AnimationSequence.svelte';
56
export { default as ColorInterpolation } from './ColorInterpolation.svelte';

0 commit comments

Comments
 (0)