@granite-js/react-native 2.5.0 → 2.5.1

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,21 @@
1
1
  # @granite-js/react-native
2
2
 
3
+ ## 2.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - a686133: 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.
8
+
9
+ Visibility effects follow committed visibility snapshots with React's effect setup and cleanup lifecycle.
10
+ - @granite-js/blur-view@2.5.1
11
+ - @granite-js/brownfield-module@2.5.1
12
+ - @granite-js/cli@2.5.1
13
+ - @granite-js/jest@2.5.1
14
+ - @granite-js/mpack@2.5.1
15
+ - @granite-js/plugin-core@2.5.1
16
+ - @granite-js/style-utils@2.5.1
17
+ - @granite-js/video@2.5.1
18
+
3
19
  ## 2.5.0
4
20
 
5
21
  ### Minor Changes
@@ -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
@@ -8,7 +8,9 @@ import { type EffectCallback } from 'react';
8
8
  * Cleans up when the screen becomes hidden, the effect callback changes, or the component unmounts.
9
9
  * Like `useEffect`, setup and cleanup run after a render is committed. The callback must be synchronous
10
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.
11
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.
12
14
  *
13
15
  * @param {EffectCallback} effect - Sets up the effect and optionally returns its cleanup function.
14
16
  * @example
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@granite-js/react-native",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
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.5.0",
95
- "@granite-js/vitest": "2.5.0",
94
+ "@granite-js/native": "2.5.1",
95
+ "@granite-js/vitest": "2.5.1",
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.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",
124
+ "@granite-js/blur-view": "2.5.1",
125
+ "@granite-js/brownfield-module": "2.5.1",
126
+ "@granite-js/cli": "2.5.1",
127
+ "@granite-js/jest": "2.5.1",
128
+ "@granite-js/mpack": "2.5.1",
129
+ "@granite-js/plugin-core": "2.5.1",
130
+ "@granite-js/style-utils": "2.5.1",
131
+ "@granite-js/video": "2.5.1",
132
132
  "@standard-schema/spec": "^1.0.0",
133
133
  "es-toolkit": "^1.39.8",
134
134
  "react-native-url-polyfill": "3.0.0"
@@ -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
  }
@@ -10,7 +10,9 @@ import { useVisibility } from './useVisibility';
10
10
  * Cleans up when the screen becomes hidden, the effect callback changes, or the component unmounts.
11
11
  * Like `useEffect`, setup and cleanup run after a render is committed. The callback must be synchronous
12
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.
13
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.
14
16
  *
15
17
  * @param {EffectCallback} effect - Sets up the effect and optionally returns its cleanup function.
16
18
  * @example