import { children as resolveChildren } from "solid-js"; import type { Accessor, JSX, ParentComponent, ParentProps, Setter } from "solid-js"; import type { SolidMotionAdapter } from "../adapter/types.ts"; import { createListTransition, createSwitchTransition, } from "@solid-primitives/transition-group"; import { solid1Adapter } from "../adapter/solid1.ts"; import { createPresenceExitController, PresenceChild } from "./presence-child.tsx"; import { PresenceProvider } from "./presence-context.tsx"; import { readPresenceKey } from "./_presence-key-registry.ts"; import type { PresenceContextValue, PresenceMotionHandle } from "./presence-context.tsx"; import type { PresenceExitController } from "./presence-child.tsx"; import type { PresenceKey } from "./_presence-key-registry.ts"; /** * Controls how `AnimatePresence` sequences exiting children and entering children. * * `sync` renders entering children immediately while removed children exit. * `wait` keeps the next child pending until the previous child reports exit completion. */ export type AnimatePresenceMode = "sync" | "wait"; /** * Props for `AnimatePresence`. * * `AnimatePresence` currently keeps the established single-slot `wait` path and layers a separate * list-oriented `sync` path on top for keyed child retention. That split keeps the old contract * stable while letting list semantics use Solid's `createListTransition` primitive directly. */ export interface AnimatePresenceOptions { /** * When false, suppress the first entry animation for descendants. */ initial?: boolean; /** * Chooses whether entering content waits for exit completion or overlaps with it. */ mode?: AnimatePresenceMode; /** * Custom payload inherited by descendant exit and variant resolution. */ custom?: unknown; } /** * Props for `AnimatePresence`. * * This uses Solid's `ParentProps` helper instead of redeclaring `children` manually. That * matches how Solid models child-bearing components and keeps the local API shape aligned * with `ParentComponent`. */ export type AnimatePresenceProps = ParentProps; /** * Internal lookup token for one retained sync-mode child. * * This is intentionally not a generated DOM id. It is the value `AnimatePresence` uses to answer * one narrower question: "does this newly resolved child represent the same list entry as a * record we were already retaining?" * * The lookup prefers semantic `presenceKey` values when available, then falls back to object or * function identity, and only uses an index-based primitive token for non-object children. */ type PresenceIdentity = string | object; /** * Retained sync-mode child state owned by the list path. * * Unlike `PresenceSnapshot`, which only needs one raw child slot value for the wait path, the sync * list path needs one stable object per list entry because `createListTransition` retains the * objects you pass into it. The current implementation also routes descendant registration through * that object, so each record carries the latest child payload, the child's current in-or-out flag, * and the exit controller that aggregates nested motion completion for that list entry. * * One record represents one retained lifetime. Re-entry with the same semantic key after removal * removal does not resurrect the old record. It creates a fresh record with a fresh controller and * lets the old exiting lifetime finish or get released. * * In this file, a "lifetime" means the whole span for one list entry from "currently present" * through any retained exit work until the record is finally removed from the transition list. */ type PresenceListRecord = { /** * Stable lookup token for the list entry this retained record represents. * * The list path stores records by this identity so later updates can decide whether a newly * resolved child should reuse an existing retained record, create a brand new one, or cancel an * older exit that has not finished yet. */ identity: PresenceIdentity; /** * Exit aggregator for motion descendants that belong to this list entry. * * The sync list path still resolves children through one list-wide provider today. That means * each retained record needs its own descendant bookkeeping so removal can wait for the right * subtree instead of releasing whichever child happened to finish first. */ exitController: PresenceExitController; /** * Current render payload for this retained record. * * The record object must stay stable for `createListTransition`, but the JSX child it renders can * change across reactive passes. This accessor lets the record update its rendered payload without * manufacturing a new transition item. */ source: Accessor; /** * Replace the current render payload for this retained record. * * Reused records call this when the source list still contains the same list entry but Solid * resolved a fresh JSX value for it on the current pass. */ setSource: Setter; /** * Whether the source list still considers this list entry present. * * This is the record-level presence flag that tells nested motion descendants whether they should * stay on their normal lanes or enter exit. */ isPresent: Accessor; /** * Update the record-level presence flag. * * The list transition flips this immediately on removal, then waits for descendant exit * completion before calling `finishExit()` and physically removing the retained record. */ setIsPresent: Setter; /** * Called once the retained record is allowed to leave the transition list. * * This stays unset while the record is currently present. Removal assigns it so nested motion exit * completion can release both the transition-group entry and the retained bookkeeping together. */ finishExit?: VoidFunction | undefined; }; /** * Inputs for the sync-mode list resolver. * * This stays narrower than `AnimatePresenceProps` because the list path needs a child accessor that * can be resolved under the provider owner, not a pre-read `children` value. */ type PresenceListContentProps = { /** * Raw child list expression from `AnimatePresence`. * * The sync path resolves this expression lazily so the list diff sees the current flattened child * payload while still staying under the retained provider owner. */ children: () => JSX.Element; /** * Solid lifecycle adapter used by this presence wrapper. */ adapter?: SolidMotionAdapter | undefined; /** * Stable retained records keyed by list-entry identity. * * `AnimatePresence` owns this map because it is part of the outer list wrapper's bookkeeping, not * the child-resolution work that `PresenceListContent` performs under the provider owner. */ recordsByIdentity: Map; /** * Fresh records created when an exiting key re-enters before the old record finishes. * * These records need to stay visible until `createListTransition` catches up with the new * lifetime. */ reenteringRecords: Set; /** * Publish the current source-list records back to `AnimatePresence`. * * Even with per-record `PresenceChild` wrappers, the current sync path still keeps the list-wide * registration bridge as a fallback for child shapes that do not yet resolve fully under the local * retained record owner. */ onCurrentRecordsChange(records: PresenceListRecord[]): void; }; /** * Normalize one semantic `presenceKey` into the record identity string used by the list path. * * Both child diffing and later motion-handle registration need to build the same identity token. * Keeping that formatting in one helper prevents the two paths from drifting apart. */ function createPresenceIdentityFromKey(presenceKey: PresenceKey): PresenceIdentity { return `presence:${typeof presenceKey}:${presenceKey}`; } /** * Create one retained list record for the sync-mode list path. * * `createListTransition` retains objects, not raw JSX values. This helper builds the stable record * object that the transition layer owns while attaching the signals and exit controller that let the * rest of the presence system update that object over time. */ function createPresenceListRecord( adapter: SolidMotionAdapter, identity: PresenceIdentity, source: JSX.Element, ): PresenceListRecord { const [currentSource, setSource] = adapter.createSignal(source); const [isPresent, setIsPresent] = adapter.createSignal(true); let record!: PresenceListRecord; const exitController = createPresenceExitController(() => { // Exit completion can race with re-entry. If this record became present again, the completed // exit belongs to an older retained lifetime and must not release the current record. if (record.isPresent()) return; record.finishExit?.(); }); record = { identity, exitController, source: currentSource, setSource, isPresent, setIsPresent, }; return record; } /** * Put one retained record back into its normal present state. * * The sync path calls this from several places: newly added records, unchanged records that are * still in the source list, and reused records that received a fresh child payload. Grouping the * writes in one helper makes that rule explicit instead of repeating it across the transition code. */ function restorePresenceListRecord(record: PresenceListRecord): void { record.setIsPresent(true); record.finishExit = undefined; record.exitController.reset(); } /** * Fully release one retained list record after its exit completes. * * This is the physical-removal step for the sync list path. The source-list removal already happened * earlier when `setIsPresent(false)` was called. */ function releasePresenceListRecord( recordsByIdentity: Map, reenteringRecords: Set, recordToRelease: PresenceListRecord, ): void { recordToRelease.finishExit = undefined; recordsByIdentity.delete(recordToRelease.identity); recordToRelease.exitController.dispose(); reenteringRecords.delete(recordToRelease); } /** * Merge freshly re-entered records back into the rendered transition list. * * `createListTransition` still remembers the previous record object as exiting, so a same-key * re-entry has to temporarily render a new record beside that old exiting object. This helper keeps * the new records visible immediately without duplicating any record already in the transition list. */ function mergeReenteringRecords( transitionedRecords: PresenceListRecord[], reenteringRecords: Set, ): PresenceListRecord[] { if (reenteringRecords.size === 0) return transitionedRecords; const mergedRecords = transitionedRecords.slice(); for (const record of reenteringRecords) { if (mergedRecords.includes(record)) continue; mergedRecords.unshift(record); } return mergedRecords; } /** * Render one retained sync-mode record through a local `PresenceChild` wrapper. * * This probes the eventual target architecture without yet removing the outer list-wide bridge. * Each record reuses its existing exit controller and asks `PresenceChild` to resolve the record's * child payload under that local provider owner. If descendant motion components can see this local * wrapper, the later full redesign can collapse more of the list-wide routing. Today that wiring is * real, but focused tests still show wrapped nested motion descendants can miss this local wrapper, * so the outer bridge remains required. * * "Wrapper" here is not a special runtime object beyond the component you can see. It is simply * the place where presence context and exit bookkeeping are provided for one retained subtree. */ function PresenceListRecordContent(props: { /** * Retained record being rendered. */ record: PresenceListRecord; }): JSX.Element { return ( ); } /** * Resolve the lookup token for one sync-mode child. * * Solid does expose `createUniqueId()`, but that solves a different problem: producing stable DOM * ids for one component owner across SSR and hydration. The presence list needs to match one newly * resolved child value to the same list entry that already existed in the previous list, so the token * must come from the child itself or from the caller's semantic `presenceKey`. */ function resolvePresenceIdentity(child: JSX.Element, index: number): PresenceIdentity { const presenceKey = readPresenceKey(child); if (presenceKey !== undefined) { return createPresenceIdentityFromKey(presenceKey); } if ((typeof child === "object" && child !== null) || typeof child === "function") { return child; } return `primitive:${index}:${typeof child}:${String(child)}`; } /** * Internal options for the slot resolver that sits inside `PresenceChild`. * * `AnimatePresence` itself owns the retained-slot state machine, while `PresenceContent` * turns the raw child slot into the two signals that state machine needs: * a retained direct-child snapshot for the transition layer, and lifecycle callbacks that say * when the slot became empty or when the old snapshot is ready to be released. * * The important limit is that this is still a direct-child slot helper, not a general retained * subtree capture mechanism. If the direct child is only a wrapper and the exit-aware motion node is * nested further inside, the current implementation does not yet keep that whole wrapped subtree * alive through exit. */ interface PresenceContentOptions { /** * Raw child slot from `AnimatePresence`. * * The function is resolved inside the retained provider owner so any exiting content keeps * reading the same presence context for the whole retained lifetime. */ children: () => JSX.Element; /** * Chooses whether entering content waits for exit completion or overlaps with it. */ mode?: AnimatePresenceMode | undefined; /** * Adapter used for local signals and effects. */ adapter?: SolidMotionAdapter | undefined; /** * Tell `AnimatePresence` whether the slot currently resolves to a child at all. */ onPresenceChange(isPresent: boolean): void; /** * Give `AnimatePresence` the transition library's `done()` callback for the exiting slot. * * The parent wrapper decides when to call it because transition readiness and motion-exit * completion are related but not identical events. `hasIncomingChild` tells the parent whether * this exit is a removal or a wait-mode replacement that should restore presence afterwards. */ onExitStart(done: VoidFunction, hasIncomingChild: boolean): void; } /** * One retained direct-child snapshot tracked by `AnimatePresence`. * * The retained slot only caches the raw child expression from the parent. * * That same value does double duty here: it is the identity signal for the switch transition, * and it is the renderable payload that Solid will resolve again when the retained slot stays * mounted during exit. */ type PresenceSnapshot = { /** * Raw direct-child identity from the parent's slot expression. */ source: JSX.Element; }; /** * Resolve the user's child expression under the presence provider owner and keep the previous * child rendered until this wrapper finishes its exit. * * The important ownership rule is where this slot resolver runs. It is created inside * `PresenceChild`, so the retained child stays under the provider owner while its exit is in * flight instead of being recreated outside the presence wrapper. * * Read the handoff like this: * * ```text * AnimatePresence child slot * ├─ child exists -> snapshot keeps the current JSX * ├─ child disappears -> switch transition keeps rendering the old snapshot * └─ exit completes -> done() releases the retained snapshot * ``` * * Read that narrowly: this helper keeps one direct child slot around. It does not yet guarantee * that any arbitrary wrapped descendant inside that slot will keep its own owner chain alive. */ function PresenceContent(props: PresenceContentOptions): JSX.Element { const adapter = props.adapter ?? solid1Adapter; const resolvedChildren = resolveChildren(props.children); const snapshot = adapter.createMemo((previous) => { const source = resolvedChildren(); if ( source === undefined || source === null ) return undefined; // Keep the previous snapshot when the direct child identity did not change. That gives the // transition layer a stable "same child" value across ordinary reactive reruns in the slot. if (previous?.source === source) return previous; return { source }; }); const renderedChildren = createSwitchTransition( () => snapshot(), { mode: props.mode === "wait" ? "out-in" : "parallel", onEnter(_child, done) { done() }, onExit(_child, done) { // `createSwitchTransition` is ready to release the old snapshot at this point, but the // surrounding presence wrapper may still be waiting on nested motion exits. // Capture whether a replacement child is already waiting so wait mode can restore the // retained slot after exit instead of treating this as a permanent disappearance. props.onExitStart(done, snapshot() !== undefined); }, }, ); adapter.createTrackedEffect(() => { // Presence bookkeeping only needs to know whether the slot currently has content, not which // concrete JSX object it resolved to. Collapsing it to a boolean keeps the outer wrapper small. props.onPresenceChange(snapshot() !== undefined); }); return ( {(entry) => entry.source} ); } /** * Resolve sync-mode children under a list-aware presence provider. * * The sync path now treats each retained record as its own local presence wrapper, but it still * keeps the older list-wide bridge as a fallback. * * The big structural difference from `PresenceContent` is cardinality. `PresenceContent` only has * to answer "what is the one current direct child slot, and should I keep the previous one around?" * This list path has to answer harder questions for every update: * * 1. which list entries are new? * 2. which are unchanged but moved? * 3. which are exiting? * 4. which same-key child re-entered while the old one is still exiting? * 5. which retained object should `createListTransition` keep rendering for each of those cases? * * `PresenceListContent` owns the list diff and retained-record lifetime bookkeeping, and each visible * record is rendered through `PresenceChild` with that record's exit controller. Some child shapes * still need the outer bridge today, so both mechanisms coexist until the local per-record path can * cover the full sync surface. In particular, wrapped nested motion descendants are not yet retained * reliably enough to remove that outer bridge. * * "Owner" matters here because Solid decides context lookup and cleanup from the subtree that * actually resolves a child. If a child still resolves broadly enough to miss the per-record * `PresenceChild`, the outer bridge has to catch its later motion registration. */ function PresenceListContent(props: PresenceListContentProps): JSX.Element { const adapter = props.adapter ?? solid1Adapter; const resolvedChildren = resolveChildren(props.children); const currentRecords = adapter.createMemo(() => { const children = resolvedChildren.toArray(); const nextRecords: PresenceListRecord[] = []; for (let index = 0; index < children.length; index += 1) { const child = children[index]; const identity = resolvePresenceIdentity(child, index); const existingRecord = props.recordsByIdentity.get(identity); if (!existingRecord) { const record = createPresenceListRecord(adapter, identity, child); props.recordsByIdentity.set(identity, record); nextRecords.push(record); continue; } if (existingRecord.finishExit) { // `createListTransition` still remembers the old object as exiting. Re-entry with the same // semantic key therefore creates a fresh retained record for the new lifetime instead of // trying to resurrect the old exiting lifetime. That keeps local component state aligned // with normal Solid expectations: remove + re-add means a fresh mount. const record = createPresenceListRecord(adapter, identity, child); props.recordsByIdentity.set(identity, record); props.reenteringRecords.add(record); existingRecord.finishExit(); nextRecords.push(record); continue; } existingRecord.setSource(() => child); restorePresenceListRecord(existingRecord); nextRecords.push(existingRecord); } return nextRecords; }); adapter.createTrackedEffect(() => { props.onCurrentRecordsChange(currentRecords()); }); const transitionedRecords = createListTransition( currentRecords, { exitMethod: "keep-index", onChange({ added, removed, unchanged, finishRemoved }) { for (const record of added) { // Added records start a new retained lifetime immediately. restorePresenceListRecord(record); props.reenteringRecords.delete(record); } const sourceRecords = new Set(currentRecords()); for (const record of unchanged) { if (!sourceRecords.has(record)) continue; // A still-present record may sit beside an older exiting sibling in the rendered list. Keep // the presence flag aligned to the source list, not to the transition layer. restorePresenceListRecord(record); props.reenteringRecords.delete(record); } for (const record of removed) { // Removal is split in two phases: // 1. mark the record absent in the source list so nested motion children enter exit // 2. wait to physically remove the retained record until that record's exit controller says // the descendant tree is finished record.setIsPresent(false); record.finishExit = () => { finishRemoved([record]); releasePresenceListRecord( props.recordsByIdentity, props.reenteringRecords, record, ); }; record.exitController.startExit(); } }, }, ); const visibleRecords = adapter.createMemo(() => { return mergeReenteringRecords(transitionedRecords(), props.reenteringRecords); }); return ( {(record) => } ); } /** * Run the sync-mode retained-list algorithm for `AnimatePresence`. * * This branch owns keyed retained-record bookkeeping for the sync-mode list path. * Splitting it out of the exported component makes the shape easier to read: this path is a list * diff plus retained per-record wrappers, with a temporary outer fallback bridge, while the wait * path below is a single-slot state machine. * * In plain terms, the wait path is "keep one old thing around until it finishes". The sync list path * is "keep the right old things around, in the right order, while new things may also appear right * now". That is why the list path needs explicit retained records instead of reusing the slot helper * almost unchanged. * * The local per-record `PresenceChild` path is now the preferred route for descendant registration. * The remaining outer bridge only exists for child shapes that still resolve broadly enough to miss * that local wrapper. Current focused tests show wrapped nested motion descendants are still one of * those fallback shapes. */ function AnimatePresenceList( props: AnimatePresenceProps, adapter: SolidMotionAdapter, ): JSX.Element { const recordsByIdentity = new Map(); const recordsByHandle = new Map(); const reenteringRecords = new Set(); const [currentRecords, setCurrentRecords] = adapter.createSignal([]); adapter.onDispose(() => { for (const record of recordsByIdentity.values()) { record.exitController.dispose(); } recordsByIdentity.clear(); recordsByHandle.clear(); reenteringRecords.clear(); }); /** * Resolve which retained sync record owns one later motion descendant registration. * * The preferred path is now the local `PresenceChild` wrapper rendered per record. The remaining * list-wide bridge stays as a fallback for child shapes that are still resolved broadly enough that * their motion descendants register here instead of in the local record wrapper. */ function findRecordForHandle(handle: PresenceMotionHandle): PresenceListRecord | undefined { if (handle.presenceKey !== undefined) { return recordsByIdentity.get(createPresenceIdentityFromKey(handle.presenceKey)); } const sourceRecords = currentRecords(); return sourceRecords.length === 1 ? sourceRecords[0] : undefined; } const contextValue: PresenceContextValue = { get isPresent() { return true; }, get initial() { return props.initial; }, get custom() { return props.custom; }, register(handle: PresenceMotionHandle): VoidFunction { const record = findRecordForHandle(handle); if (!record) return () => undefined; const unregister = record.exitController.register(handle); recordsByHandle.set(handle.id, record); return () => { recordsByHandle.delete(handle.id); unregister(); }; }, retainOnOwnerDispose(id: symbol): boolean { return recordsByHandle.get(id)?.exitController.retainOnOwnerDispose(id) ?? false; }, onExitComplete(id: symbol): void { recordsByHandle.get(id)?.exitController.reportExitComplete(id); }, }; return ( props.children} adapter={adapter} recordsByIdentity={recordsByIdentity} reenteringRecords={reenteringRecords} onCurrentRecordsChange={setCurrentRecords} /> ); } /** * Run the wait-mode retained-slot algorithm for `AnimatePresence`. * * This path only needs one retained child slot at a time. It therefore composes directly from one * `PresenceChild` plus one `PresenceContent` resolver instead of using the list-aware retained-record * bookkeeping required by the sync path. */ function AnimatePresenceSingle( props: AnimatePresenceProps, adapter: SolidMotionAdapter, ): JSX.Element { const [present, setPresent] = adapter.createSignal(true); const [initial, setInitial] = adapter.createSignal(props.initial); // `createSwitchTransition` controls when the retained JSX snapshot can disappear from the DOM. // `PresenceChild` controls when the nested motion tree is actually done exiting. We keep the // transition's `done()` callback here so those two timelines can be joined explicitly. let pendingDone: VoidFunction | undefined; let pendingPresentAfterExit = false; let hasConsumedInitial = false; /** * Keep the retained presence record aligned with whether the child slot currently resolves. * * The first present child also consumes the boundary-level `initial` flag. After that first * resolved render, descendants fall back to their own `initial` semantics on later entries. */ function handlePresenceChange(nextPresent: boolean): void { if (pendingDone && nextPresent) { // Replacement in wait mode keeps the entering child pending while the old slot exits. // Remember that a new child is waiting, but keep the retained slot logically absent until // the current exit cycle finishes and releases `pendingDone`. That keeps descendant exit // lanes authoritative until the old retained record has fully settled. pendingPresentAfterExit = true; return; } adapter.batch(() => { setPresent(nextPresent); if (hasConsumedInitial || !nextPresent) return; // Boundary-level `initial` only applies to the first successful entry. Later entries should // behave like normal retained re-entry instead of repeatedly suppressing descendant animations. hasConsumedInitial = true; setInitial(undefined); }); } /** * Start one retained exit cycle for the current child slot. * * `done` does not mean "the exit is finished". It only means the switch transition is ready * to drop the old child. We intentionally delay calling it until `PresenceChild` reports that * all nested motion exits have settled. */ function handleExitStart(done: VoidFunction, hasIncomingChild: boolean): void { // Store the transition callback now, then let `PresenceChild` decide when the retained slot // is actually safe to release after every registered descendant reports completion. pendingDone = done; pendingPresentAfterExit = hasIncomingChild; setPresent(false); } /** * Release the retained child once the nested presence tree says exit is complete. */ function handleExitComplete(): void { const done = pendingDone; const shouldRestorePresence = pendingPresentAfterExit; pendingPresentAfterExit = false; pendingDone = undefined; // Release the transition snapshot first, then reopen the retained slot if wait mode already // observed an incoming child. The next tracked pass will rebuild presence from the new slot. done?.(); if (shouldRestorePresence) { setPresent(true); } } return ( props.children} mode={props.mode} adapter={adapter} onPresenceChange={handlePresenceChange} onExitStart={handleExitStart} /> ); } /** * Retain one disappearing child long enough for its exit animation to settle. * * `AnimatePresence` stays mounted while its child slot appears and disappears. That lets the * boundary keep one provider owner alive for the whole slot lifetime while `PresenceContent` * retains the exiting child until `PresenceChild` reports exit completion. */ export const AnimatePresence: ParentComponent = ( props: AnimatePresenceProps, adapter: SolidMotionAdapter = solid1Adapter, ) => { if (props.mode !== "wait") { return AnimatePresenceList(props, adapter); } return AnimatePresenceSingle(props, adapter); };