@granite-js/react-native 1.0.43 → 1.0.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # @granite-js/react-native
2
2
 
3
+ ## 1.0.45
4
+
5
+ ### Patch Changes
6
+
7
+ - @granite-js/blur-view@1.0.45
8
+ - @granite-js/brownfield-module@1.0.45
9
+ - @granite-js/cli@1.0.45
10
+ - @granite-js/jest@1.0.45
11
+ - @granite-js/mpack@1.0.45
12
+ - @granite-js/plugin-core@1.0.45
13
+ - @granite-js/style-utils@1.0.45
14
+ - @granite-js/video@1.0.45
15
+
16
+ ## 1.0.44
17
+
18
+ ### Patch Changes
19
+
20
+ - e97fe95: Use an unrestricted native peer range instead of an exact workspace constraint, preventing compatible updates from causing an unintended major release.
21
+ - 23a710f: Read navigation focus through `useSyncExternalStore` so focus changes between rendering and subscription are not missed by `useIsFocusedSafely`, `useVisibility`, or visibility effects. Preserve the focused fallback when no navigation context is available.
22
+
23
+ Visibility effects follow committed visibility snapshots with React's effect setup and cleanup lifecycle.
24
+
25
+ - 92a23a8: Add `useVisibilityEffect` to run an effect while a screen is visible and clean it up when visibility is lost, the callback changes, or the component unmounts.
26
+
27
+ Deprecate `useVisibilityChange` without changing its behavior. Use `useVisibilityEffect` for effects with cleanup, or `useVisibility` with React's `useEffect` for explicit visibility change handling.
28
+
29
+ - Updated dependencies [e97fe95]
30
+ - @granite-js/blur-view@1.0.44
31
+ - @granite-js/jest@1.0.44
32
+ - @granite-js/brownfield-module@1.0.44
33
+ - @granite-js/cli@1.0.44
34
+ - @granite-js/mpack@1.0.44
35
+ - @granite-js/plugin-core@1.0.44
36
+ - @granite-js/style-utils@1.0.44
37
+ - @granite-js/video@1.0.44
38
+
3
39
  ## 1.0.43
4
40
 
5
41
  ### Patch Changes
@@ -3,5 +3,6 @@ export * from './useIsAppForeground';
3
3
  export * from './useVisibility';
4
4
  export * from './useVisibilityChanged';
5
5
  export * from './useVisibilityChange';
6
+ export * from './useVisibilityEffect';
6
7
  export * from './VisibilityProvider';
7
8
  export * from './onVisibilityChanged';
@@ -9,6 +9,7 @@
9
9
  * A Hook that safely uses `useIsFocused` provided by `@react-navigation/native`.
10
10
  * This Hook is based on `useIsFocused` from `@react-navigation/native`, but modified to not throw errors when `navigation` or `root` objects are `null` or `undefined`.
11
11
  * It ensures that users don't see errors even when the code is used in environments where `@react-navigation/native` is not used.
12
+ * Focus is read as an external store snapshot so changes between rendering and subscription are observed.
12
13
  *
13
14
  * @returns {boolean} - Returns the focus state of the current screen.
14
15
  * @example
@@ -14,6 +14,7 @@ export type VisibilityCallback = (state: VisibilityState) => void;
14
14
  * @public
15
15
  * @category Screen Control
16
16
  * @name useVisibilityChange
17
+ * @deprecated Use `useVisibilityEffect` for effects with cleanup, or `useVisibility` with React's `useEffect` to handle visibility changes.
17
18
  * @kind function
18
19
  * @description
19
20
  * Calls a callback function with the visibility state when the screen's visibility changes.
@@ -0,0 +1,34 @@
1
+ import { type EffectCallback } from 'react';
2
+ /**
3
+ * @public
4
+ * @category Screen Control
5
+ * @name useVisibilityEffect
6
+ * @description
7
+ * Runs an effect while the screen is visible, using the visibility state from `useVisibility`.
8
+ * Cleans up when the screen becomes hidden, the effect callback changes, or the component unmounts.
9
+ * Like `useEffect`, setup and cleanup run after a render is committed. The callback must be synchronous
10
+ * and may return a cleanup function. Use `useCallback` to avoid restarting on unrelated renders.
11
+ * Effects follow the visibility snapshot from the committed render; later focus changes apply on a subsequent commit.
12
+ * Unlike `useVisibilityChange`, a returned cleanup function is registered with React.
13
+ * Effect ordering between different screens is not guaranteed; navigation events do not invoke this callback synchronously.
14
+ *
15
+ * @param {EffectCallback} effect - Sets up the effect and optionally returns its cleanup function.
16
+ * @example
17
+ * ```tsx
18
+ * import { useCallback } from 'react';
19
+ * import { BackHandler } from 'react-native';
20
+ * import { useVisibilityEffect } from '@granite-js/react-native';
21
+ *
22
+ * function Screen({ onBack }: { onBack: () => boolean }) {
23
+ * useVisibilityEffect(
24
+ * useCallback(() => {
25
+ * const subscription = BackHandler.addEventListener('hardwareBackPress', onBack);
26
+ * return () => subscription.remove();
27
+ * }, [onBack])
28
+ * );
29
+ *
30
+ * return null;
31
+ * }
32
+ * ```
33
+ */
34
+ export declare function useVisibilityEffect(effect: EffectCallback): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@granite-js/react-native",
3
- "version": "1.0.43",
3
+ "version": "1.0.45",
4
4
  "description": "The Granite Framework",
5
5
  "bin": {
6
6
  "granite": "./bin/cli.js"
@@ -91,8 +91,8 @@
91
91
  "@babel/core": "7.28.5",
92
92
  "@babel/preset-env": "7.28.5",
93
93
  "@babel/preset-typescript": "7.28.5",
94
- "@granite-js/native": "1.0.43",
95
- "@granite-js/vitest": "1.0.43",
94
+ "@granite-js/native": "1.0.45",
95
+ "@granite-js/vitest": "1.0.45",
96
96
  "@testing-library/dom": "^10.4.0",
97
97
  "@testing-library/react": "^16.1.0",
98
98
  "@types/babel__core": "^7",
@@ -114,21 +114,21 @@
114
114
  "zod": "^4.1.12"
115
115
  },
116
116
  "peerDependencies": {
117
- "@granite-js/native": "1.0.43",
117
+ "@granite-js/native": "*",
118
118
  "@types/react": "*",
119
119
  "brick-module": "*",
120
120
  "react": "*",
121
121
  "react-native": "*"
122
122
  },
123
123
  "dependencies": {
124
- "@granite-js/blur-view": "1.0.43",
125
- "@granite-js/brownfield-module": "1.0.43",
126
- "@granite-js/cli": "1.0.43",
127
- "@granite-js/jest": "1.0.43",
128
- "@granite-js/mpack": "1.0.43",
129
- "@granite-js/plugin-core": "1.0.43",
130
- "@granite-js/style-utils": "1.0.43",
131
- "@granite-js/video": "1.0.43",
124
+ "@granite-js/blur-view": "1.0.45",
125
+ "@granite-js/brownfield-module": "1.0.45",
126
+ "@granite-js/cli": "1.0.45",
127
+ "@granite-js/jest": "1.0.45",
128
+ "@granite-js/mpack": "1.0.45",
129
+ "@granite-js/plugin-core": "1.0.45",
130
+ "@granite-js/style-utils": "1.0.45",
131
+ "@granite-js/video": "1.0.45",
132
132
  "@standard-schema/spec": "^1.0.0",
133
133
  "es-toolkit": "^1.39.8",
134
134
  "react-native-url-polyfill": "3.0.0"
@@ -3,5 +3,6 @@ export * from './useIsAppForeground';
3
3
  export * from './useVisibility';
4
4
  export * from './useVisibilityChanged';
5
5
  export * from './useVisibilityChange';
6
+ export * from './useVisibilityEffect';
6
7
  export * from './VisibilityProvider';
7
- export * from './onVisibilityChanged';
8
+ export * from './onVisibilityChanged';
@@ -0,0 +1,216 @@
1
+ import { act, cleanup, render, renderHook } from '@testing-library/react';
2
+ import { type EffectCallback, type ReactNode, useEffect, useLayoutEffect } from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { useIsFocusedSafely } from './useIsFocusedSafely';
5
+ import { AppStateProvider } from '../useIsAppForeground';
6
+ import { VisibilityChangedProvider } from '../useVisibilityChanged';
7
+ import { useVisibilityEffect } from '../useVisibilityEffect';
8
+
9
+ function createNavigation(initialFocus: boolean) {
10
+ let focused = initialFocus;
11
+ const listeners = { focus: new Set<() => void>(), blur: new Set<() => void>() };
12
+ return {
13
+ isFocused: () => focused,
14
+ addListener(event: 'focus' | 'blur', listener: () => void) {
15
+ listeners[event].add(listener);
16
+ return () => {
17
+ listeners[event].delete(listener);
18
+ };
19
+ },
20
+ setFocused(value: boolean) {
21
+ focused = value;
22
+ listeners[value ? 'focus' : 'blur'].forEach((listener) => listener());
23
+ },
24
+ listenerCount: () => listeners.focus.size + listeners.blur.size,
25
+ };
26
+ }
27
+
28
+ let navigation: ReturnType<typeof createNavigation> | undefined;
29
+ vi.mock('./useNavigationSafely', () => ({ useNavigationSafely: () => navigation }));
30
+
31
+ beforeEach(() => {
32
+ navigation = undefined;
33
+ });
34
+ afterEach(cleanup);
35
+
36
+ describe('useIsFocusedSafely', () => {
37
+ it('returns true without a navigation context', () => {
38
+ const { result } = renderHook(useIsFocusedSafely);
39
+ expect(result.current).toBe(true);
40
+ });
41
+
42
+ it.each([false, true])('reads the initial focus snapshot (%s)', (focused) => {
43
+ navigation = createNavigation(focused);
44
+ const { result } = renderHook(useIsFocusedSafely);
45
+ expect(result.current).toBe(focused);
46
+ });
47
+
48
+ it.each([false, true])('detects a change from %s before its subscription starts', (initiallyFocused) => {
49
+ const store = createNavigation(initiallyFocused);
50
+ navigation = store;
51
+ function CommitChange({ children }: { readonly children: ReactNode }) {
52
+ useLayoutEffect(() => {
53
+ expect(store.listenerCount()).toBe(0);
54
+ store.setFocused(!initiallyFocused);
55
+ }, []);
56
+ return children;
57
+ }
58
+ const { result } = renderHook(useIsFocusedSafely, { wrapper: CommitChange });
59
+ expect(result.current).toBe(!initiallyFocused);
60
+ });
61
+
62
+ it('reads the current snapshot for focus and blur notifications', () => {
63
+ const store = createNavigation(false);
64
+ navigation = store;
65
+ const { result } = renderHook(useIsFocusedSafely);
66
+ act(() => store.setFocused(true));
67
+ expect(result.current).toBe(true);
68
+ act(() => store.setFocused(false));
69
+ expect(result.current).toBe(false);
70
+ });
71
+
72
+ it('replaces subscriptions when navigation changes and removes them on unmount', () => {
73
+ const first = createNavigation(true);
74
+ const second = createNavigation(false);
75
+ navigation = first;
76
+ const { result, rerender, unmount } = renderHook(useIsFocusedSafely);
77
+ expect(first.listenerCount()).toBe(2);
78
+ navigation = second;
79
+ rerender();
80
+ expect(first.listenerCount()).toBe(0);
81
+ expect(second.listenerCount()).toBe(2);
82
+ expect(result.current).toBe(false);
83
+ act(() => first.setFocused(false));
84
+ expect(result.current).toBe(false);
85
+ act(() => second.setFocused(true));
86
+ expect(result.current).toBe(true);
87
+ unmount();
88
+ expect(second.listenerCount()).toBe(0);
89
+ });
90
+
91
+ it('restores the fallback when navigation is removed', () => {
92
+ const store = createNavigation(false);
93
+ navigation = store;
94
+ const { result, rerender } = renderHook(useIsFocusedSafely);
95
+ navigation = undefined;
96
+ rerender();
97
+ expect(result.current).toBe(true);
98
+ expect(store.listenerCount()).toBe(0);
99
+ });
100
+
101
+ it.each([false, true])('follows committed focus snapshots for visibility effects (initially %s)', (initiallyFocused) => {
102
+ const store = createNavigation(initiallyFocused);
103
+ navigation = store;
104
+ const dispose = vi.fn();
105
+ const effect = vi.fn(() => dispose);
106
+ function Consumer() {
107
+ useVisibilityEffect(effect);
108
+ return null;
109
+ }
110
+ function CommitChange() {
111
+ useLayoutEffect(() => store.setFocused(!initiallyFocused), []);
112
+ return null;
113
+ }
114
+ const { unmount } = render(
115
+ <AppStateProvider>
116
+ <VisibilityChangedProvider isVisible={true}>
117
+ <Consumer />
118
+ <CommitChange />
119
+ </VisibilityChangedProvider>
120
+ </AppStateProvider>
121
+ );
122
+ expect(effect).toHaveBeenCalledTimes(1);
123
+ expect(dispose).toHaveBeenCalledTimes(initiallyFocused ? 1 : 0);
124
+ unmount();
125
+ expect(dispose).toHaveBeenCalledTimes(1);
126
+ expect(store.listenerCount()).toBe(0);
127
+ });
128
+
129
+ it.each(['mount', 'callback replacement'] as const)(
130
+ 'keeps the committed visibility effect active across rapid refocus during %s',
131
+ (scenario) => {
132
+ const store = createNavigation(true);
133
+ navigation = store;
134
+ const firstDispose = vi.fn();
135
+ const firstEffect = vi.fn(() => firstDispose);
136
+ const dispose = vi.fn();
137
+ const effect = vi.fn(() => dispose);
138
+ function Consumer({ callback }: { readonly callback: EffectCallback }) {
139
+ useVisibilityEffect(callback);
140
+ return null;
141
+ }
142
+ function CommitChange({ changeFocus }: { readonly changeFocus: boolean }) {
143
+ useLayoutEffect(() => {
144
+ if (changeFocus) {
145
+ store.setFocused(false);
146
+ }
147
+ }, [changeFocus]);
148
+ useEffect(() => {
149
+ if (changeFocus) {
150
+ expect(effect).toHaveBeenCalledTimes(1);
151
+ store.setFocused(true);
152
+ // Refocusing must not restart the effect from the visible commit.
153
+ expect(effect).toHaveBeenCalledTimes(1);
154
+ expect(dispose).not.toHaveBeenCalled();
155
+ }
156
+ }, [changeFocus]);
157
+ return null;
158
+ }
159
+ const screen = (changeFocus: boolean) => (
160
+ <AppStateProvider>
161
+ <VisibilityChangedProvider isVisible={true}>
162
+ <Consumer callback={changeFocus ? effect : firstEffect} />
163
+ <CommitChange changeFocus={changeFocus} />
164
+ </VisibilityChangedProvider>
165
+ </AppStateProvider>
166
+ );
167
+ const { rerender, unmount } = render(screen(scenario === 'mount'));
168
+ rerender(screen(true));
169
+ expect(effect).toHaveBeenCalledTimes(1);
170
+ expect(firstDispose).toHaveBeenCalledTimes(scenario === 'mount' ? 0 : 1);
171
+ expect(store.listenerCount()).toBe(2);
172
+
173
+ rerender(screen(true));
174
+ expect(effect).toHaveBeenCalledTimes(1);
175
+ expect(dispose).not.toHaveBeenCalled();
176
+
177
+ unmount();
178
+ expect(dispose).toHaveBeenCalledTimes(1);
179
+ expect(store.listenerCount()).toBe(0);
180
+ }
181
+ );
182
+
183
+ it('cleans up and restarts visibility effects as focus snapshots are committed', () => {
184
+ const store = createNavigation(true);
185
+ navigation = store;
186
+ const dispose = vi.fn();
187
+ const effect = vi.fn(() => dispose);
188
+ function Consumer() {
189
+ useVisibilityEffect(effect);
190
+ return null;
191
+ }
192
+ function CommitChange() {
193
+ useLayoutEffect(() => store.setFocused(false), []);
194
+ return null;
195
+ }
196
+ const { unmount } = render(
197
+ <AppStateProvider>
198
+ <VisibilityChangedProvider isVisible={true}>
199
+ <Consumer />
200
+ <CommitChange />
201
+ </VisibilityChangedProvider>
202
+ </AppStateProvider>
203
+ );
204
+ expect(effect).toHaveBeenCalledTimes(1);
205
+ expect(dispose).toHaveBeenCalledTimes(1);
206
+ expect(store.listenerCount()).toBe(2);
207
+
208
+ act(() => store.setFocused(true));
209
+ expect(effect).toHaveBeenCalledTimes(2);
210
+ act(() => store.setFocused(false));
211
+ expect(dispose).toHaveBeenCalledTimes(2);
212
+ unmount();
213
+ expect(dispose).toHaveBeenCalledTimes(2);
214
+ expect(store.listenerCount()).toBe(0);
215
+ });
216
+ });
@@ -1,4 +1,4 @@
1
- import { useDebugValue, useEffect, useState } from 'react';
1
+ import { useCallback, useDebugValue, useSyncExternalStore } from 'react';
2
2
  import { useNavigationSafely } from './useNavigationSafely';
3
3
 
4
4
  /**
@@ -12,6 +12,7 @@ import { useNavigationSafely } from './useNavigationSafely';
12
12
  * A Hook that safely uses `useIsFocused` provided by `@react-navigation/native`.
13
13
  * This Hook is based on `useIsFocused` from `@react-navigation/native`, but modified to not throw errors when `navigation` or `root` objects are `null` or `undefined`.
14
14
  * It ensures that users don't see errors even when the code is used in environments where `@react-navigation/native` is not used.
15
+ * Focus is read as an external store snapshot so changes between rendering and subscription are observed.
15
16
  *
16
17
  * @returns {boolean} - Returns the focus state of the current screen.
17
18
  * @example
@@ -22,37 +23,22 @@ import { useNavigationSafely } from './useNavigationSafely';
22
23
  */
23
24
  export function useIsFocusedSafely(): boolean {
24
25
  const navigation = useNavigationSafely();
25
- const isNavigationFocused = () => navigation?.isFocused() ?? true;
26
-
27
- const [isFocused, setIsFocused] = useState(isNavigationFocused());
28
-
29
- const valueToReturn = isNavigationFocused();
30
-
31
- if (isFocused !== valueToReturn) {
32
- // If the value has changed since the last render, we need to update it.
33
- // This could happen if we missed an update from the event listeners during re-render.
34
- // React will process this update immediately, so the old subscription value won't be committed.
35
- // It is still nice to avoid returning a mismatched value though, so let's override the return value.
36
- // This is the same logic as in https://github.com/facebook/react/tree/master/packages/use-subscription
37
- setIsFocused(valueToReturn);
38
- }
39
-
40
- useEffect(() => {
41
- if (navigation == null) {
42
- return;
43
- }
44
-
45
- const unsubscribeFocus = navigation.addListener('focus', () => setIsFocused(true));
46
-
47
- const unsubscribeBlur = navigation.addListener('blur', () => setIsFocused(false));
48
-
49
- return () => {
50
- unsubscribeFocus();
51
- unsubscribeBlur();
52
- };
53
- }, [navigation]);
54
-
55
- useDebugValue(valueToReturn);
56
-
57
- return valueToReturn;
26
+ const getSnapshot = useCallback(() => navigation?.isFocused() ?? true, [navigation]);
27
+ const subscribe = useCallback(
28
+ (onStoreChange: () => void) => {
29
+ const unsubscribeFocus = navigation?.addListener('focus', onStoreChange);
30
+ const unsubscribeBlur = navigation?.addListener('blur', onStoreChange);
31
+
32
+ return () => {
33
+ unsubscribeFocus?.();
34
+ unsubscribeBlur?.();
35
+ };
36
+ },
37
+ [navigation]
38
+ );
39
+
40
+ const isFocused = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
41
+ useDebugValue(isFocused);
42
+
43
+ return isFocused;
58
44
  }
@@ -20,6 +20,7 @@ export type VisibilityCallback = (state: VisibilityState) => void;
20
20
  * @public
21
21
  * @category Screen Control
22
22
  * @name useVisibilityChange
23
+ * @deprecated Use `useVisibilityEffect` for effects with cleanup, or `useVisibility` with React's `useEffect` to handle visibility changes.
23
24
  * @kind function
24
25
  * @description
25
26
  * Calls a callback function with the visibility state when the screen's visibility changes.
@@ -67,4 +68,3 @@ export function useVisibilityChange(callback: VisibilityCallback) {
67
68
  callback(isVisible ? 'visible' : 'hidden');
68
69
  }, [callback, isVisible, prevValue]);
69
70
  }
70
-
@@ -0,0 +1,160 @@
1
+ import { cleanup, render } from '@testing-library/react';
2
+ import { type EffectCallback, type ReactNode } from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { AppStateProvider } from './useIsAppForeground';
5
+ import { VisibilityChangedProvider } from './useVisibilityChanged';
6
+ import * as visibility from './useVisibilityEffect';
7
+
8
+ const navigation = vi.hoisted(() => ({ focused: true }));
9
+ vi.mock('./react-navigation/useIsFocusedSafely', () => ({
10
+ useIsFocusedSafely: () => navigation.focused,
11
+ }));
12
+
13
+ function Consumer({ effect }: { readonly effect: EffectCallback }) {
14
+ visibility.useVisibilityEffect(effect);
15
+ return null;
16
+ }
17
+
18
+ function Boundary({ visible, children }: { readonly visible: boolean; readonly children: ReactNode }) {
19
+ return (
20
+ <AppStateProvider>
21
+ <VisibilityChangedProvider isVisible={visible}>{children}</VisibilityChangedProvider>
22
+ </AppStateProvider>
23
+ );
24
+ }
25
+
26
+ function screen(visible: boolean, effect: EffectCallback) {
27
+ return (
28
+ <Boundary visible={visible}>
29
+ <Consumer effect={effect} />
30
+ </Boundary>
31
+ );
32
+ }
33
+
34
+ afterEach(() => {
35
+ cleanup();
36
+ navigation.focused = true;
37
+ });
38
+
39
+ describe('useVisibilityEffect', () => {
40
+ it('exports a visibility effect hook', () => {
41
+ expect(visibility).toHaveProperty('useVisibilityEffect', expect.any(Function));
42
+ });
43
+
44
+ it('does not set up or clean up an effect when mounted and unmounted hidden', () => {
45
+ const dispose = vi.fn();
46
+ const effect = vi.fn(() => dispose);
47
+ const { unmount } = render(screen(false, effect));
48
+ unmount();
49
+ expect(effect).not.toHaveBeenCalled();
50
+ expect(dispose).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it('sets up on visible mount and cleans up on direct unmount', () => {
54
+ const dispose = vi.fn();
55
+ const effect = vi.fn(() => dispose);
56
+ const { unmount } = render(screen(true, effect));
57
+ expect(effect).toHaveBeenCalledTimes(1);
58
+ unmount();
59
+ expect(dispose).toHaveBeenCalledTimes(1);
60
+ });
61
+
62
+ it('cleans up when hidden and sets up again when visible', () => {
63
+ const dispose = vi.fn();
64
+ const effect = vi.fn(() => dispose);
65
+ const { rerender, unmount } = render(screen(true, effect));
66
+ rerender(screen(false, effect));
67
+ expect(dispose).toHaveBeenCalledTimes(1);
68
+ rerender(screen(true, effect));
69
+ expect(effect).toHaveBeenCalledTimes(2);
70
+ unmount();
71
+ expect(dispose).toHaveBeenCalledTimes(2);
72
+ });
73
+
74
+ it('does not clean up again when unmounted after becoming hidden', () => {
75
+ const dispose = vi.fn();
76
+ const { rerender, unmount } = render(screen(true, () => dispose));
77
+ rerender(screen(false, () => dispose));
78
+ unmount();
79
+ expect(dispose).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ it('keeps a stable effect running across visible rerenders', () => {
83
+ const dispose = vi.fn();
84
+ const effect = vi.fn(() => dispose);
85
+ const { rerender } = render(screen(true, effect));
86
+ rerender(screen(true, effect));
87
+ expect(effect).toHaveBeenCalledTimes(1);
88
+ expect(dispose).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it('cleans up the previous callback before running its replacement while visible', () => {
92
+ const events: string[] = [];
93
+ const first = () => {
94
+ events.push('first setup');
95
+ return () => {
96
+ events.push('first cleanup');
97
+ };
98
+ };
99
+ const second = () => {
100
+ events.push('second setup');
101
+ };
102
+ const { rerender } = render(screen(true, first));
103
+ rerender(screen(true, second));
104
+ expect(events).toEqual(['first setup', 'first cleanup', 'second setup']);
105
+ });
106
+
107
+ it('uses the latest callback when it becomes visible after a hidden update', () => {
108
+ const first = vi.fn();
109
+ const second = vi.fn();
110
+ const { rerender } = render(screen(false, first));
111
+ rerender(screen(false, second));
112
+ expect(first).not.toHaveBeenCalled();
113
+ expect(second).not.toHaveBeenCalled();
114
+ rerender(screen(true, second));
115
+ expect(first).not.toHaveBeenCalled();
116
+ expect(second).toHaveBeenCalledTimes(1);
117
+ });
118
+
119
+ it('allows an effect without a cleanup function', () => {
120
+ const effect = vi.fn(() => {});
121
+ const { rerender, unmount } = render(screen(true, effect));
122
+ rerender(screen(false, effect));
123
+ unmount();
124
+ expect(effect).toHaveBeenCalledTimes(1);
125
+ });
126
+
127
+ it('cleans up when navigator focus is lost while native visibility stays true', () => {
128
+ const dispose = vi.fn();
129
+ const effect = vi.fn(() => dispose);
130
+ const { rerender } = render(screen(true, effect));
131
+ navigation.focused = false;
132
+ rerender(screen(true, effect));
133
+ expect(dispose).toHaveBeenCalledTimes(1);
134
+ navigation.focused = true;
135
+ rerender(screen(true, effect));
136
+ expect(effect).toHaveBeenCalledTimes(2);
137
+ });
138
+
139
+ it('keeps sibling visibility boundaries independent', () => {
140
+ const firstDispose = vi.fn();
141
+ const secondDispose = vi.fn();
142
+ const first = vi.fn(() => firstDispose);
143
+ const second = vi.fn(() => secondDispose);
144
+ const tree = (firstVisible: boolean) => (
145
+ <>
146
+ <Boundary visible={firstVisible}>
147
+ <Consumer effect={first} />
148
+ </Boundary>
149
+ <Boundary visible={true}>
150
+ <Consumer effect={second} />
151
+ </Boundary>
152
+ </>
153
+ );
154
+ const { rerender } = render(tree(true));
155
+ rerender(tree(false));
156
+ expect(firstDispose).toHaveBeenCalledTimes(1);
157
+ expect(secondDispose).not.toHaveBeenCalled();
158
+ expect(second).toHaveBeenCalledTimes(1);
159
+ });
160
+ });
@@ -0,0 +1,46 @@
1
+ import { type EffectCallback, useEffect } from 'react';
2
+ import { useVisibility } from './useVisibility';
3
+
4
+ /**
5
+ * @public
6
+ * @category Screen Control
7
+ * @name useVisibilityEffect
8
+ * @description
9
+ * Runs an effect while the screen is visible, using the visibility state from `useVisibility`.
10
+ * Cleans up when the screen becomes hidden, the effect callback changes, or the component unmounts.
11
+ * Like `useEffect`, setup and cleanup run after a render is committed. The callback must be synchronous
12
+ * and may return a cleanup function. Use `useCallback` to avoid restarting on unrelated renders.
13
+ * Effects follow the visibility snapshot from the committed render; later focus changes apply on a subsequent commit.
14
+ * Unlike `useVisibilityChange`, a returned cleanup function is registered with React.
15
+ * Effect ordering between different screens is not guaranteed; navigation events do not invoke this callback synchronously.
16
+ *
17
+ * @param {EffectCallback} effect - Sets up the effect and optionally returns its cleanup function.
18
+ * @example
19
+ * ```tsx
20
+ * import { useCallback } from 'react';
21
+ * import { BackHandler } from 'react-native';
22
+ * import { useVisibilityEffect } from '@granite-js/react-native';
23
+ *
24
+ * function Screen({ onBack }: { onBack: () => boolean }) {
25
+ * useVisibilityEffect(
26
+ * useCallback(() => {
27
+ * const subscription = BackHandler.addEventListener('hardwareBackPress', onBack);
28
+ * return () => subscription.remove();
29
+ * }, [onBack])
30
+ * );
31
+ *
32
+ * return null;
33
+ * }
34
+ * ```
35
+ */
36
+ export function useVisibilityEffect(effect: EffectCallback): void {
37
+ const isVisible = useVisibility();
38
+
39
+ useEffect(() => {
40
+ if (!isVisible) {
41
+ return;
42
+ }
43
+
44
+ return effect();
45
+ }, [effect, isVisible]);
46
+ }