Skip to content

Commit 5108456

Browse files
committed
test: add navigation lifecycle harness test
Verifies RiveView preserves state after screen detach/reattach using react-native-screens ScreenContainer.
1 parent a4edac3 commit 5108456

File tree

6 files changed

+221
-5
lines changed

6 files changed

+221
-5
lines changed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import {
2+
describe,
3+
it,
4+
expect,
5+
render,
6+
waitFor,
7+
cleanup,
8+
} from 'react-native-harness';
9+
import { useEffect } from 'react';
10+
import { ScreenContainer, Screen } from 'react-native-screens';
11+
import {
12+
RiveView,
13+
RiveFileFactory,
14+
Fit,
15+
type RiveFile,
16+
type RiveViewRef,
17+
} from '@rive-app/react-native';
18+
import type { ViewModelInstance } from '@rive-app/react-native';
19+
20+
const QUICK_START = require('../assets/rive/quick_start.riv');
21+
22+
function expectDefined<T>(value: T): asserts value is NonNullable<T> {
23+
expect(value).toBeDefined();
24+
}
25+
26+
type TestContext = {
27+
ref: RiveViewRef | null;
28+
error: string | null;
29+
};
30+
31+
function ScreenWithRive({
32+
file,
33+
instance,
34+
context,
35+
activityState,
36+
}: {
37+
file: RiveFile;
38+
instance: ViewModelInstance;
39+
context: TestContext;
40+
activityState: 0 | 2;
41+
}) {
42+
useEffect(() => {
43+
return () => {
44+
context.ref = null;
45+
};
46+
}, [context]);
47+
48+
return (
49+
<ScreenContainer style={{ width: 200, height: 200 }}>
50+
<Screen activityState={activityState} style={{ flex: 1 }}>
51+
<RiveView
52+
hybridRef={{
53+
f: (ref: RiveViewRef | null) => {
54+
context.ref = ref;
55+
},
56+
}}
57+
style={{ flex: 1 }}
58+
file={file}
59+
autoPlay={false}
60+
dataBind={instance}
61+
fit={Fit.Contain}
62+
stateMachineName="State Machine 1"
63+
onError={(e) => {
64+
context.error = e.message;
65+
}}
66+
/>
67+
</Screen>
68+
</ScreenContainer>
69+
);
70+
}
71+
72+
describe('navigation lifecycle (PR #46)', () => {
73+
it('RiveView preserves state after screen detach/reattach', async () => {
74+
const file = await RiveFileFactory.fromSource(QUICK_START, undefined);
75+
const vm = file.defaultArtboardViewModel();
76+
expectDefined(vm);
77+
const instance = vm.createDefaultInstance();
78+
expectDefined(instance);
79+
80+
const context: TestContext = { ref: null, error: null };
81+
82+
const { rerender } = await render(
83+
<ScreenWithRive
84+
file={file}
85+
instance={instance}
86+
context={context}
87+
activityState={2}
88+
/>
89+
);
90+
91+
await waitFor(
92+
() => {
93+
expect(context.ref).not.toBeNull();
94+
},
95+
{ timeout: 5000 }
96+
);
97+
await context.ref!.awaitViewReady();
98+
99+
// Set health to 75 via the native view's VMI
100+
const boundVmi = context.ref!.getViewModelInstance();
101+
expectDefined(boundVmi);
102+
const health = boundVmi.numberProperty('health');
103+
expectDefined(health);
104+
health.value = 75;
105+
106+
// Detach screen (simulates navigating away — triggers onDetachedFromWindow)
107+
await rerender(
108+
<ScreenWithRive
109+
file={file}
110+
instance={instance}
111+
context={context}
112+
activityState={0}
113+
/>
114+
);
115+
await new Promise((r) => setTimeout(r, 300));
116+
117+
// Reattach screen (simulates navigating back)
118+
await rerender(
119+
<ScreenWithRive
120+
file={file}
121+
instance={instance}
122+
context={context}
123+
activityState={2}
124+
/>
125+
);
126+
await new Promise((r) => setTimeout(r, 300));
127+
128+
// Query the VMI from the native view after reattach
129+
expect(context.error).toBeNull();
130+
const reattachedVmi = context.ref!.getViewModelInstance();
131+
expectDefined(reattachedVmi);
132+
const reattachedHealth = reattachedVmi.numberProperty('health');
133+
expectDefined(reattachedHealth);
134+
expect(reattachedHealth.value).toBe(75);
135+
136+
cleanup();
137+
});
138+
});

example/metro.config.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ const path = require('path');
22
const { getDefaultConfig } = require('@react-native/metro-config');
33
const { getConfig } = require('react-native-builder-bob/metro-config');
44
const { withRnHarness } = require('react-native-harness/metro');
5-
const { withSingleReactNative } = require('./metro.helpers');
5+
const {
6+
withSingleReactNative,
7+
withBlockedSiblingDeps,
8+
} = require('./metro.helpers');
69

710
const root = path.resolve(__dirname, '..');
811

@@ -21,4 +24,10 @@ const finalConfig = getConfig(config, {
2124
project: __dirname,
2225
});
2326

24-
module.exports = withRnHarness(withSingleReactNative(finalConfig, __dirname));
27+
const expoExampleDir = path.resolve(root, 'expo-example');
28+
module.exports = withRnHarness(
29+
withSingleReactNative(
30+
withBlockedSiblingDeps(finalConfig, __dirname, expoExampleDir),
31+
__dirname
32+
)
33+
);

example/metro.helpers.js

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,51 @@ function withSingleReactNative(config, projectDir) {
5353
};
5454
}
5555

56-
module.exports = { withSingleReactNative };
56+
/**
57+
* Blocks a sibling workspace's node_modules for any dependencies shared between the two.
58+
* Prevents duplicate native module registration when builder-bob watches the monorepo root.
59+
*
60+
* @param {import('metro-config').MetroConfig} config - Metro configuration
61+
* @param {string} projectDir - This project's directory
62+
* @param {string} siblingDir - The sibling workspace's directory
63+
* @returns {import('metro-config').MetroConfig}
64+
*/
65+
function withBlockedSiblingDeps(config, projectDir, siblingDir) {
66+
const myPkg = require(path.resolve(projectDir, 'package.json'));
67+
const siblingPkg = require(path.resolve(siblingDir, 'package.json'));
68+
69+
const myDeps = new Set([
70+
...Object.keys(myPkg.dependencies || {}),
71+
...Object.keys(myPkg.devDependencies || {}),
72+
]);
73+
const siblingDeps = [
74+
...Object.keys(siblingPkg.dependencies || {}),
75+
...Object.keys(siblingPkg.devDependencies || {}),
76+
];
77+
78+
const shared = siblingDeps.filter((dep) => myDeps.has(dep));
79+
if (shared.length === 0) return config;
80+
81+
const patterns = shared.map((dep) => {
82+
const escaped = path
83+
.resolve(siblingDir, 'node_modules', dep)
84+
.replace(/[/\\]/g, '[/\\\\]');
85+
return new RegExp(escaped + '[/\\\\].*$');
86+
});
87+
88+
const existing = config.resolver?.blockList;
89+
const blockList = [
90+
...(existing ? (Array.isArray(existing) ? existing : [existing]) : []),
91+
...patterns,
92+
];
93+
94+
return {
95+
...config,
96+
resolver: {
97+
...config.resolver,
98+
blockList,
99+
},
100+
};
101+
}
102+
103+
module.exports = { withSingleReactNative, withBlockedSiblingDeps };

example/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"react-native-nitro-modules": "0.35.0",
2525
"react-native-reanimated": "4.1.5",
2626
"react-native-safe-area-context": "^5.4.0",
27+
"react-native-screens": "~4.18.0",
2728
"react-native-worklets": "0.6.1"
2829
},
2930
"devDependencies": {

expo-example/metro.config.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
const { getDefaultConfig } = require('expo/metro-config');
33
const { getConfig } = require('react-native-builder-bob/metro-config');
44
const path = require('path');
5-
const { withSingleReactNative } = require('../example/metro.helpers');
5+
const {
6+
withSingleReactNative,
7+
withBlockedSiblingDeps,
8+
} = require('../example/metro.helpers');
69

710
const root = path.resolve(__dirname, '..');
811

@@ -60,4 +63,8 @@ const configWithAlias = {
6063
},
6164
};
6265

63-
module.exports = withSingleReactNative(configWithAlias, __dirname);
66+
const exampleDir = path.resolve(root, 'example');
67+
module.exports = withSingleReactNative(
68+
withBlockedSiblingDeps(configWithAlias, __dirname, exampleDir),
69+
__dirname
70+
);

yarn.lock

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14458,6 +14458,7 @@ __metadata:
1445814458
react-native-nitro-modules: 0.35.0
1445914459
react-native-reanimated: 4.1.5
1446014460
react-native-safe-area-context: ^5.4.0
14461+
react-native-screens: ~4.18.0
1446114462
react-native-worklets: 0.6.1
1446214463
languageName: unknown
1446314464
linkType: soft
@@ -14486,6 +14487,19 @@ __metadata:
1448614487
languageName: node
1448714488
linkType: hard
1448814489

14490+
"react-native-screens@npm:~4.18.0":
14491+
version: 4.18.0
14492+
resolution: "react-native-screens@npm:4.18.0"
14493+
dependencies:
14494+
react-freeze: ^1.0.0
14495+
warn-once: ^0.1.0
14496+
peerDependencies:
14497+
react: "*"
14498+
react-native: "*"
14499+
checksum: b7942efe7bf316ad66aabf6e3b8b999268d3b88b3d23affb0f90f627d8dd980172f79b48abf476d10c3466ba5123240ee3f18f8d0ff7db5b79b9772cb520afa0
14500+
languageName: node
14501+
linkType: hard
14502+
1448914503
"react-native-web@npm:~0.21.0":
1449014504
version: 0.21.2
1449114505
resolution: "react-native-web@npm:0.21.2"

0 commit comments

Comments
 (0)