@applicaster/zapp-react-native-utils 16.0.0-alpha.1929433645 → 16.0.0-alpha.2105365027

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.
Files changed (46) hide show
  1. package/actionUtils/index.ts +3 -1
  2. package/actionsExecutor/actions/__tests__/navigateToScreen.test.ts +139 -5
  3. package/actionsExecutor/actions/__tests__/openBottomSheet.customContent.test.ts +24 -0
  4. package/actionsExecutor/actions/navigateToScreen.ts +29 -45
  5. package/actionsExecutor/actions/openBottomSheet.ts +1 -0
  6. package/appUtils/playerManager/__tests__/playerNative.test.ts +76 -0
  7. package/appUtils/playerManager/__tests__/playerNativeCommand.test.ts +62 -0
  8. package/appUtils/playerManager/__tests__/playerToolbox.test.ts +97 -0
  9. package/appUtils/playerManager/__tests__/usePlayerControllerSetup.test.tsx +62 -0
  10. package/appUtils/playerManager/index.ts +7 -0
  11. package/appUtils/playerManager/player.ts +1 -0
  12. package/appUtils/playerManager/playerNative.ts +22 -0
  13. package/appUtils/playerManager/playerNativeCommand.ts +2 -6
  14. package/appUtils/playerManager/playerToolbox.ts +157 -0
  15. package/appUtils/playerManager/usePlayerControllerSetup.tsx +6 -0
  16. package/arrayUtils/__tests__/reorderByIds.test.ts +13 -0
  17. package/arrayUtils/index.ts +26 -43
  18. package/cellUtils/__tests__/getButtonsEnabled.test.ts +28 -0
  19. package/cellUtils/__tests__/getCellNavigationEnabled.test.ts +36 -0
  20. package/cellUtils/__tests__/getIsCellNavigationEnabled.test.ts +35 -0
  21. package/cellUtils/__tests__/isCellNavigationEnabled.test.ts +21 -0
  22. package/cellUtils/index.ts +74 -19
  23. package/configurationUtils/__tests__/modalBlocksParser.test.ts +123 -0
  24. package/configurationUtils/index.ts +77 -73
  25. package/configurationUtils/modalBlocksParser.ts +38 -0
  26. package/focusManager/FocusManager.ts +26 -0
  27. package/focusManager/__tests__/FocusManager.test.ts +9 -0
  28. package/localizationUtils/index.ts +12 -6
  29. package/logger/__tests__/xrayToolbox.test.ts +69 -0
  30. package/logger/xrayToolbox.ts +67 -0
  31. package/manifestUtils/defaultManifestConfigurations/player.js +8 -0
  32. package/manifestUtils/keys.js +6 -1
  33. package/manifestUtils/mobileAction/container/__tests__/mobileActionButtonsContainer.test.js +21 -0
  34. package/manifestUtils/mobileAction/groups/__tests__/buildMobileActionButtonGroups.test.js +1 -1
  35. package/manifestUtils/mobileAction/groups/defaults.js +2 -1
  36. package/navigationUtils/xrayNavigator.ts +12 -0
  37. package/package.json +2 -2
  38. package/perfTracker.ts +203 -0
  39. package/reactHooks/__tests__/getPluginConfiguration.test.ts +82 -0
  40. package/reactHooks/connection/index.tsx +8 -5
  41. package/reactHooks/feed/__tests__/useMarkPipesDataStale.test.tsx +243 -0
  42. package/reactHooks/feed/useMarkPipesDataStale.ts +68 -23
  43. package/reactHooks/usePluginConfiguration.ts +84 -46
  44. package/riverComponetsMeasurementProvider/index.tsx +8 -1
  45. package/uiActionsRegistrator/__tests__/subscribe.test.ts +26 -0
  46. package/uiActionsRegistrator/registry.ts +16 -0
@@ -1,5 +1,6 @@
1
1
  const DEFAULT_MOBILE_ACTION_BUTTONS_CONTAINER_DEFAULTS = {
2
2
  buttonsEnabled: false,
3
+ cellNavigationEnabled: false,
3
4
 
4
5
  align: "left",
5
6
  overImagePosition: "bottom_left",
@@ -63,7 +64,7 @@ const DEFAULT_MOBILE_ACTION_BUTTON_PRESETS = {
63
64
  },
64
65
  2: {
65
66
  buttonEnabled: false,
66
- assignAction: "secondary_navigation",
67
+ assignAction: "trailer_action",
67
68
  },
68
69
  3: {
69
70
  buttonEnabled: false,
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The app navigator for the X-Ray toolboxes (nav, screen), which run outside React. The
3
+ * NavigationProvider sets it after each commit; it is a plain module variable, so updating it
4
+ * never renders anything.
5
+ */
6
+ let navigator: QuickBrickAppNavigator | undefined;
7
+
8
+ export const setXrayNavigator = (value: QuickBrickAppNavigator | undefined) => {
9
+ navigator = value;
10
+ };
11
+
12
+ export const getXrayNavigator = () => navigator;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-utils",
3
- "version": "16.0.0-alpha.1929433645",
3
+ "version": "16.0.0-alpha.2105365027",
4
4
  "description": "Applicaster Zapp React Native utilities package",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "homepage": "https://github.com/applicaster/quickbrick#readme",
29
29
  "dependencies": {
30
- "@applicaster/applicaster-types": "16.0.0-alpha.1929433645",
30
+ "@applicaster/applicaster-types": "16.0.0-alpha.2105365027",
31
31
  "buffer": "^5.2.1",
32
32
  "camelize": "^1.0.0",
33
33
  "dayjs": "^1.11.10",
package/perfTracker.ts ADDED
@@ -0,0 +1,203 @@
1
+ import { createLogger } from "./logger";
2
+ import { NativeModules } from "react-native";
3
+
4
+ const { ProfilerBridge } = NativeModules;
5
+
6
+ const loggerObj = createLogger({
7
+ category: "PerfTracker",
8
+ subsystem: "PerfTelemetry",
9
+ });
10
+
11
+ const renderCounts: Record<string, number> = {};
12
+ const cellRenderCounts: Record<string, number> = {};
13
+
14
+ const timingStats: Record<
15
+ string,
16
+ { totalMs: number; count: number; maxMs: number; minMs: number }
17
+ > = {};
18
+
19
+ const t0 = Date.now();
20
+
21
+ export function getDelta(): number {
22
+ return Date.now() - t0;
23
+ }
24
+
25
+ export function recordTiming(
26
+ name: string,
27
+ durationMs: number,
28
+ details?: Record<string, any>
29
+ ): void {
30
+ const rounded = Math.round(durationMs * 100) / 100;
31
+
32
+ if (!timingStats[name]) {
33
+ timingStats[name] = {
34
+ totalMs: rounded,
35
+ count: 1,
36
+ maxMs: rounded,
37
+ minMs: rounded,
38
+ };
39
+ } else {
40
+ const s = timingStats[name];
41
+ s.totalMs = Math.round((s.totalMs + rounded) * 100) / 100;
42
+ s.count += 1;
43
+ if (rounded > s.maxMs) s.maxMs = rounded;
44
+ if (rounded < s.minMs) s.minMs = rounded;
45
+ }
46
+
47
+ const stat = timingStats[name];
48
+ const avg = Math.round((stat.totalMs / stat.count) * 100) / 100;
49
+ const delta = getDelta();
50
+
51
+ if (ProfilerBridge?.updateTimer) {
52
+ try {
53
+ ProfilerBridge.updateTimer("timings", name, rounded);
54
+ } catch (e) {}
55
+ }
56
+
57
+ if (stat.count <= 3 || rounded >= 3 || stat.count % 10 === 0) {
58
+ const msg = `[PERF TIMING #${stat.count}] ${name}: ${rounded}ms (avg: ${avg}ms, max: ${stat.maxMs}ms, total: ${stat.totalMs}ms) (+${delta}ms)`;
59
+
60
+ loggerObj.log_info(msg, {
61
+ timingName: name,
62
+ durationMs: rounded,
63
+ avgMs: avg,
64
+ maxMs: stat.maxMs,
65
+ minMs: stat.minMs,
66
+ totalMs: stat.totalMs,
67
+ count: stat.count,
68
+ deltaMs: delta,
69
+ ...(details || {}),
70
+ });
71
+ }
72
+ }
73
+
74
+ export function measureSync<T>(
75
+ name: string,
76
+ fn: () => T,
77
+ details?: Record<string, any>
78
+ ): T {
79
+ const start =
80
+ typeof performance !== "undefined" && performance.now
81
+ ? performance.now()
82
+ : Date.now();
83
+
84
+ try {
85
+ return fn();
86
+ } finally {
87
+ const end =
88
+ typeof performance !== "undefined" && performance.now
89
+ ? performance.now()
90
+ : Date.now();
91
+
92
+ recordTiming(name, end - start, details);
93
+ }
94
+ }
95
+
96
+ export function startTimer(
97
+ name: string,
98
+ details?: Record<string, any>
99
+ ): (additionalDetails?: Record<string, any>) => number {
100
+ const start =
101
+ typeof performance !== "undefined" && performance.now
102
+ ? performance.now()
103
+ : Date.now();
104
+
105
+ return (additionalDetails?: Record<string, any>) => {
106
+ const end =
107
+ typeof performance !== "undefined" && performance.now
108
+ ? performance.now()
109
+ : Date.now();
110
+
111
+ const duration = end - start;
112
+
113
+ recordTiming(name, duration, {
114
+ ...(details || {}),
115
+ ...(additionalDetails || {}),
116
+ });
117
+
118
+ return duration;
119
+ };
120
+ }
121
+
122
+ export function countRender(
123
+ componentName: string,
124
+ details?: Record<string, any>
125
+ ): number {
126
+ renderCounts[componentName] = (renderCounts[componentName] || 0) + 1;
127
+ const count = renderCounts[componentName];
128
+ const delta = getDelta();
129
+
130
+ if (ProfilerBridge?.updateCounter) {
131
+ try {
132
+ ProfilerBridge.updateCounter("render_count", componentName, count);
133
+ } catch (e) {}
134
+ }
135
+
136
+ const msg = `[PERF RENDER #${count}] ${componentName} (+${delta}ms)`;
137
+
138
+ loggerObj.log_info(msg, {
139
+ component: componentName,
140
+ count,
141
+ deltaMs: delta,
142
+ ...(details || {}),
143
+ });
144
+
145
+ return count;
146
+ }
147
+
148
+ export function countCellRender(
149
+ cellType: string,
150
+ itemId: string | number,
151
+ details?: Record<string, any>
152
+ ): number {
153
+ const key = `${cellType}:${itemId || "unknown"}`;
154
+ cellRenderCounts[key] = (cellRenderCounts[key] || 0) + 1;
155
+ const count = cellRenderCounts[key];
156
+
157
+ renderCounts[cellType] = (renderCounts[cellType] || 0) + 1;
158
+ const totalForType = renderCounts[cellType];
159
+ const delta = getDelta();
160
+
161
+ if (ProfilerBridge?.updateCounter) {
162
+ try {
163
+ ProfilerBridge.updateCounter("cell_render_count", cellType, totalForType);
164
+ } catch (e) {}
165
+ }
166
+
167
+ if (count > 1 || totalForType <= 12 || totalForType % 10 === 0) {
168
+ const msg = `[PERF CELL ${
169
+ count > 1 ? "RE-RENDER" : "RENDER"
170
+ } #${count}] ${cellType} item:${itemId} (total ${cellType}: #${totalForType}) (+${delta}ms)`;
171
+
172
+ loggerObj.log_info(msg, {
173
+ cellType,
174
+ itemId,
175
+ itemRenderCount: count,
176
+ totalForCellType: totalForType,
177
+ deltaMs: delta,
178
+ ...(details || {}),
179
+ });
180
+ }
181
+
182
+ return count;
183
+ }
184
+
185
+ export function dumpSummary(checkpoint: string): void {
186
+ const delta = getDelta();
187
+ let totalRenders = 0;
188
+
189
+ for (const k in renderCounts) {
190
+ totalRenders += renderCounts[k];
191
+ }
192
+
193
+ const summaryMsg = `=== [PERF SUMMARY at ${checkpoint}] total renders: ${totalRenders} (+${delta}ms) ===`;
194
+
195
+ loggerObj.log_info(summaryMsg, {
196
+ checkpoint,
197
+ elapsedMs: delta,
198
+ totalRenders,
199
+ renderCounts,
200
+ cellRenderCounts,
201
+ timingStats,
202
+ });
203
+ }
@@ -0,0 +1,82 @@
1
+ import { renderHook } from "@testing-library/react-native";
2
+
3
+ const plugin = {
4
+ identifier: "cast",
5
+ configuration: {
6
+ title: "Configured",
7
+ localizations: { fr: { close: "Fermer" } },
8
+ },
9
+ };
10
+
11
+ jest.mock("@applicaster/zapp-react-native-redux/hooks", () => ({
12
+ usePlugins: () => [plugin],
13
+ }));
14
+
15
+ jest.mock(
16
+ "@applicaster/zapp-react-native-utils/appUtils/localizationsHelper",
17
+ () => ({ getUILanguage: () => "fr" })
18
+ );
19
+
20
+ import {
21
+ getPluginConfiguration,
22
+ mergeLocalizations,
23
+ usePluginConfiguration,
24
+ } from "../usePluginConfiguration";
25
+
26
+ const manifest = {
27
+ custom_configuration_fields: [
28
+ { key: "title", initial_value: "Default" },
29
+ { key: "color", initial_value: "red" },
30
+ ],
31
+ localizations: {
32
+ fields: [
33
+ { key: "close", initial_value: "Close" },
34
+ { key: "cancel", initial_value: "Cancel" },
35
+ ],
36
+ },
37
+ };
38
+
39
+ it("matches usePluginConfiguration outside React", () => {
40
+ const { result } = renderHook(() => usePluginConfiguration("cast", manifest));
41
+
42
+ expect(getPluginConfiguration(plugin, manifest)).toEqual(result.current);
43
+ });
44
+
45
+ // Expected values are what R.mergeDeepWith((l, r) => (isNil(l) ? r : l)) returned before ramda was removed
46
+ describe("mergeLocalizations", () => {
47
+ const defaults = { en: { close: "Close", cancel: "Cancel" } };
48
+
49
+ it("returns the defaults when nothing is configured", () => {
50
+ expect(mergeLocalizations(undefined, defaults)).toEqual(defaults);
51
+ });
52
+
53
+ it("keeps configured values, fills nil ones and missing keys from defaults", () => {
54
+ expect(
55
+ mergeLocalizations(
56
+ { en: { close: null, extra: "X" }, fr: { close: "Fermer" } },
57
+ defaults
58
+ )
59
+ ).toEqual({
60
+ en: { close: "Close", extra: "X", cancel: "Cancel" },
61
+ fr: { close: "Fermer" },
62
+ });
63
+ });
64
+
65
+ it("does not merge arrays", () => {
66
+ expect(
67
+ mergeLocalizations(
68
+ { en: { close: ["a"] } },
69
+ { en: { close: ["b"], cancel: "C" } }
70
+ )
71
+ ).toEqual({ en: { close: ["a"], cancel: "C" } });
72
+ });
73
+ });
74
+
75
+ it("keeps configured languages first, so the fallback language stays the configured one", () => {
76
+ const merged = mergeLocalizations(
77
+ { he: { close: "סגור" }, en: { close: "Close it" } },
78
+ { en: { close: "Close" } }
79
+ );
80
+
81
+ expect(Object.keys(merged)).toEqual(["he", "en"]);
82
+ });
@@ -4,6 +4,11 @@ import { NetInfoState } from "@react-native-community/netinfo";
4
4
  import { NetworkStatusContext } from "@applicaster/zapp-react-native-ui-components/Contexts/NetworkStatusContext";
5
5
  import { isOnline } from "@applicaster/quick-brick-core/App/NetworkStatusProvider/utils";
6
6
 
7
+ type ConnectionInfo = NetInfoState & {
8
+ deviceStatus: NetStatus;
9
+ stillOfflineAfterResumeCount?: number;
10
+ };
11
+
7
12
  /**
8
13
  * This hook returns connection type from RN NetInfo component or boolean for iConnected if justOnlineStatus flag is set
9
14
  * @param {boolean} justOnlineStatus - flag if to return boolean for isConnected
@@ -12,16 +17,14 @@ import { isOnline } from "@applicaster/quick-brick-core/App/NetworkStatusProvide
12
17
 
13
18
  export function useConnectionInfo(justOnlineStatus: true): boolean;
14
19
 
15
- export function useConnectionInfo(
16
- justOnlineStatus: false
17
- ): NetInfoState & { deviceStatus: NetStatus };
20
+ export function useConnectionInfo(justOnlineStatus: false): ConnectionInfo;
18
21
 
19
22
  export function useConnectionInfo(
20
23
  justOnlineStatus: boolean
21
- ): boolean | (NetInfoState & { deviceStatus: NetStatus }) {
24
+ ): boolean | ConnectionInfo {
22
25
  const connectionInfo = React.useContext(
23
26
  NetworkStatusContext
24
- ) as NetInfoState & { deviceStatus: NetStatus };
27
+ ) as ConnectionInfo;
25
28
 
26
29
  return justOnlineStatus ? isOnline(connectionInfo) === true : connectionInfo;
27
30
  }
@@ -0,0 +1,243 @@
1
+ import { renderHook } from "@testing-library/react-native";
2
+ import * as zappPipesModule from "@applicaster/zapp-react-native-redux/ZappPipes";
3
+ import * as useRouteHook from "@applicaster/zapp-react-native-utils/reactHooks/navigation/useRoute";
4
+ import * as useNavigationHooks from "@applicaster/zapp-react-native-utils/reactHooks/navigation/useNavigation";
5
+ import * as useIsScreenActiveHook from "@applicaster/zapp-react-native-utils/reactHooks/navigation/useIsScreenActive";
6
+ import { refreshCoordinator } from "../../../refreshUtils/RefreshCoordinator";
7
+ import { useMarkPipesDataStale } from "../useMarkPipesDataStale";
8
+
9
+ const mockDispatch = jest.fn();
10
+
11
+ jest.mock("@applicaster/zapp-react-native-redux", () => ({
12
+ ...jest.requireActual("@applicaster/zapp-react-native-redux"),
13
+ useAppDispatch: () => mockDispatch,
14
+ }));
15
+
16
+ jest.mock(
17
+ "@applicaster/zapp-react-native-ui-components/Decorators/RiverFeedLoader/utils/getDatasourceUrl",
18
+ () => ({
19
+ getDatasourceUrl: jest.fn(
20
+ (_ctx, component) => component?.data?.source || null
21
+ ),
22
+ })
23
+ );
24
+
25
+ jest.mock(
26
+ "@applicaster/zapp-react-native-ui-components/Decorators/RiverFeedLoader/utils/usePipesContexts",
27
+ () => ({
28
+ usePipesContexts: jest.fn(() => ({})),
29
+ })
30
+ );
31
+
32
+ describe("useMarkPipesDataStale", () => {
33
+ let mockNavigator: any;
34
+ let mockRoute: any;
35
+ let mockIsActive: boolean;
36
+
37
+ const riverId = "river-123";
38
+
39
+ const refreshableComponent: any = {
40
+ id: "comp-1",
41
+ rules: { clear_cache_on_reload: true },
42
+ data: { source: "https://feed.example.com/data1" },
43
+ };
44
+
45
+ const stringBooleanComponent: any = {
46
+ id: "comp-2",
47
+ rules: { clear_cache_on_reload: "true" },
48
+ data: { source: "https://feed.example.com/data2" },
49
+ };
50
+
51
+ const nonRefreshableComponent: any = {
52
+ id: "comp-3",
53
+ rules: { clear_cache_on_reload: false },
54
+ data: { source: "https://feed.example.com/data3" },
55
+ };
56
+
57
+ const noSourceComponent: any = {
58
+ id: "comp-4",
59
+ rules: { clear_cache_on_reload: true },
60
+ };
61
+
62
+ const allComponents = [
63
+ refreshableComponent,
64
+ stringBooleanComponent,
65
+ nonRefreshableComponent,
66
+ noSourceComponent,
67
+ ];
68
+
69
+ beforeEach(() => {
70
+ jest.clearAllMocks();
71
+
72
+ mockIsActive = true;
73
+
74
+ mockNavigator = {
75
+ currentRoute: "river/river-123",
76
+ };
77
+
78
+ mockRoute = {
79
+ pathname: "river/river-123",
80
+ screenData: { id: riverId },
81
+ };
82
+
83
+ jest
84
+ .spyOn(useNavigationHooks, "useNavigation")
85
+ .mockImplementation(() => mockNavigator);
86
+
87
+ jest.spyOn(useRouteHook, "useRoute").mockImplementation(() => mockRoute);
88
+
89
+ jest
90
+ .spyOn(useIsScreenActiveHook, "useIsScreenActive")
91
+ .mockImplementation(() => mockIsActive);
92
+
93
+ jest
94
+ .spyOn(refreshCoordinator, "triggerRefresh")
95
+ .mockImplementation(jest.fn());
96
+
97
+ jest
98
+ .spyOn(zappPipesModule, "markPipesDataStale")
99
+ .mockImplementation(
100
+ (url, opts) => ({ type: "MARK_STALE", url, opts }) as any
101
+ );
102
+ });
103
+
104
+ describe("on unmount", () => {
105
+ it("marks clear_cache_on_reload feeds stale on unmount", () => {
106
+ const { unmount } = renderHook(() =>
107
+ useMarkPipesDataStale(riverId, allComponents)
108
+ );
109
+
110
+ expect(mockDispatch).not.toHaveBeenCalled();
111
+
112
+ unmount();
113
+
114
+ expect(zappPipesModule.markPipesDataStale).toHaveBeenCalledWith(
115
+ "https://feed.example.com/data1",
116
+ { riverId }
117
+ );
118
+
119
+ expect(zappPipesModule.markPipesDataStale).toHaveBeenCalledWith(
120
+ "https://feed.example.com/data2",
121
+ { riverId }
122
+ );
123
+
124
+ expect(zappPipesModule.markPipesDataStale).not.toHaveBeenCalledWith(
125
+ "https://feed.example.com/data3",
126
+ expect.anything()
127
+ );
128
+
129
+ expect(mockDispatch).toHaveBeenCalledTimes(2);
130
+ });
131
+
132
+ it("does not mark stale on unmount if pathname includes videoModal", () => {
133
+ mockRoute.pathname = "videoModal/item-123";
134
+
135
+ const { unmount } = renderHook(() =>
136
+ useMarkPipesDataStale(riverId, allComponents)
137
+ );
138
+
139
+ unmount();
140
+
141
+ expect(mockDispatch).not.toHaveBeenCalled();
142
+ });
143
+ });
144
+
145
+ describe("on screen return (screen not unmounted in Transitioner)", () => {
146
+ it("does not trigger refresh on initial mount", () => {
147
+ renderHook(() => useMarkPipesDataStale(riverId, allComponents));
148
+
149
+ expect(refreshCoordinator.triggerRefresh).not.toHaveBeenCalled();
150
+ expect(mockDispatch).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it("triggers refresh and marks stale when returning from a deeper screen", () => {
154
+ const { rerender } = renderHook(() =>
155
+ useMarkPipesDataStale(riverId, allComponents)
156
+ );
157
+
158
+ // 1. User navigates away to a deeper screen
159
+ mockNavigator.currentRoute = "river/deeper-screen";
160
+ mockIsActive = false;
161
+ rerender({});
162
+
163
+ expect(refreshCoordinator.triggerRefresh).not.toHaveBeenCalled();
164
+ expect(mockDispatch).not.toHaveBeenCalled();
165
+
166
+ // 2. User presses back and returns to original screen
167
+ mockNavigator.currentRoute = "river/river-123";
168
+ mockIsActive = true;
169
+ rerender({});
170
+
171
+ expect(refreshCoordinator.triggerRefresh).toHaveBeenCalledTimes(1);
172
+
173
+ expect(refreshCoordinator.triggerRefresh).toHaveBeenCalledWith(
174
+ [refreshableComponent, stringBooleanComponent],
175
+ riverId
176
+ );
177
+
178
+ expect(zappPipesModule.markPipesDataStale).toHaveBeenCalledWith(
179
+ "https://feed.example.com/data1",
180
+ { riverId }
181
+ );
182
+
183
+ expect(zappPipesModule.markPipesDataStale).toHaveBeenCalledWith(
184
+ "https://feed.example.com/data2",
185
+ { riverId }
186
+ );
187
+
188
+ expect(mockDispatch).toHaveBeenCalledTimes(2);
189
+ });
190
+
191
+ it("does not trigger duplicate refreshes on subsequent re-renders on the same screen", () => {
192
+ const { rerender } = renderHook(() =>
193
+ useMarkPipesDataStale(riverId, allComponents)
194
+ );
195
+
196
+ // Navigate away
197
+ mockNavigator.currentRoute = "river/deeper-screen";
198
+ mockIsActive = false;
199
+ rerender({});
200
+
201
+ // Return
202
+ mockNavigator.currentRoute = "river/river-123";
203
+ mockIsActive = true;
204
+ rerender({});
205
+
206
+ expect(refreshCoordinator.triggerRefresh).toHaveBeenCalledTimes(1);
207
+
208
+ // Subsequent re-render without navigation
209
+ rerender({});
210
+ rerender({});
211
+
212
+ expect(refreshCoordinator.triggerRefresh).toHaveBeenCalledTimes(1);
213
+ });
214
+
215
+ it("triggers refresh again if user navigates away a second time and returns", () => {
216
+ const { rerender } = renderHook(() =>
217
+ useMarkPipesDataStale(riverId, allComponents)
218
+ );
219
+
220
+ // First trip: navigate away and return
221
+ mockNavigator.currentRoute = "river/screen-2";
222
+ mockIsActive = false;
223
+ rerender({});
224
+
225
+ mockNavigator.currentRoute = "river/river-123";
226
+ mockIsActive = true;
227
+ rerender({});
228
+
229
+ expect(refreshCoordinator.triggerRefresh).toHaveBeenCalledTimes(1);
230
+
231
+ // Second trip: navigate away to another screen and return
232
+ mockNavigator.currentRoute = "river/screen-3";
233
+ mockIsActive = false;
234
+ rerender({});
235
+
236
+ mockNavigator.currentRoute = "river/river-123";
237
+ mockIsActive = true;
238
+ rerender({});
239
+
240
+ expect(refreshCoordinator.triggerRefresh).toHaveBeenCalledTimes(2);
241
+ });
242
+ });
243
+ });