Commit 39bf8bf
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- .changeset
- .github/workflows
- src
- lib
- components
- motion
- motion-start
- components/AnimatePresence
- PresenceChild
- context
- motion
- features
- layout
- utils
- routes/tests
22 files changed
+729
-453
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
11 | | - | |
| 11 | + | |
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| |||
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| 15 | + | |
| 16 | + | |
15 | 17 | | |
16 | 18 | | |
17 | 19 | | |
| |||
38 | 40 | | |
39 | 41 | | |
40 | 42 | | |
41 | | - | |
42 | | - | |
43 | | - | |
44 | | - | |
45 | | - | |
46 | | - | |
47 | | - | |
48 | | - | |
49 | | - | |
50 | | - | |
51 | | - | |
52 | 43 | | |
53 | 44 | | |
54 | 45 | | |
55 | 46 | | |
56 | 47 | | |
57 | 48 | | |
58 | 49 | | |
| 50 | + | |
| 51 | + | |
59 | 52 | | |
60 | 53 | | |
| 54 | + | |
61 | 55 | | |
62 | 56 | | |
63 | 57 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | | - | |
3 | | - | |
4 | | - | |
5 | | - | |
6 | | - | |
7 | | - | |
8 | | - | |
9 | | - | |
10 | | - | |
11 | | - | |
12 | | - | |
13 | | - | |
14 | | - | |
15 | | - | |
16 | | - | |
17 | | - | |
18 | | - | |
19 | | - | |
20 | | - | |
| 2 | + | |
21 | 3 | | |
22 | | - | |
23 | | - | |
24 | | - | |
25 | | - | |
26 | | - | |
27 | | - | |
28 | | - | |
29 | | - | |
30 | | - | |
31 | | - | |
32 | | - | |
33 | | - | |
34 | | - | |
35 | | - | |
36 | | - | |
37 | | - | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
38 | 25 | | |
39 | 26 | | |
40 | 27 | | |
41 | 28 | | |
42 | 29 | | |
43 | | - | |
44 | | - | |
45 | | - | |
46 | | - | |
47 | | - | |
48 | | - | |
49 | | - | |
50 | | - | |
51 | | - | |
52 | | - | |
53 | | - | |
54 | | - | |
55 | | - | |
56 | | - | |
57 | | - | |
58 | | - | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
59 | 47 | | |
60 | | - | |
| 48 | + | |
61 | 49 | | |
Lines changed: 74 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | | - | |
3 | | - | |
4 | | - | |
5 | | - | |
6 | | - | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
7 | 9 | | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
8 | 23 | | |
9 | 24 | | |
10 | 25 | | |
| |||
13 | 28 | | |
14 | 29 | | |
15 | 30 | | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
| 3 | + | |
3 | 4 | | |
4 | 5 | | |
5 | 6 | | |
| |||
0 commit comments