@applicaster/zapp-react-native-utils 16.0.0-alpha.1701179921 → 16.0.0-alpha.1733304298

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 (74) hide show
  1. package/actionUtils/index.ts +3 -1
  2. package/actionsExecutor/__tests__/StorageActions.test.ts +98 -0
  3. package/actionsExecutor/__tests__/feedDecorator.test.ts +30 -0
  4. package/actionsExecutor/__tests__/screenToggleFlag.test.ts +90 -0
  5. package/actionsExecutor/actions/__tests__/navigateToScreen.test.ts +139 -5
  6. package/actionsExecutor/actions/__tests__/openBottomSheet.customContent.test.ts +24 -0
  7. package/actionsExecutor/actions/__tests__/openBottomSheet.inlineItems.test.ts +72 -0
  8. package/actionsExecutor/actions/navigateToScreen.ts +29 -45
  9. package/actionsExecutor/actions/openBottomSheet.ts +2 -0
  10. package/actionsExecutor/actions/screenToggleFlag.ts +1 -3
  11. package/actionsExecutor/feedDecorator.ts +6 -0
  12. package/analyticsUtils/__tests__/playerAnalyticsSafeguard.test.ts +62 -0
  13. package/analyticsUtils/playerAnalyticsTracker.ts +1 -0
  14. package/appUtils/contextKeysManager/__tests__/getKey/failure.test.ts +1 -22
  15. package/appUtils/contextKeysManager/__tests__/getKey/success/fullSearch.test.ts +1 -25
  16. package/appUtils/contextKeysManager/__tests__/getKey/success/searchInSecure.test.ts +1 -7
  17. package/appUtils/contextKeysManager/__tests__/removeKey/failure.test.ts +0 -25
  18. package/appUtils/contextKeysManager/__tests__/removeKey/success.test.ts +0 -6
  19. package/appUtils/contextKeysManager/__tests__/setKey/failure/failedToSaveInStorage.test.ts +0 -40
  20. package/appUtils/contextKeysManager/__tests__/setKey/{success/saveItemAndReference.test.ts → success.test.ts} +2 -42
  21. package/appUtils/contextKeysManager/index.ts +28 -129
  22. package/appUtils/playerManager/__tests__/playerDiagnostics.test.ts +62 -0
  23. package/appUtils/playerManager/__tests__/playerToolbox.test.ts +281 -0
  24. package/appUtils/playerManager/__tests__/usePlayerControllerSetup.test.tsx +62 -0
  25. package/appUtils/playerManager/index.ts +7 -0
  26. package/appUtils/playerManager/player.ts +15 -0
  27. package/appUtils/playerManager/playerToolbox.ts +196 -0
  28. package/appUtils/playerManager/usePlayerControllerSetup.tsx +6 -0
  29. package/cellUtils/__tests__/getButtonsEnabled.test.ts +28 -0
  30. package/cellUtils/__tests__/getCellNavigationEnabled.test.ts +36 -0
  31. package/cellUtils/__tests__/getIsCellNavigationEnabled.test.ts +35 -0
  32. package/cellUtils/__tests__/isCellNavigationEnabled.test.ts +21 -0
  33. package/cellUtils/index.ts +40 -0
  34. package/configurationUtils/__tests__/modalBlocksParser.test.ts +134 -0
  35. package/configurationUtils/modalBlocksParser.ts +38 -0
  36. package/focusManager/FocusManager.ts +26 -0
  37. package/focusManager/__tests__/FocusManager.test.ts +9 -0
  38. package/localizationUtils/index.ts +12 -6
  39. package/logger/__tests__/xrayToolbox.test.ts +69 -0
  40. package/logger/xrayToolbox.ts +67 -0
  41. package/manifestUtils/defaultManifestConfigurations/player.js +8 -0
  42. package/manifestUtils/keys.js +7 -2
  43. package/manifestUtils/mobileAction/container/__tests__/mobileActionButtonsContainer.test.js +21 -0
  44. package/manifestUtils/mobileAction/groups/__tests__/buildMobileActionButtonGroups.test.js +1 -1
  45. package/manifestUtils/mobileAction/groups/defaults.js +2 -1
  46. package/modalState/ModalOrchestrator.ts +1 -0
  47. package/modalState/__tests__/ModalOrchestrator.phase3.test.ts +23 -0
  48. package/modalState/index.ts +2 -0
  49. package/modalState/types.ts +11 -0
  50. package/navigationUtils/xrayNavigator.ts +12 -0
  51. package/package.json +3 -2
  52. package/quickBrickAssets/__tests__/index.test.ts +18 -0
  53. package/quickBrickAssets/index.ts +35 -0
  54. package/reactHooks/__tests__/getPluginConfiguration.test.ts +82 -0
  55. package/reactHooks/actions/index.ts +29 -23
  56. package/reactHooks/connection/index.tsx +8 -5
  57. package/reactHooks/feed/__tests__/feedInvalidation.test.tsx +338 -0
  58. package/reactHooks/feed/__tests__/useBatchLoading.test.tsx +8 -2
  59. package/reactHooks/feed/__tests__/useMarkPipesDataStale.test.tsx +243 -0
  60. package/reactHooks/feed/useFeedLoader.tsx +28 -1
  61. package/reactHooks/feed/useLoadPipesDataDispatch.ts +12 -11
  62. package/reactHooks/feed/useMarkPipesDataStale.ts +68 -23
  63. package/reactHooks/usePluginConfiguration.ts +84 -46
  64. package/riverComponetsMeasurementProvider/index.tsx +3 -1
  65. package/uiActionsRegistrator/__tests__/subscribe.test.ts +26 -0
  66. package/uiActionsRegistrator/registry.ts +16 -0
  67. package/appUtils/contextKeysManager/__tests__/getKey/success/byReference.test.ts +0 -56
  68. package/appUtils/contextKeysManager/__tests__/getKeyByReference/failure.test.ts +0 -265
  69. package/appUtils/contextKeysManager/__tests__/getKeyByReference/success.test.ts +0 -303
  70. package/appUtils/contextKeysManager/__tests__/removeReferenceForKey/failure.test.ts +0 -82
  71. package/appUtils/contextKeysManager/__tests__/removeReferenceForKey/success.test.ts +0 -41
  72. package/appUtils/contextKeysManager/__tests__/setKey/success/saveItemButNotReference.test.ts +0 -208
  73. package/appUtils/contextKeysManager/__tests__/setReferenceForKey/failure.test.ts +0 -93
  74. package/appUtils/contextKeysManager/__tests__/setReferenceForKey/success.test.ts +0 -45
@@ -0,0 +1,338 @@
1
+ import React from "react";
2
+ import { Provider } from "react-redux";
3
+ import { configureStore } from "@reduxjs/toolkit";
4
+ import { act, renderHook } from "@testing-library/react-native";
5
+ import reducer, {
6
+ loadPipesData,
7
+ unsubscribeFromDataSource,
8
+ zappPipesClear,
9
+ } from "@applicaster/zapp-react-native-redux/ZappPipes";
10
+ import { zappPipesMiddleware } from "../../../../zapp-react-native-redux/ZappPipes/middleware";
11
+ import { useFeedLoader } from "../useFeedLoader";
12
+
13
+ const mockGet = jest.fn();
14
+ const mockPost = jest.fn();
15
+ const mockSubscribe = jest.fn();
16
+ const mockRelease = jest.fn();
17
+ let mockChange: () => void;
18
+ let mockLoad: ReturnType<typeof jest.fn>;
19
+
20
+ jest.mock("@applicaster/zapp-pipes-v2-client", () => ({
21
+ __esModule: true,
22
+ default: jest.fn(() => ({
23
+ get: (...args) => mockGet(...args),
24
+ post: (...args) => mockPost(...args),
25
+ })),
26
+ RequestBuilder: jest.fn(() => ({
27
+ setResolvers() {
28
+ return this;
29
+ },
30
+ build: async () => ({}),
31
+ })),
32
+ subscribeForUrlContextKeyChanges: (...args) => mockSubscribe(...args),
33
+ }));
34
+
35
+ jest.mock("../useInflatedUrl", () => ({
36
+ useInflatedUrl: ({ feedUrl }) => feedUrl,
37
+ }));
38
+
39
+ jest.mock("../useLoadPipesDataDispatch", () => ({
40
+ useLoadPipesDataDispatch: () => mockLoad,
41
+ }));
42
+
43
+ const url = "https://example.test/feed";
44
+ const data = { id: "feed", type: { value: "feed" }, entry: [] };
45
+
46
+ const settle = async () => {
47
+ for (let index = 0; index < 30; index++) await Promise.resolve();
48
+ };
49
+
50
+ const createHarness = (plugins: QuickBrickPlugin[] = []) => {
51
+ const store = configureStore({
52
+ reducer: {
53
+ zappPipes: reducer,
54
+ appData: () => ({ layoutVersion: "v2" }),
55
+ plugins: () => plugins,
56
+ },
57
+ middleware: (defaults) =>
58
+ defaults({ serializableCheck: false }).concat(zappPipesMiddleware),
59
+ });
60
+
61
+ mockLoad = jest.fn((feedUrl, options) =>
62
+ store.dispatch(loadPipesData(feedUrl, options))
63
+ );
64
+
65
+ const wrapper = ({ children }) => (
66
+ <Provider store={store}>{children}</Provider>
67
+ );
68
+
69
+ return { store, wrapper };
70
+ };
71
+
72
+ describe("dependency-driven feed invalidation", () => {
73
+ beforeEach(() => {
74
+ jest.clearAllMocks();
75
+ mockGet.mockResolvedValue({ response: data, code: 200 });
76
+ mockPost.mockResolvedValue({ response: data, code: 200 });
77
+
78
+ mockSubscribe.mockImplementation((_url, _options, onChange) => {
79
+ mockChange = onChange;
80
+
81
+ return mockRelease;
82
+ });
83
+ });
84
+
85
+ afterEach(() => unsubscribeFromDataSource());
86
+
87
+ it("silently revalidates a shared mounted feed once and keeps its content", async () => {
88
+ const { store, wrapper } = createHarness();
89
+
90
+ const first = renderHook(() => useFeedLoader({ feedUrl: url }), {
91
+ wrapper,
92
+ });
93
+
94
+ const second = renderHook(() => useFeedLoader({ feedUrl: url }), {
95
+ wrapper,
96
+ });
97
+
98
+ await act(settle);
99
+
100
+ expect(mockGet).toHaveBeenCalledTimes(1);
101
+ expect(mockSubscribe).toHaveBeenCalledTimes(1);
102
+ let finish;
103
+
104
+ mockGet.mockImplementationOnce(
105
+ () =>
106
+ new Promise((resolve) => {
107
+ finish = resolve;
108
+ })
109
+ );
110
+
111
+ await act(async () => {
112
+ mockChange();
113
+ await settle();
114
+ });
115
+
116
+ expect(mockGet).toHaveBeenCalledTimes(2);
117
+ expect(first.result.current.data).toEqual(data);
118
+ expect(first.result.current.loading).toBe(false);
119
+ expect(store.getState().zappPipes[url].refreshing).toBe(true);
120
+
121
+ await act(async () => {
122
+ finish({ response: data, code: 200 });
123
+ await settle();
124
+ });
125
+
126
+ expect(store.getState().zappPipes[url].stale).toBeUndefined();
127
+ first.unmount();
128
+ second.unmount();
129
+ expect(mockRelease).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it("invalidates an unmounted cache without fetching and revalidates on remount", async () => {
133
+ const { store, wrapper } = createHarness();
134
+
135
+ const first = renderHook(() => useFeedLoader({ feedUrl: url }), {
136
+ wrapper,
137
+ });
138
+
139
+ await act(settle);
140
+ first.unmount();
141
+
142
+ mockChange();
143
+ mockChange();
144
+ await settle();
145
+
146
+ expect(mockGet).toHaveBeenCalledTimes(1);
147
+
148
+ expect(store.getState().zappPipes[url]).toMatchObject({
149
+ stale: true,
150
+ invalidationVersion: 2,
151
+ data,
152
+ });
153
+
154
+ const second = renderHook(() => useFeedLoader({ feedUrl: url }), {
155
+ wrapper,
156
+ });
157
+
158
+ await act(settle);
159
+
160
+ expect(mockGet).toHaveBeenCalledTimes(2);
161
+ expect(store.getState().zappPipes[url].stale).toBeUndefined();
162
+ second.unmount();
163
+ });
164
+
165
+ it("runs one follow-up when invalidated repeatedly during a request", async () => {
166
+ const { store, wrapper } = createHarness();
167
+ let finish;
168
+
169
+ mockGet.mockImplementationOnce(
170
+ () =>
171
+ new Promise((resolve) => {
172
+ finish = resolve;
173
+ })
174
+ );
175
+
176
+ const hook = renderHook(() => useFeedLoader({ feedUrl: url }), { wrapper });
177
+ await act(settle);
178
+
179
+ await act(async () => {
180
+ mockChange();
181
+ mockChange();
182
+ await settle();
183
+ });
184
+
185
+ expect(mockGet).toHaveBeenCalledTimes(1);
186
+
187
+ await act(async () => {
188
+ finish({ response: data, code: 200 });
189
+ await settle();
190
+ });
191
+
192
+ expect(mockGet).toHaveBeenCalledTimes(2);
193
+ expect(store.getState().zappPipes[url].stale).toBeUndefined();
194
+ hook.unmount();
195
+ });
196
+
197
+ it("stops a failed revalidation until another dependency change", async () => {
198
+ const { wrapper } = createHarness();
199
+ const hook = renderHook(() => useFeedLoader({ feedUrl: url }), { wrapper });
200
+ await act(settle);
201
+ mockGet.mockRejectedValueOnce(new Error("network unavailable"));
202
+
203
+ await act(async () => {
204
+ mockChange();
205
+ await settle();
206
+ });
207
+
208
+ expect(mockGet).toHaveBeenCalledTimes(2);
209
+ expect(hook.result.current.error).toBeDefined();
210
+
211
+ await act(async () => {
212
+ mockChange();
213
+ await settle();
214
+ });
215
+
216
+ expect(mockGet).toHaveBeenCalledTimes(3);
217
+ expect(hook.result.current.error).toBeUndefined();
218
+ hook.unmount();
219
+ });
220
+
221
+ it("releases cache subscriptions for direct clear actions", async () => {
222
+ const { store } = createHarness();
223
+ await store.dispatch(loadPipesData(url));
224
+ await store.dispatch(loadPipesData(`${url}/two`));
225
+ store.dispatch(zappPipesClear(url));
226
+ expect(mockRelease).toHaveBeenCalledTimes(1);
227
+ store.dispatch(zappPipesClear());
228
+ expect(mockRelease).toHaveBeenCalledTimes(2);
229
+ });
230
+
231
+ it("preserves POST method and body when revalidating", async () => {
232
+ const { wrapper } = createHarness();
233
+ const bodyParams = { filter: "recent" };
234
+
235
+ const hook = renderHook(
236
+ () =>
237
+ useFeedLoader({
238
+ feedUrl: url,
239
+ pipesOptions: { method: "post", bodyParams },
240
+ }),
241
+ { wrapper }
242
+ );
243
+
244
+ await act(settle);
245
+
246
+ await act(async () => {
247
+ mockChange();
248
+ await settle();
249
+ });
250
+
251
+ expect(mockSubscribe).toHaveBeenCalledWith(
252
+ url,
253
+ { method: "post" },
254
+ expect.any(Function)
255
+ );
256
+
257
+ expect(mockPost).toHaveBeenCalledTimes(2);
258
+ expect(mockPost).toHaveBeenLastCalledWith(url, bodyParams);
259
+ expect(mockGet).not.toHaveBeenCalled();
260
+ hook.unmount();
261
+ });
262
+
263
+ it("does not revalidate a skipped mounted feed until loading is enabled", async () => {
264
+ const { store, wrapper } = createHarness();
265
+ await store.dispatch(loadPipesData(url));
266
+
267
+ const hook = renderHook(
268
+ ({ skipLoading }) =>
269
+ useFeedLoader({
270
+ feedUrl: url,
271
+ pipesOptions: { skipLoading },
272
+ }),
273
+ { wrapper, initialProps: { skipLoading: true } }
274
+ );
275
+
276
+ await act(async () => {
277
+ mockChange();
278
+ await settle();
279
+ });
280
+
281
+ expect(mockGet).toHaveBeenCalledTimes(1);
282
+ expect(store.getState().zappPipes[url].stale).toBe(true);
283
+
284
+ hook.rerender({ skipLoading: false });
285
+ await act(settle);
286
+ expect(mockGet).toHaveBeenCalledTimes(2);
287
+ expect(store.getState().zappPipes[url].stale).toBeUndefined();
288
+ hook.unmount();
289
+ });
290
+
291
+ it("shares GET revalidation with an omitted or empty unused request body", async () => {
292
+ const { store } = createHarness();
293
+ await store.dispatch(loadPipesData(url));
294
+ mockChange();
295
+ mockGet.mockClear();
296
+
297
+ await Promise.all([
298
+ store.dispatch(loadPipesData(url)),
299
+ store.dispatch(loadPipesData(url, { method: "get", bodyParams: {} })),
300
+ ]);
301
+
302
+ expect(mockGet).toHaveBeenCalledTimes(1);
303
+ });
304
+
305
+ it("keeps plugin observation until its cache is cleared", async () => {
306
+ const release = jest.fn();
307
+ let changed;
308
+
309
+ const addDataSourceListener = jest.fn((callback) => {
310
+ changed = callback;
311
+
312
+ return release;
313
+ });
314
+
315
+ const { store, wrapper } = createHarness([
316
+ {
317
+ identifier: "generic-provider",
318
+ module: { addDataSourceListener },
319
+ } as QuickBrickPlugin,
320
+ ]);
321
+
322
+ const pluginUrl = "pipesv2://generic-provider/feed";
323
+
324
+ const hook = renderHook(() => useFeedLoader({ feedUrl: pluginUrl }), {
325
+ wrapper,
326
+ });
327
+
328
+ await act(settle);
329
+ hook.unmount();
330
+ changed();
331
+ await settle();
332
+ expect(mockGet).toHaveBeenCalledTimes(1);
333
+ expect(store.getState().zappPipes[pluginUrl].stale).toBe(true);
334
+ expect(release).not.toHaveBeenCalled();
335
+ store.dispatch(zappPipesClear(pluginUrl));
336
+ expect(release).toHaveBeenCalledTimes(1);
337
+ });
338
+ });
@@ -2,6 +2,7 @@ import { renderHook, waitFor } from "@testing-library/react-native";
2
2
  import { allFeedsIsReady, useBatchLoading } from "../useBatchLoading";
3
3
  import { WrappedWithProviders } from "@applicaster/zapp-react-native-utils/testUtils";
4
4
  import { appStore } from "@applicaster/zapp-react-native-redux/AppStore";
5
+ import * as ZappPipes from "@applicaster/zapp-react-native-redux/ZappPipes";
5
6
 
6
7
  jest.mock("../../navigation");
7
8
 
@@ -32,11 +33,16 @@ describe("useBatchLoading", () => {
32
33
  // ... more items
33
34
  ];
34
35
 
35
- beforeAll(() => {
36
- jest.useFakeTimers();
36
+ beforeEach(() => {
37
+ jest
38
+ .spyOn(ZappPipes, "loadPipesData")
39
+ .mockImplementation((url, options) => async (dispatch) => {
40
+ dispatch(ZappPipes.zappPipesRequestStart(url, options));
41
+ });
37
42
  });
38
43
 
39
44
  afterEach(() => {
45
+ jest.restoreAllMocks();
40
46
  jest.clearAllMocks();
41
47
  });
42
48
 
@@ -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
+ });
@@ -15,6 +15,7 @@ type Props = {
15
15
  pipesOptions?: {
16
16
  clearCache?: boolean;
17
17
  loadLocalFavourites?: boolean;
18
+ loadLocalFavorites?: boolean;
18
19
  silentRefresh?: boolean;
19
20
  method?: "get" | "post";
20
21
  bodyParams?: Record<string, unknown>;
@@ -45,11 +46,36 @@ export const useFeedLoader = ({
45
46
 
46
47
  const loadPipesDataDispatcher = useLoadPipesDataDispatch();
47
48
 
49
+ useEffect(() => {
50
+ if (
51
+ currentFeed?.stale &&
52
+ !currentFeed.refreshing &&
53
+ (!currentFeed.error || isInitialRender) &&
54
+ !pipesOptions.skipLoading
55
+ ) {
56
+ loadPipesDataDispatcher(
57
+ callableFeedUrl,
58
+ { ...pipesOptions, clearCache: false, silentRefresh: true },
59
+ { withResolvers: true }
60
+ );
61
+ }
62
+ }, [
63
+ callableFeedUrl,
64
+ currentFeed?.stale,
65
+ currentFeed?.invalidationVersion,
66
+ currentFeed?.refreshing,
67
+ currentFeed?.error,
68
+ pipesOptions,
69
+ loadPipesDataDispatcher,
70
+ isInitialRender,
71
+ ]);
72
+
48
73
  const reloadData = React.useCallback<ReloadDataFunction>(
49
74
  (silentRefresh = true, callback) => {
50
75
  loadPipesDataDispatcher(
51
76
  callableFeedUrl,
52
77
  {
78
+ ...pipesOptions,
53
79
  clearCache: true,
54
80
  silentRefresh,
55
81
  callback,
@@ -59,7 +85,7 @@ export const useFeedLoader = ({
59
85
  }
60
86
  );
61
87
  },
62
- [callableFeedUrl]
88
+ [callableFeedUrl, pipesOptions, loadPipesDataDispatcher]
63
89
  );
64
90
 
65
91
  const loadNext: FeedLoaderResponse["loadNext"] = React.useCallback(() => {
@@ -81,6 +107,7 @@ export const useFeedLoader = ({
81
107
 
82
108
  useEffect(() => {
83
109
  if (
110
+ !currentFeed?.stale &&
84
111
  shouldDispatchData(callableFeedUrl, currentFeed, pipesOptions.clearCache)
85
112
  ) {
86
113
  if (callableFeedUrl && !pipesOptions.skipLoading) {