Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/steady-routes-clean.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions docs/docs/1.x/en/guide/usage/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
32 changes: 22 additions & 10 deletions docs/docs/1.x/en/guide/usage/multi-instance-management.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions docs/docs/1.x/ko/guide/usage/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 설명 |
Expand Down
29 changes: 20 additions & 9 deletions docs/docs/1.x/ko/guide/usage/multi-instance-management.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
224 changes: 223 additions & 1 deletion src/__tests__/SwipeDeck.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<Profile>();
Expand Down Expand Up @@ -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<Profile>();

await render(
<>
<ProfileDeck.Root id="route:nearby" data={[adaProfile]} getKey={getProfileKey}>
<ProfileDeck.Card>{({ item }) => <Text>nearby:{item.name}</Text>}</ProfileDeck.Card>
</ProfileDeck.Root>
<ProfileDeck.Root id="route:recommended" data={[graceProfile]} getKey={getProfileKey}>
<ProfileDeck.Card>{({ item }) => <Text>recommended:{item.name}</Text>}</ProfileDeck.Card>
</ProfileDeck.Root>
</>,
);

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<Profile>();
const interactions: ReturnType<typeof ProfileDeck.useDeckInteraction>[] = [];

function InteractionProbe() {
const interaction = ProfileDeck.useDeckInteraction('route:fresh');

useEffect(() => {
interactions.push(interaction);
}, [interaction]);

return <Text>probe:fresh</Text>;
}

function Example() {
return (
<>
<InteractionProbe />
<ProfileDeck.Root id="route:fresh" data={[adaProfile]} getKey={getProfileKey}>
<ProfileDeck.Card>{({ item }) => <Text>{item.name}</Text>}</ProfileDeck.Card>
</ProfileDeck.Root>
</>
);
}

const firstRender = await render(<Example />);

expect(screen.getByText('probe:fresh')).toBeOnTheScreen();
expect(interactions).toHaveLength(1);

const firstInteraction = interactions[0];

await firstRender.unmount();
await flushRegistryEvictionMicrotask();

await render(<Example />);

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<Profile>();
const interactions: ReturnType<typeof ProfileDeck.useDeckInteraction>[] = [];

function InteractionProbe() {
const interaction = ProfileDeck.useDeckInteraction('route:kept');

useEffect(() => {
interactions.push(interaction);
}, [interaction]);

return <Text>probe:kept</Text>;
}

function Example({ rootMounted = true }: { rootMounted?: boolean }) {
return (
<>
<InteractionProbe />
{rootMounted ? (
<ProfileDeck.Root id="route:kept" data={[adaProfile]} getKey={getProfileKey}>
<ProfileDeck.Card>{({ item }) => <Text>{item.name}</Text>}</ProfileDeck.Card>
</ProfileDeck.Root>
) : null}
</>
);
}

const renderResult = await render(<Example />);
const firstInteraction = interactions[0];

await renderResult.rerender(<Example rootMounted={false} />);
await flushRegistryEvictionMicrotask();
await renderResult.rerender(<Example />);

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<Profile>();
const interactions: ReturnType<typeof ProfileDeck.useDeckInteraction>[] = [];

function InteractionProbe() {
const interaction = ProfileDeck.useDeckInteraction('route:hook-only');

useEffect(() => {
interactions.push(interaction);
}, [interaction]);

return <Text>probe:hook-only</Text>;
}

const firstRender = await render(<InteractionProbe />);
const firstInteraction = interactions[0];

await firstRender.unmount();
await flushRegistryEvictionMicrotask();

await render(<InteractionProbe />);

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<Profile>();
const interactions: ReturnType<typeof ProfileDeck.useDeckInteraction>[] = [];
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);

function InteractionProbe() {
const interaction = ProfileDeck.useDeckInteraction('route:strict');

useEffect(() => {
interactions.push(interaction);
}, [interaction]);

return <Text>probe:strict</Text>;
}

try {
await render(
<StrictMode>
<InteractionProbe />
<ProfileDeck.Root id="route:strict" data={[adaProfile]} getKey={getProfileKey}>
<ProfileDeck.Card>{({ item }) => <Text>{item.name}</Text>}</ProfileDeck.Card>
</ProfileDeck.Root>
</StrictMode>,
);

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<Profile>();
const interactions: ReturnType<typeof ProfileDeck.useDeckInteraction>[] = [];
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);

function InteractionProbe() {
const interaction = ProfileDeck.useDeckInteraction('route:duplicate');

useEffect(() => {
interactions.push(interaction);
}, [interaction]);

return <Text>probe:duplicate</Text>;
}

function Survivor() {
return (
<>
<InteractionProbe />
<ProfileDeck.Root id="route:duplicate" data={[adaProfile]} getKey={getProfileKey}>
<ProfileDeck.Card>{({ item }) => <Text>{item.name}</Text>}</ProfileDeck.Card>
</ProfileDeck.Root>
</>
);
}

function DuplicateRoot() {
return (
<ProfileDeck.Root id="route:duplicate" data={[graceProfile]} getKey={getProfileKey}>
<ProfileDeck.Card>{({ item }) => <Text>{item.name}</Text>}</ProfileDeck.Card>
</ProfileDeck.Root>
);
}

try {
const survivorRender = await render(<Survivor />);
const firstInteraction = interactions[0];

await expect(render(<DuplicateRoot />)).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(<Survivor />);

expect(interactions).toHaveLength(2);
expect(interactions[1]).not.toBe(firstInteraction);
} finally {
consoleErrorSpy.mockRestore();
}
});
});

describe('static SwipeDeck surface', () => {
Expand Down
Loading
Loading