@granite-js/react-native 2.3.2 → 2.5.0
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 +32 -0
- package/dist/visibility/index.d.ts +1 -0
- package/dist/visibility/useVisibilityChange.d.ts +1 -0
- package/dist/visibility/useVisibilityEffect.d.ts +32 -0
- package/package.json +11 -11
- package/src/visibility/index.ts +2 -1
- package/src/visibility/useVisibilityChange.ts +1 -1
- package/src/visibility/useVisibilityEffect.spec.tsx +160 -0
- package/src/visibility/useVisibilityEffect.ts +44 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# @granite-js/react-native
|
|
2
2
|
|
|
3
|
+
## 2.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f9086dc: 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.
|
|
8
|
+
|
|
9
|
+
Deprecate `useVisibilityChange` without changing its behavior. Use `useVisibilityEffect` for effects with cleanup, or `useVisibility` with React's `useEffect` for explicit visibility change handling.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- @granite-js/blur-view@2.5.0
|
|
14
|
+
- @granite-js/brownfield-module@2.5.0
|
|
15
|
+
- @granite-js/cli@2.5.0
|
|
16
|
+
- @granite-js/jest@2.5.0
|
|
17
|
+
- @granite-js/mpack@2.5.0
|
|
18
|
+
- @granite-js/plugin-core@2.5.0
|
|
19
|
+
- @granite-js/style-utils@2.5.0
|
|
20
|
+
- @granite-js/video@2.5.0
|
|
21
|
+
|
|
22
|
+
## 2.4.0
|
|
23
|
+
|
|
24
|
+
### Patch Changes
|
|
25
|
+
|
|
26
|
+
- @granite-js/blur-view@2.4.0
|
|
27
|
+
- @granite-js/brownfield-module@2.4.0
|
|
28
|
+
- @granite-js/cli@2.4.0
|
|
29
|
+
- @granite-js/jest@2.4.0
|
|
30
|
+
- @granite-js/mpack@2.4.0
|
|
31
|
+
- @granite-js/plugin-core@2.4.0
|
|
32
|
+
- @granite-js/style-utils@2.4.0
|
|
33
|
+
- @granite-js/video@2.4.0
|
|
34
|
+
|
|
3
35
|
## 2.3.2
|
|
4
36
|
|
|
5
37
|
### 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';
|
|
@@ -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,32 @@
|
|
|
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
|
+
* Unlike `useVisibilityChange`, a returned cleanup function is registered with React.
|
|
12
|
+
*
|
|
13
|
+
* @param {EffectCallback} effect - Sets up the effect and optionally returns its cleanup function.
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* import { useCallback } from 'react';
|
|
17
|
+
* import { BackHandler } from 'react-native';
|
|
18
|
+
* import { useVisibilityEffect } from '@granite-js/react-native';
|
|
19
|
+
*
|
|
20
|
+
* function Screen({ onBack }: { onBack: () => boolean }) {
|
|
21
|
+
* useVisibilityEffect(
|
|
22
|
+
* useCallback(() => {
|
|
23
|
+
* const subscription = BackHandler.addEventListener('hardwareBackPress', onBack);
|
|
24
|
+
* return () => subscription.remove();
|
|
25
|
+
* }, [onBack])
|
|
26
|
+
* );
|
|
27
|
+
*
|
|
28
|
+
* return null;
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
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": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
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": "2.
|
|
95
|
-
"@granite-js/vitest": "2.
|
|
94
|
+
"@granite-js/native": "2.5.0",
|
|
95
|
+
"@granite-js/vitest": "2.5.0",
|
|
96
96
|
"@testing-library/dom": "^10.4.0",
|
|
97
97
|
"@testing-library/react": "^16.1.0",
|
|
98
98
|
"@types/babel__core": "^7",
|
|
@@ -121,14 +121,14 @@
|
|
|
121
121
|
"react-native": "*"
|
|
122
122
|
},
|
|
123
123
|
"dependencies": {
|
|
124
|
-
"@granite-js/blur-view": "2.
|
|
125
|
-
"@granite-js/brownfield-module": "2.
|
|
126
|
-
"@granite-js/cli": "2.
|
|
127
|
-
"@granite-js/jest": "2.
|
|
128
|
-
"@granite-js/mpack": "2.
|
|
129
|
-
"@granite-js/plugin-core": "2.
|
|
130
|
-
"@granite-js/style-utils": "2.
|
|
131
|
-
"@granite-js/video": "2.
|
|
124
|
+
"@granite-js/blur-view": "2.5.0",
|
|
125
|
+
"@granite-js/brownfield-module": "2.5.0",
|
|
126
|
+
"@granite-js/cli": "2.5.0",
|
|
127
|
+
"@granite-js/jest": "2.5.0",
|
|
128
|
+
"@granite-js/mpack": "2.5.0",
|
|
129
|
+
"@granite-js/plugin-core": "2.5.0",
|
|
130
|
+
"@granite-js/style-utils": "2.5.0",
|
|
131
|
+
"@granite-js/video": "2.5.0",
|
|
132
132
|
"@standard-schema/spec": "^1.0.0",
|
|
133
133
|
"es-toolkit": "^1.39.8",
|
|
134
134
|
"react-native-url-polyfill": "3.0.0"
|
package/src/visibility/index.ts
CHANGED
|
@@ -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';
|
|
@@ -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,44 @@
|
|
|
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
|
+
* Unlike `useVisibilityChange`, a returned cleanup function is registered with React.
|
|
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 function useVisibilityEffect(effect: EffectCallback): void {
|
|
35
|
+
const isVisible = useVisibility();
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!isVisible) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return effect();
|
|
43
|
+
}, [effect, isVisible]);
|
|
44
|
+
}
|