From b023c2e53a4ef9fffdfb512f0aba0926cbee4ea9 Mon Sep 17 00:00:00 2001 From: harang Date: Tue, 11 Aug 2026 20:42:18 +0900 Subject: [PATCH 1/6] fix: retain registry stores by committed consumers Dynamic route ids need a deletion path. Factory lookup stays id-based, so the registry now tracks committed consumers. Final eviction is deferred to avoid StrictMode replay and late commit races. Constraint: Factory hooks resolve stores by id without Provider or Scope ancestry Rejected: Evict stores from getStore; render allocation cannot prove commit Confidence: high Scope-risk: moderate Directive: Keep render allocation separate from committed-consumer eviction Tested: Registry and integration lifecycle suites before commit Not-tested: Memory profiling of never-committed abandoned renders --- src/registry/registry.ts | 72 +++++++++++++++++++++++++++++++++++----- src/types.ts | 15 +++++++-- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/registry/registry.ts b/src/registry/registry.ts index 49ddb79..2f985d3 100644 --- a/src/registry/registry.ts +++ b/src/registry/registry.ts @@ -42,9 +42,16 @@ export type SwipeDeckStore = { type DeckStoreKey = string | typeof DEFAULT_DECK_KEY; type GetSwipeDeckStore = (id?: string) => SwipeDeckStore; +type SwipeDeckStoreRelease = () => void; + +type SwipeDeckStoreEntry = { + referenceCount: number; + store: SwipeDeckStore; +}; export type SwipeDeckRegistry = SwipeDeckRegistryHooks & { getStore: GetSwipeDeckStore; + retainStore: (id: string | undefined, heldStore: SwipeDeckStore) => SwipeDeckStoreRelease; }; function getDeckStoreKey(id?: string): DeckStoreKey { @@ -211,26 +218,75 @@ function createStore(label: string): SwipeDeckStore { } export function createSwipeDeckRegistry(): SwipeDeckRegistry { - const stores = new Map>(); + const stores = new Map>(); + + const createEntry = (store: SwipeDeckStore): SwipeDeckStoreEntry => ({ + referenceCount: 0, + store, + }); + + const scheduleEntryEviction = (deckStoreKey: DeckStoreKey, entry: SwipeDeckStoreEntry) => { + Promise.resolve().then(() => { + if (stores.get(deckStoreKey) === entry && entry.referenceCount === 0) { + stores.delete(deckStoreKey); + } + }); + }; 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); + + 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); } - 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 hooks = createRegistryHooks(getStore, retainStore); return { getStore, + retainStore, useDeckState: hooks.useDeckState, useDeckActions: hooks.useDeckActions, useDeckInteraction: hooks.useDeckInteraction, 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. */ From 45700eb7df64b6a15243a2b379217f6fd1965f01 Mon Sep 17 00:00:00 2001 From: harang Date: Tue, 11 Aug 2026 20:42:41 +0900 Subject: [PATCH 2/6] fix: release deck stores from roots and hooks Roots and public hooks are the committed registry consumers. Retain the store in layout effects and release it during cleanup. Root attach errors release the held store before rethrowing. Constraint: Duplicate Root protection must remain unchanged for a factory id Rejected: Allow two Roots for one id; external hook ownership would be ambiguous Confidence: high Scope-risk: moderate Directive: Keep attach cleanup ordering before changing duplicate Root behavior Tested: Integration lifecycle suites before commit Not-tested: Native-device navigation memory profile --- src/components/SwipeDeck.tsx | 47 ++++++++++++++++++++++++----------- src/registry/registryHooks.ts | 12 +++++++-- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/components/SwipeDeck.tsx b/src/components/SwipeDeck.tsx index 8d04ccc..7e3b16d 100644 --- a/src/components/SwipeDeck.tsx +++ b/src/components/SwipeDeck.tsx @@ -374,20 +374,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/registryHooks.ts b/src/registry/registryHooks.ts index eb6d8a7..a1807d6 100644 --- a/src/registry/registryHooks.ts +++ b/src/registry/registryHooks.ts @@ -12,6 +12,7 @@ import type { import type { SwipeDeckStore } from './registry'; type GetSwipeDeckStore = (id?: string) => SwipeDeckStore; +type RetainSwipeDeckStore = (id: string | undefined, heldStore: SwipeDeckStore) => () => void; export type SwipeDeckRegistryHooks = { useDeckState: (id?: string) => SwipeDeckState; @@ -21,9 +22,16 @@ export type SwipeDeckRegistryHooks = { useDeckEventListener: SwipeDeckEventListenerHook; }; -export function createRegistryHooks(getStore: GetSwipeDeckStore): SwipeDeckRegistryHooks { +export function createRegistryHooks( + getStore: GetSwipeDeckStore, + retainStore: RetainSwipeDeckStore, +): SwipeDeckRegistryHooks { function useDeckStore(id?: string): SwipeDeckStore { - return useMemo(() => getStore(id), [id]); + const store = useMemo(() => getStore(id), [id]); + + useLayoutEffect(() => retainStore(id, store), [id, store]); + + return store; } function useDeckEvent>( From fce199d53d28d354e2af6db5376a9067acd740ec Mon Sep 17 00:00:00 2001 From: harang Date: Tue, 11 Aug 2026 20:44:39 +0900 Subject: [PATCH 3/6] fix(registry): protect the dynamic-id reclamation contract Dynamic route ids now follow committed consumer lifetime. Cover eviction, late retain, StrictMode, and duplicate Root cleanup. Document stable ids and the abandoned-render limitation in both languages. Constraint: Duplicate Roots for one factory id must remain rejected Rejected: Treat unique ids as item keys | deck ids identify factory-scoped stores Confidence: high Scope-risk: moderate Directive: Update lifecycle tests and docs together when changing registry ownership Tested: 15 Jest suites / 213 tests; lint; typecheck; format; builds; docs checks Not-tested: Memory profiling of fresh ids from renders that never commit --- src/__tests__/SwipeDeck.integration.test.tsx | 224 ++++++++++++++++++- src/__tests__/registry.test.ts | 103 +++++++++ 2 files changed, 326 insertions(+), 1 deletion(-) 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..ed00c90 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(); @@ -262,6 +266,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(); From b3ab7efe07b649b4ac1efaec0258be3a6c0e45ad Mon Sep 17 00:00:00 2001 From: harang Date: Tue, 11 Aug 2026 20:45:55 +0900 Subject: [PATCH 4/6] docs(registry): make dynamic-id lifetime explicit Stable route ids avoid concurrent Root collisions but need lifecycle cleanup. Explain the committed-consumer contract, duplicate-Root rule, and residual risk. Ship the behavior as a minor Changeset for downstream consumers. Constraint: Deck ids identify factory-scoped stores rather than card items Rejected: Hide the render-abort limitation | eager cleanup can race delayed commits Confidence: high Scope-risk: narrow Directive: Keep English, Korean, API, and multi-instance guidance aligned Tested: Docs typecheck/build and generated callout output Not-tested: Memory profiling of fresh ids from renders that never commit --- .changeset/steady-routes-clean.md | 13 ++++++++ .../docs/1.x/en/guide/usage/api-reference.mdx | 10 ++++++ .../guide/usage/multi-instance-management.mdx | 32 +++++++++++++------ .../docs/1.x/ko/guide/usage/api-reference.mdx | 9 ++++++ .../guide/usage/multi-instance-management.mdx | 29 +++++++++++------ 5 files changed, 74 insertions(+), 19 deletions(-) create mode 100644 .changeset/steady-routes-clean.md diff --git a/.changeset/steady-routes-clean.md b/.changeset/steady-routes-clean.md new file mode 100644 index 0000000..e0381f7 --- /dev/null +++ b/.changeset/steady-routes-clean.md @@ -0,0 +1,13 @@ +--- +'@react-native-motion-kit/swipe-deck': minor +--- + +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 From 53f1961389a860cbe22a90000fa1de65a05c5de0 Mon Sep 17 00:00:00 2001 From: harang Date: Tue, 11 Aug 2026 20:52:08 +0900 Subject: [PATCH 5/6] fix(release): avoid overstating registry lifecycle impact The change repairs internal registry retention without adding public API. Align the Changeset with this repository's patch convention for bug fixes. Constraint: Identity resets only after the final committed consumer releases the id Rejected: Minor bump | no new public API or additive user-facing surface Confidence: high Scope-risk: narrow Tested: Changeset status, Oxfmt check, and git diff check --- .changeset/steady-routes-clean.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/steady-routes-clean.md b/.changeset/steady-routes-clean.md index e0381f7..70e09dd 100644 --- a/.changeset/steady-routes-clean.md +++ b/.changeset/steady-routes-clean.md @@ -1,5 +1,5 @@ --- -'@react-native-motion-kit/swipe-deck': minor +'@react-native-motion-kit/swipe-deck': patch --- Reclaim factory registry entries after the final committed `Root` or public hook consumer for an From 47d4ac85e4c15c41cfa94d51593704f11c55d33d Mon Sep 17 00:00:00 2001 From: harang Date: Tue, 11 Aug 2026 21:48:35 +0900 Subject: [PATCH 6/6] fix(registry): rebase late consumers before store retain Observe canonical store identity before committed hooks and Roots retain it. This rebases interrupted renders when another store has claimed the same id. The mismatch guard remains an internal invariant. Constraint: Providerless factory hooks resolve decks globally by id Rejected: Ignore stale retain conflicts | consumers would read an orphaned store Rejected: Per-id numeric generations | adds tombstones without better identity checks Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep identity snapshots pure and notify subscribers outside render Tested: Jest 16 suites / 216 tests; typecheck; lint; format; library and docs builds Not-tested: React 18 runtime matrix Not-tested: Fresh-id aborted renders retain the documented cleanup limitation --- src/__tests__/registry.test.ts | 29 +++++ src/__tests__/registryConcurrency.test.tsx | 138 +++++++++++++++++++++ src/components/SwipeDeck.tsx | 3 +- src/registry/registry.ts | 50 +++++++- src/registry/registryHooks.ts | 31 ++++- 5 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/registryConcurrency.test.tsx diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts index ed00c90..67049d1 100644 --- a/src/__tests__/registry.test.ts +++ b/src/__tests__/registry.test.ts @@ -62,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(); 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 7e3b16d..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)); diff --git a/src/registry/registry.ts b/src/registry/registry.ts index 2f985d3..9311c35 100644 --- a/src/registry/registry.ts +++ b/src/registry/registry.ts @@ -43,6 +43,7 @@ type DeckStoreKey = string | typeof DEFAULT_DECK_KEY; type GetSwipeDeckStore = (id?: string) => SwipeDeckStore; type SwipeDeckStoreRelease = () => void; +type SwipeDeckStoreListener = () => void; type SwipeDeckStoreEntry = { referenceCount: number; @@ -51,7 +52,9 @@ type SwipeDeckStoreEntry = { 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 { @@ -219,20 +222,40 @@ function createStore(label: string): SwipeDeckStore { export function createSwipeDeckRegistry(): SwipeDeckRegistry { 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 existingEntry = stores.get(deckStoreKey); @@ -244,6 +267,7 @@ export function createSwipeDeckRegistry(): SwipeDeckRegistry { const entry = createEntry(createStore(getDeckStoreLabel(id))); stores.set(deckStoreKey, entry); + scheduleStoreNotification(deckStoreKey); return entry.store; }; @@ -262,6 +286,7 @@ export function createSwipeDeckRegistry(): SwipeDeckRegistry { if (!existingEntry) { stores.set(deckStoreKey, entry); + scheduleStoreNotification(deckStoreKey); } entry.referenceCount += 1; @@ -282,11 +307,34 @@ export function createSwipeDeckRegistry(): SwipeDeckRegistry { }; }; - const hooks = createRegistryHooks(getStore, retainStore); + 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 a1807d6..99e385a 100644 --- a/src/registry/registryHooks.ts +++ b/src/registry/registryHooks.ts @@ -9,10 +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; @@ -22,12 +25,32 @@ export type SwipeDeckRegistryHooks = { useDeckEventListener: SwipeDeckEventListenerHook; }; +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( - getStore: GetSwipeDeckStore, + registry: SwipeDeckRegistryStoreAccess, retainStore: RetainSwipeDeckStore, ): SwipeDeckRegistryHooks { function useDeckStore(id?: string): SwipeDeckStore { - const store = useMemo(() => getStore(id), [id]); + const store = useSwipeDeckRegistryStore(registry, id); useLayoutEffect(() => retainStore(id, store), [id, store]);