@granite-js/react-native 2.4.0 → 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 CHANGED
@@ -1,5 +1,24 @@
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
+
3
22
  ## 2.4.0
4
23
 
5
24
  ### 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.4.0",
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.4.0",
95
- "@granite-js/vitest": "2.4.0",
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.4.0",
125
- "@granite-js/brownfield-module": "2.4.0",
126
- "@granite-js/cli": "2.4.0",
127
- "@granite-js/jest": "2.4.0",
128
- "@granite-js/mpack": "2.4.0",
129
- "@granite-js/plugin-core": "2.4.0",
130
- "@granite-js/style-utils": "2.4.0",
131
- "@granite-js/video": "2.4.0",
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"
@@ -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
+ }