diff --git a/.changeset/steady-routes-clean.md b/.changeset/steady-routes-clean.md new file mode 100644 index 0000000..70e09dd --- /dev/null +++ b/.changeset/steady-routes-clean.md @@ -0,0 +1,13 @@ +--- +'@react-native-motion-kit/swipe-deck': patch +--- + +Reclaim factory registry entries after the final committed `Root` or public hook consumer for an +id unmounts. Stable navigation route keys are now supported as deck instance ids, provided the key +does not change while the screen is mounted. + +The duplicate-Root rule is unchanged: two simultaneous Roots from the same factory still require +distinct ids. This release also changes the documented identity contract. Actions and interaction +shared values stay stable only while at least one committed consumer retains the id; after a gap +with zero committed consumers and deferred eviction, a later consumer for the same id receives a +fresh action/interaction identity. diff --git a/docs/docs/1.x/en/guide/usage/api-reference.mdx b/docs/docs/1.x/en/guide/usage/api-reference.mdx index 4c1bdf0..d5bbdfd 100644 --- a/docs/docs/1.x/en/guide/usage/api-reference.mdx +++ b/docs/docs/1.x/en/guide/usage/api-reference.mdx @@ -67,6 +67,16 @@ type SwipeThreshold = number | ((layout: SwipeDeckLayout) => number); Static `SwipeDeck.Root` accepts the same props except `id`. +See [Multi-Instance Management](./multi-instance-management) for the complete id contract. + +`id` is not an item key. Use a stable deck namespace such as `"nearby"`, +`"recommended"`, or a navigation route key that does not change while the screen +is mounted. Identity is stable while at least one committed Root or public hook +consumer remains mounted for that id. After the final cleanup and deferred +eviction, a later consumer receives fresh actions and interaction shared values. +Do not derive ids from item ids, timestamps, or values that change while mounted. +Two simultaneous Roots from the same factory still require distinct ids. + ## `SwipeDeck.Card` Props | Prop | Type | Notes | diff --git a/docs/docs/1.x/en/guide/usage/multi-instance-management.mdx b/docs/docs/1.x/en/guide/usage/multi-instance-management.mdx index a47066a..8f289e3 100644 --- a/docs/docs/1.x/en/guide/usage/multi-instance-management.mdx +++ b/docs/docs/1.x/en/guide/usage/multi-instance-management.mdx @@ -22,20 +22,32 @@ function MultiDeckScreen() { } ``` -`id` is a factory-scoped deck namespace, not an item key. Two different -factories can both use the default id safely, but two mounted roots from the -same factory and same id are invalid. - ## Id Rules -- Keep ids stable and low-cardinality. -- Use screen-level names such as `"nearby"` or `"recommended"`. +::: info Use a stable id per mounted deck +`id` is a factory-scoped deck namespace, not an item key. Different factories can safely use the +same id, but simultaneous Roots from one factory must use distinct ids. A stable navigation route +key is supported as long as it does not change while the screen is mounted. +::: + +- Use screen-level names such as `"nearby"` or `"recommended"`, or a navigation route key that + remains stable for one mounted screen. +- Give simultaneous Roots from the same factory distinct ids. The duplicate-Root rule is unchanged: + two mounted Roots from the same factory and same id are invalid. - Do not derive ids from item ids, timestamps, values that change per render, or - one-off route values. -- Create factories and ids outside render paths. + values that change while the screen is mounted. +- Create factories outside render paths, and keep each id stable for the mounted lifecycle. + +The registry keeps hooks, actions, and interaction shared values stable while at +least one committed Root or public hook consumer retains that id. After the final +committed consumer cleans up and the registry finishes its deferred eviction, a +later consumer for the same id receives fresh actions and interaction shared +values. -The registry keeps one store per id for the lifetime of the factory so hooks, -actions, and interaction shared values stay stable. +Never-committed abandoned renders are a known best-effort cleanup limitation: if +a render reads a brand-new id but React never commits it, there is no committed +cleanup path for the library to observe. Avoid creating ids from values that +change while a screen is mounted. ## Same-Factory Rule diff --git a/docs/docs/1.x/ko/guide/usage/api-reference.mdx b/docs/docs/1.x/ko/guide/usage/api-reference.mdx index 62a7e69..880c378 100644 --- a/docs/docs/1.x/ko/guide/usage/api-reference.mdx +++ b/docs/docs/1.x/ko/guide/usage/api-reference.mdx @@ -67,6 +67,15 @@ type SwipeThreshold = number | ((layout: SwipeDeckLayout) => number); Static `SwipeDeck.Root`는 `id`를 제외한 같은 props를 받습니다. +전체 id contract는 [Multi-Instance Management](./multi-instance-management)를 참고하세요. + +`id`는 item key가 아닙니다. `"nearby"`, `"recommended"` 같은 안정적인 deck namespace나, 화면이 +mount되어 있는 동안 변하지 않는 navigation route key를 사용하세요. 해당 id를 retain하는 committed +Root 또는 public hook consumer가 하나 이상 mount되어 있는 동안 identity가 안정적으로 유지됩니다. +마지막 cleanup과 deferred eviction 이후 같은 id를 다시 사용하면 새 action과 interaction shared value를 +받습니다. Item id, timestamp, mounted lifecycle 중에 바뀌는 값에서 id를 만들지 마세요. 같은 factory의 +Root 두 개가 동시에 mount되려면 여전히 서로 다른 id가 필요합니다. + ## `SwipeDeck.Card` props | Prop | Type | 설명 | diff --git a/docs/docs/1.x/ko/guide/usage/multi-instance-management.mdx b/docs/docs/1.x/ko/guide/usage/multi-instance-management.mdx index 65d630c..7f73c7a 100644 --- a/docs/docs/1.x/ko/guide/usage/multi-instance-management.mdx +++ b/docs/docs/1.x/ko/guide/usage/multi-instance-management.mdx @@ -22,19 +22,30 @@ function MultiDeckScreen() { } ``` +## Id 규칙 + +::: info mount된 deck마다 안정적인 id를 사용하세요 `id`는 item key가 아니라 factory 안에서 deck instance를 구분하는 namespace입니다. 서로 다른 -factory는 둘 다 default id를 써도 충돌하지 않지만, 같은 factory와 같은 id의 Root 두 개가 -동시에 mount되는 것은 잘못된 사용입니다. +factory는 같은 id를 안전하게 사용할 수 있지만, 같은 factory에서 동시에 mount되는 Root에는 서로 +다른 id를 사용해야 합니다. 화면이 mount된 동안 바뀌지 않는 navigation route key를 사용할 수 있습니다. +::: -## Id 규칙 +- `"nearby"`, `"recommended"` 같은 화면/용도 단위 이름이나, 화면이 mount되어 있는 동안 변하지 않는 + navigation route key를 사용하세요. +- 같은 factory에서 동시에 mount되는 Root는 서로 다른 id를 가져야 합니다. Duplicate Root 규칙은 + 그대로입니다. 같은 factory와 같은 id의 Root 두 개가 동시에 mount되는 것은 잘못된 사용입니다. +- Item id, timestamp, 매 render마다 바뀌는 값, mounted lifecycle 중에 바뀌는 값에서 id를 만들지 + 마세요. +- Factory는 render path 밖에서 만들고, 각 id는 mounted lifecycle 동안 안정적으로 유지하세요. -- 안정적이고 적은 개수의 id를 사용하세요. -- `"nearby"`, `"recommended"` 같은 화면/용도 단위 이름을 사용하세요. -- Item id, timestamp, 매 render마다 바뀌는 값, 일회성 route 값에서 id를 만들지 마세요. -- Factory와 id는 render path 밖에서 안정적으로 만들고 유지하세요. +Registry는 하나 이상의 committed Root 또는 public hook consumer가 해당 id를 retain하는 동안 hook, +action, interaction shared value의 identity를 안정적으로 유지합니다. 마지막 committed consumer가 +cleanup되고 registry의 deferred eviction이 끝난 뒤 같은 id를 다시 사용하면 새 action과 interaction +shared value를 받습니다. -Registry는 hook, action, interaction shared value의 identity를 안정적으로 유지하기 위해 factory -lifetime 동안 id별 store를 유지합니다. +Never-committed abandoned render는 known best-effort cleanup limitation입니다. Render가 새 id를 +읽었지만 React가 commit하지 않으면 library가 관찰할 committed cleanup path가 없습니다. 화면이 +mount되어 있는 동안 바뀌는 값으로 id를 만들지 마세요. ## Same-Factory Rule diff --git a/src/__tests__/SwipeDeck.integration.test.tsx b/src/__tests__/SwipeDeck.integration.test.tsx index 9c33622..6a93a8b 100644 --- a/src/__tests__/SwipeDeck.integration.test.tsx +++ b/src/__tests__/SwipeDeck.integration.test.tsx @@ -6,7 +6,7 @@ import type { import { describe, expect, it, jest } from '@jest/globals'; import { act, fireEvent, render, screen, userEvent } from '@testing-library/react-native'; -import { useEffect, useState } from 'react'; +import { StrictMode, useEffect, useState } from 'react'; import { Pressable, Text, View } from 'react-native'; import { fireGestureHandler, getByGestureTestId } from 'react-native-gesture-handler/jest-utils'; @@ -46,6 +46,12 @@ async function measureDeckFromVisibleCard(cardName: string) { }); } +async function flushRegistryEvictionMicrotask() { + await act(async () => { + await Promise.resolve(); + }); +} + describe('SwipeDeck factory hooks', () => { it('keeps actions disabled until the deck is measured, then publishes swipe state', async () => { const ProfileDeck = createSwipeDeck(); @@ -2265,6 +2271,222 @@ describe('SwipeDeck factory hooks', () => { expect(await screen.findByText('state:1:false:false:true')).toBeOnTheScreen(); expect(screen.queryByText('Ada')).not.toBeOnTheScreen(); }); + + it('mounts distinct route-like ids from one factory at the same time', async () => { + const ProfileDeck = createSwipeDeck(); + + await render( + <> + + {({ item }) => nearby:{item.name}} + + + {({ item }) => recommended:{item.name}} + + , + ); + + expect(screen.getByText('nearby:Ada')).toBeOnTheScreen(); + expect(screen.getByText('recommended:Grace')).toBeOnTheScreen(); + }); + + it('evicts an id after the final Root and hook unmount and recreates fresh interaction identity', async () => { + const ProfileDeck = createSwipeDeck(); + const interactions: ReturnType[] = []; + + function InteractionProbe() { + const interaction = ProfileDeck.useDeckInteraction('route:fresh'); + + useEffect(() => { + interactions.push(interaction); + }, [interaction]); + + return probe:fresh; + } + + function Example() { + return ( + <> + + + {({ item }) => {item.name}} + + + ); + } + + const firstRender = await render(); + + expect(screen.getByText('probe:fresh')).toBeOnTheScreen(); + expect(interactions).toHaveLength(1); + + const firstInteraction = interactions[0]; + + await firstRender.unmount(); + await flushRegistryEvictionMicrotask(); + + await render(); + + expect(interactions).toHaveLength(2); + expect(interactions[1]).not.toBe(firstInteraction); + }); + + it('keeps interaction identity when a hook stays mounted across Root-only unmount and remount', async () => { + const ProfileDeck = createSwipeDeck(); + const interactions: ReturnType[] = []; + + function InteractionProbe() { + const interaction = ProfileDeck.useDeckInteraction('route:kept'); + + useEffect(() => { + interactions.push(interaction); + }, [interaction]); + + return probe:kept; + } + + function Example({ rootMounted = true }: { rootMounted?: boolean }) { + return ( + <> + + {rootMounted ? ( + + {({ item }) => {item.name}} + + ) : null} + + ); + } + + const renderResult = await render(); + const firstInteraction = interactions[0]; + + await renderResult.rerender(); + await flushRegistryEvictionMicrotask(); + await renderResult.rerender(); + + expect(screen.getByText('probe:kept')).toBeOnTheScreen(); + expect(interactions).toEqual([firstInteraction]); + }); + + it('evicts a hook-only consumer after unmount and gives the next hook fresh identity', async () => { + const ProfileDeck = createSwipeDeck(); + const interactions: ReturnType[] = []; + + function InteractionProbe() { + const interaction = ProfileDeck.useDeckInteraction('route:hook-only'); + + useEffect(() => { + interactions.push(interaction); + }, [interaction]); + + return probe:hook-only; + } + + const firstRender = await render(); + const firstInteraction = interactions[0]; + + await firstRender.unmount(); + await flushRegistryEvictionMicrotask(); + + await render(); + + expect(interactions).toHaveLength(2); + expect(interactions[1]).not.toBe(firstInteraction); + }); + + it('preserves identity through StrictMode setup and cleanup replay without throwing', async () => { + const ProfileDeck = createSwipeDeck(); + const interactions: ReturnType[] = []; + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + function InteractionProbe() { + const interaction = ProfileDeck.useDeckInteraction('route:strict'); + + useEffect(() => { + interactions.push(interaction); + }, [interaction]); + + return probe:strict; + } + + try { + await render( + + + + {({ item }) => {item.name}} + + , + ); + + expect( + consoleErrorSpy.mock.calls.every((args) => { + const message = args.map(String).join(' '); + + return message.includes('findNodeHandle') && message.includes('deprecated in StrictMode'); + }), + ).toBe(true); + expect(screen.getByText('probe:strict')).toBeOnTheScreen(); + expect(new Set(interactions).size).toBe(1); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it('cleans up duplicate Root retain failure so the surviving id can be recreated', async () => { + const ProfileDeck = createSwipeDeck(); + const interactions: ReturnType[] = []; + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + function InteractionProbe() { + const interaction = ProfileDeck.useDeckInteraction('route:duplicate'); + + useEffect(() => { + interactions.push(interaction); + }, [interaction]); + + return probe:duplicate; + } + + function Survivor() { + return ( + <> + + + {({ item }) => {item.name}} + + + ); + } + + function DuplicateRoot() { + return ( + + {({ item }) => {item.name}} + + ); + } + + try { + const survivorRender = await render(); + const firstInteraction = interactions[0]; + + await expect(render()).rejects.toThrow( + 'SwipeDeck.Root with id "route:duplicate" is already mounted for this factory. Use a unique id for multiple decks.', + ); + + await survivorRender.unmount(); + await flushRegistryEvictionMicrotask(); + + await render(); + + expect(interactions).toHaveLength(2); + expect(interactions[1]).not.toBe(firstInteraction); + } finally { + consoleErrorSpy.mockRestore(); + } + }); }); describe('static SwipeDeck surface', () => { diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts index f6e3292..67049d1 100644 --- a/src/__tests__/registry.test.ts +++ b/src/__tests__/registry.test.ts @@ -16,6 +16,10 @@ function createAttachedState(canUndo = false) { }; } +async function flushRegistryEvictionMicrotask() { + await Promise.resolve(); +} + describe('createSwipeDeckRegistry', () => { it('scopes default deck ids by registry instance', () => { const firstRegistry = createSwipeDeckRegistry(); @@ -58,6 +62,35 @@ describe('createSwipeDeckRegistry', () => { expect(store.interaction.dismissDirection.get()).toBeNull(); }); + it('publishes canonical store identity changes without allocating during snapshot reads', async () => { + const registry = createSwipeDeckRegistry(); + const listener = jest.fn(); + const unsubscribe = registry.subscribeStore('route:identity', listener); + + expect(registry.getStoreSnapshot('route:identity')).toBeUndefined(); + expect(registry.getStoreSnapshot('route:identity')).toBeUndefined(); + + const store = registry.getStore('route:identity'); + + expect(registry.getStoreSnapshot('route:identity')).toBe(store); + expect(listener).not.toHaveBeenCalled(); + + await Promise.resolve(); + + expect(listener).toHaveBeenCalledTimes(1); + + const release = registry.retainStore('route:identity', store); + + release(); + await Promise.resolve(); + await Promise.resolve(); + + expect(registry.getStoreSnapshot('route:identity')).toBeUndefined(); + expect(listener).toHaveBeenCalledTimes(2); + + unsubscribe(); + }); + it('notifies state subscribers only when the snapshot changes', () => { const registry = createSwipeDeckRegistry(); const store = registry.getStore(); @@ -262,6 +295,105 @@ describe('createSwipeDeckRegistry', () => { secondDetach(); }); + it('evicts a store after the final release and returns a fresh identity on next lookup', async () => { + const registry = createSwipeDeckRegistry(); + const firstStore = registry.getStore('route:ada'); + const release = registry.retainStore('route:ada', firstStore); + + release(); + await flushRegistryEvictionMicrotask(); + + expect(registry.getStore('route:ada')).not.toBe(firstStore); + expect(registry.getStore('route:ada').interaction).not.toBe(firstStore.interaction); + }); + + it('makes release closures idempotent and does not underflow the lifecycle count', async () => { + const registry = createSwipeDeckRegistry(); + const store = registry.getStore('route:grace'); + const firstRelease = registry.retainStore('route:grace', store); + const secondRelease = registry.retainStore('route:grace', store); + + firstRelease(); + firstRelease(); + await flushRegistryEvictionMicrotask(); + + expect(registry.getStore('route:grace')).toBe(store); + + secondRelease(); + secondRelease(); + await flushRegistryEvictionMicrotask(); + + expect(registry.getStore('route:grace')).not.toBe(store); + }); + + it('preserves identity when the same entry is re-retained before the eviction microtask', async () => { + const registry = createSwipeDeckRegistry(); + const store = registry.getStore('route:linus'); + const firstRelease = registry.retainStore('route:linus', store); + + firstRelease(); + + const secondRelease = registry.retainStore('route:linus', store); + + await flushRegistryEvictionMicrotask(); + + expect(registry.getStore('route:linus')).toBe(store); + + secondRelease(); + }); + + it('restores a late held store when its id is unclaimed', async () => { + const registry = createSwipeDeckRegistry(); + const heldStore = registry.getStore('route:late'); + const firstRelease = registry.retainStore('route:late', heldStore); + + firstRelease(); + await flushRegistryEvictionMicrotask(); + + const lateRelease = registry.retainStore('route:late', heldStore); + + expect(registry.getStore('route:late')).toBe(heldStore); + + lateRelease(); + }); + + it('does not let a stale release delete a replacement entry for the same id', async () => { + const registry = createSwipeDeckRegistry(); + const firstStore = registry.getStore('route:replacement'); + const firstRelease = registry.retainStore('route:replacement', firstStore); + + firstRelease(); + const replacementPromise = Promise.resolve().then(() => registry.getStore('route:replacement')); + + const secondRelease = registry.retainStore('route:replacement', firstStore); + secondRelease(); + + const replacementStore = await replacementPromise; + await flushRegistryEvictionMicrotask(); + + expect(registry.getStore('route:replacement')).toBe(replacementStore); + expect(replacementStore).not.toBe(firstStore); + }); + + it('rejects retaining a held store when a different entry already owns the id', async () => { + const registry = createSwipeDeckRegistry(); + const heldStore = registry.getStore('route:conflict'); + const firstRelease = registry.retainStore('route:conflict', heldStore); + + firstRelease(); + await flushRegistryEvictionMicrotask(); + + const replacementStore = registry.getStore('route:conflict'); + const replacementRelease = registry.retainStore('route:conflict', replacementStore); + + expect(() => registry.retainStore('route:conflict', heldStore)).toThrow( + /SwipeDeck registry lifecycle inconsistency/, + ); + expect(registry.getStore('route:conflict')).toBe(replacementStore); + + replacementRelease(); + }); + it('resets interaction shared values on detach', () => { const registry = createSwipeDeckRegistry(); const store = registry.getStore(); diff --git a/src/__tests__/registryConcurrency.test.tsx b/src/__tests__/registryConcurrency.test.tsx new file mode 100644 index 0000000..b15732f --- /dev/null +++ b/src/__tests__/registryConcurrency.test.tsx @@ -0,0 +1,138 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { act, render } from '@testing-library/react-native'; +import { Activity, useLayoutEffect } from 'react'; +import { Text } from 'react-native'; + +import { createSwipeDeck } from '../index'; +import { createSwipeDeckRegistry } from '../registry/registry'; + +async function flushRegistryEvictionMicrotask() { + await act(async () => { + await Promise.resolve(); + }); +} + +describe('SwipeDeck registry concurrent lifecycle', () => { + it('rebases a hidden hook consumer onto the current store before its effects mount', async () => { + const registry = createSwipeDeckRegistry(); + const renderedInteractions: ReturnType[] = []; + const committedInteractions: ReturnType[] = []; + const replacementInteractions: ReturnType[] = []; + + function AnchorConsumer() { + registry.useDeckInteraction('route:concurrent'); + + return anchor; + } + + function LateConsumer() { + const interaction = registry.useDeckInteraction('route:concurrent'); + + renderedInteractions.push(interaction); + + useLayoutEffect(() => { + committedInteractions.push(interaction); + }, [interaction]); + + return late; + } + + function ReplacementConsumer() { + const interaction = registry.useDeckInteraction('route:concurrent'); + + useLayoutEffect(() => { + replacementInteractions.push(interaction); + }, [interaction]); + + return replacement; + } + + const anchorRender = await render(); + const hiddenRender = await render( + + + , + ); + + const staleInteraction = renderedInteractions.at(-1); + + expect(staleInteraction).toBeDefined(); + expect(committedInteractions).toEqual([]); + + await anchorRender.unmount(); + await flushRegistryEvictionMicrotask(); + + const replacementRender = await render(); + const currentInteraction = replacementInteractions[0]; + + expect(currentInteraction).toBeDefined(); + expect(currentInteraction).not.toBe(staleInteraction); + + await expect( + hiddenRender.rerender( + + + , + ), + ).resolves.toBeUndefined(); + + expect(committedInteractions.at(-1)).toBe(currentInteraction); + + await hiddenRender.unmount(); + await replacementRender.unmount(); + }); + + it('attaches a hidden Root to the current store when it becomes visible', async () => { + const ProfileDeck = createSwipeDeck<{ id: string }>(); + const getProfileKey = jest.fn((item: { id: string }) => item.id); + + function AnchorConsumer() { + ProfileDeck.useDeckInteraction('route:root'); + + return anchor; + } + + function CurrentStateProbe() { + const state = ProfileDeck.useDeckState('route:root'); + + return current-count:{state.count}; + } + + function LateRoot() { + return ( + + {null} + + ); + } + + const anchorRender = await render(); + const hiddenRender = await render( + + + , + ); + + expect(getProfileKey).toHaveBeenCalled(); + + await anchorRender.unmount(); + await flushRegistryEvictionMicrotask(); + + const replacementRender = await render(); + + expect(replacementRender.getByText('current-count:0')).toBeOnTheScreen(); + + await expect( + hiddenRender.rerender( + + + , + ), + ).resolves.toBeUndefined(); + + expect(await replacementRender.findByText('current-count:1')).toBeOnTheScreen(); + + await hiddenRender.unmount(); + await replacementRender.unmount(); + }); +}); diff --git a/src/components/SwipeDeck.tsx b/src/components/SwipeDeck.tsx index 8d04ccc..607eed5 100644 --- a/src/components/SwipeDeck.tsx +++ b/src/components/SwipeDeck.tsx @@ -38,6 +38,7 @@ import { useSwipeDeckMotionConfig } from '../hooks/useSwipeDeckMotionConfig'; import { useSwipeDeckUndoRuntime } from '../hooks/useSwipeDeckUndoRuntime'; import { getSwipeDeckState } from '../registry/deckState'; import { createSwipeDeckRegistry, type SwipeDeckRegistry } from '../registry/registry'; +import { useSwipeDeckRegistryStore } from '../registry/registryHooks'; import { SwipeDeckCard } from './SwipeDeckCard'; import { SwipeDeckRenderedCard } from './SwipeDeckRenderedCard'; @@ -81,7 +82,7 @@ function Root({ children, registry, }: SwipeDeckRootProps): ReactElement { - const deckStore = useMemo(() => registry.getStore(id), [id, registry]); + const deckStore = useSwipeDeckRegistryStore(registry, id); const interaction = deckStore.interaction; const [layout, setLayout] = useState({ width: 0, height: 0 }); const [activeIndex, setActiveIndex] = useState(() => clampActiveIndex(data.length, initialIndex)); @@ -374,20 +375,39 @@ function Root({ attachmentGenerationRef.current = currentAttachmentGeneration; attachmentGeneration.set(currentAttachmentGeneration); - const detach = deckStore.attach({ - getState: getDeckState, - swipe: swipeProgrammatically, - undo: undoProgrammatically, - }); - - return () => { - const nextAttachmentGeneration = attachmentGenerationRef.current + 1; - - attachmentGenerationRef.current = nextAttachmentGeneration; - attachmentGeneration.set(nextAttachmentGeneration); - detach(); - }; - }, [attachmentGeneration, deckStore, getDeckState, swipeProgrammatically, undoProgrammatically]); + const releaseStore = registry.retainStore(id, deckStore); + + try { + const detach = deckStore.attach({ + getState: getDeckState, + swipe: swipeProgrammatically, + undo: undoProgrammatically, + }); + + return () => { + try { + const nextAttachmentGeneration = attachmentGenerationRef.current + 1; + + attachmentGenerationRef.current = nextAttachmentGeneration; + attachmentGeneration.set(nextAttachmentGeneration); + detach(); + } finally { + releaseStore(); + } + }; + } catch (error) { + releaseStore(); + throw error; + } + }, [ + attachmentGeneration, + deckStore, + getDeckState, + id, + registry, + swipeProgrammatically, + undoProgrammatically, + ]); // Root owns public deck-state publication for any active-index change. // Dismiss runtime separately owns active render-item sync and post-dismiss reset ordering. diff --git a/src/registry/registry.ts b/src/registry/registry.ts index 49ddb79..9311c35 100644 --- a/src/registry/registry.ts +++ b/src/registry/registry.ts @@ -42,9 +42,19 @@ export type SwipeDeckStore = { type DeckStoreKey = string | typeof DEFAULT_DECK_KEY; type GetSwipeDeckStore = (id?: string) => SwipeDeckStore; +type SwipeDeckStoreRelease = () => void; +type SwipeDeckStoreListener = () => void; + +type SwipeDeckStoreEntry = { + referenceCount: number; + store: SwipeDeckStore; +}; export type SwipeDeckRegistry = SwipeDeckRegistryHooks & { getStore: GetSwipeDeckStore; + getStoreSnapshot: (id?: string) => SwipeDeckStore | undefined; + retainStore: (id: string | undefined, heldStore: SwipeDeckStore) => SwipeDeckStoreRelease; + subscribeStore: (id: string | undefined, listener: SwipeDeckStoreListener) => () => void; }; function getDeckStoreKey(id?: string): DeckStoreKey { @@ -211,26 +221,120 @@ function createStore(label: string): SwipeDeckStore { } export function createSwipeDeckRegistry(): SwipeDeckRegistry { - const stores = new Map>(); + const stores = new Map>(); + const storeListeners = new Map>(); + const pendingStoreNotifications = new Set(); + + const createEntry = (store: SwipeDeckStore): SwipeDeckStoreEntry => ({ + referenceCount: 0, + store, + }); + + const scheduleStoreNotification = (deckStoreKey: DeckStoreKey) => { + if (pendingStoreNotifications.has(deckStoreKey)) { + return; + } + + pendingStoreNotifications.add(deckStoreKey); + + // getStore can create an entry during render, so registry subscribers must + // not be notified synchronously from that render. + Promise.resolve().then(() => { + pendingStoreNotifications.delete(deckStoreKey); + storeListeners.get(deckStoreKey)?.forEach((listener) => listener()); + }); + }; + + const scheduleEntryEviction = (deckStoreKey: DeckStoreKey, entry: SwipeDeckStoreEntry) => { + Promise.resolve().then(() => { + if (stores.get(deckStoreKey) === entry && entry.referenceCount === 0) { + stores.delete(deckStoreKey); + scheduleStoreNotification(deckStoreKey); + } + }); + }; + + const getStoreSnapshot = (id?: string) => stores.get(getDeckStoreKey(id))?.store; const getStore = (id?: string) => { const deckStoreKey = getDeckStoreKey(id); - const existingStore = stores.get(deckStoreKey); + const existingEntry = stores.get(deckStoreKey); + + if (existingEntry) { + return existingEntry.store; + } + + const entry = createEntry(createStore(getDeckStoreLabel(id))); + + stores.set(deckStoreKey, entry); + scheduleStoreNotification(deckStoreKey); + + return entry.store; + }; + + const retainStore = (id: string | undefined, heldStore: SwipeDeckStore) => { + const deckStoreKey = getDeckStoreKey(id); + const existingEntry = stores.get(deckStoreKey); + + if (existingEntry && existingEntry.store !== heldStore) { + throw new Error( + `SwipeDeck registry lifecycle inconsistency for id "${getDeckStoreLabel(id)}": a different store already owns this id.`, + ); + } + + const entry = existingEntry ?? createEntry(heldStore); - if (existingStore) { - return existingStore; + if (!existingEntry) { + stores.set(deckStoreKey, entry); + scheduleStoreNotification(deckStoreKey); } - const store = createStore(getDeckStoreLabel(id)); - stores.set(deckStoreKey, store); + entry.referenceCount += 1; - return store; + let released = false; + + return () => { + if (released) { + return; + } + + released = true; + entry.referenceCount -= 1; + + if (entry.referenceCount === 0) { + scheduleEntryEviction(deckStoreKey, entry); + } + }; }; - const hooks = createRegistryHooks(getStore); + const subscribeStore = (id: string | undefined, listener: SwipeDeckStoreListener) => { + const deckStoreKey = getDeckStoreKey(id); + const listeners = storeListeners.get(deckStoreKey) ?? new Set(); + + listeners.add(listener); + storeListeners.set(deckStoreKey, listeners); + + return () => { + listeners.delete(listener); + + if (listeners.size === 0) { + storeListeners.delete(deckStoreKey); + } + }; + }; + + const storeAccess = { + getStore, + getStoreSnapshot, + subscribeStore, + }; + const hooks = createRegistryHooks(storeAccess, retainStore); return { getStore, + getStoreSnapshot, + retainStore, + subscribeStore, useDeckState: hooks.useDeckState, useDeckActions: hooks.useDeckActions, useDeckInteraction: hooks.useDeckInteraction, diff --git a/src/registry/registryHooks.ts b/src/registry/registryHooks.ts index eb6d8a7..99e385a 100644 --- a/src/registry/registryHooks.ts +++ b/src/registry/registryHooks.ts @@ -9,9 +9,13 @@ import type { SwipeDeckInteraction, SwipeDeckState, } from '../types'; -import type { SwipeDeckStore } from './registry'; +import type { SwipeDeckRegistry, SwipeDeckStore } from './registry'; -type GetSwipeDeckStore = (id?: string) => SwipeDeckStore; +type RetainSwipeDeckStore = (id: string | undefined, heldStore: SwipeDeckStore) => () => void; +type SwipeDeckRegistryStoreAccess = Pick< + SwipeDeckRegistry, + 'getStore' | 'getStoreSnapshot' | 'subscribeStore' +>; export type SwipeDeckRegistryHooks = { useDeckState: (id?: string) => SwipeDeckState; @@ -21,9 +25,36 @@ export type SwipeDeckRegistryHooks = { useDeckEventListener: SwipeDeckEventListenerHook; }; -export function createRegistryHooks(getStore: GetSwipeDeckStore): SwipeDeckRegistryHooks { +export function useSwipeDeckRegistryStore( + registry: SwipeDeckRegistryStoreAccess, + id?: string, +): SwipeDeckStore { + const heldStore = useMemo(() => registry.getStore(id), [id, registry]); + const subscribe = useCallback( + (listener: () => void) => registry.subscribeStore(id, listener), + [id, registry], + ); + // Preserve an unclaimed held store so retainStore can restore it. If a + // replacement owns the id, React observes that identity before commit and + // rerenders the consumer with the replacement instead. + const getSnapshot = useCallback( + () => registry.getStoreSnapshot(id) ?? heldStore, + [heldStore, id, registry], + ); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +export function createRegistryHooks( + registry: SwipeDeckRegistryStoreAccess, + retainStore: RetainSwipeDeckStore, +): SwipeDeckRegistryHooks { function useDeckStore(id?: string): SwipeDeckStore { - return useMemo(() => getStore(id), [id]); + const store = useSwipeDeckRegistryStore(registry, id); + + useLayoutEffect(() => retainStore(id, store), [id, store]); + + return store; } function useDeckEvent>( diff --git a/src/types.ts b/src/types.ts index b690e75..45702bc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -406,9 +406,18 @@ export type SwipeDeckProps = { /** * Deck instance id inside this factory namespace. * - * This is not an item key. Use it only for a small, stable set of deck instances rendered - * from the same `createSwipeDeck()` factory, such as `"nearby"` or `"recommended"`. - * Do not derive it from item ids, timestamps, or rapidly changing route/render values. + * This is not an item key. Use it for a stable deck instance rendered from the same + * `createSwipeDeck()` factory, such as `"nearby"`, `"recommended"`, or a navigation + * route key that remains unchanged while the screen is mounted. Two simultaneous Roots from + * the same factory must use distinct ids. + * + * The id keeps actions and interaction identity stable while at least one committed Root or + * public hook consumer remains mounted. After the final committed consumer cleans up and the + * registry finishes its deferred eviction, a later consumer for the same id receives fresh + * actions and interaction shared values. + * + * Do not derive ids from item ids, timestamps, or values that change while mounted. A render + * that reads a new id but never commits is a known best-effort cleanup limitation. * * Omit this for the common single-deck case. */