@applicaster/zapp-react-native-utils 16.0.0-rc.125 → 16.0.0-rc.126

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.
@@ -0,0 +1,98 @@
1
+ import { ActionResult } from "../ActionExecutor";
2
+ import { localStorageToggleFlag } from "../StorageActions";
3
+ import { TOGGLE_FLAG_MAX_ITEMS_REACHED_EVENT } from "../consts";
4
+ import { postEvent } from "../../reactHooks/useSubscriberFor";
5
+
6
+ jest.mock("../ActionExecutorContext", () => ({
7
+ log_error: jest.fn(),
8
+ log_info: jest.fn(),
9
+ }));
10
+
11
+ jest.mock("../../reactHooks/useSubscriberFor", () => ({
12
+ postEvent: jest.fn(),
13
+ }));
14
+
15
+ // In-memory stand-in for the storage-backed provider; the limit check under
16
+ // test lives in storageToggleFlag, not in the provider.
17
+ const mockStore: Record<string, string[]> = {};
18
+
19
+ jest.mock("../../storage/StorageMultiSelectProvider", () => ({
20
+ StorageMultiSelectProvider: {
21
+ getProvider: (keyNamespace: string) => ({
22
+ getSelectedAsync: async () => [...(mockStore[keyNamespace] ?? [])],
23
+ addItem: async (item: string) => {
24
+ mockStore[keyNamespace] = [...(mockStore[keyNamespace] ?? []), item];
25
+ },
26
+ removeItem: async (item: string) => {
27
+ mockStore[keyNamespace] = (mockStore[keyNamespace] ?? []).filter(
28
+ (it) => it !== item
29
+ );
30
+ },
31
+ }),
32
+ },
33
+ }));
34
+
35
+ const KEY = "user_preferences.teams";
36
+
37
+ // Entries of a team selection feed with explicit tap actions
38
+ // (localStorageToggleFlag with key and selector).
39
+ const teamEntry = (n: number) =>
40
+ ({
41
+ id: `team-${n}`,
42
+ title: `Team ${n}`,
43
+ type: { value: "action" },
44
+ extensions: { tag: `TEAM ${n}` },
45
+ }) as unknown as ZappEntry;
46
+
47
+ const tapAction = (extraOptions = {}) =>
48
+ ({
49
+ type: "localStorageToggleFlag",
50
+ options: { key: KEY, selector: "extensions.tag", ...extraOptions },
51
+ }) as unknown as ActionType;
52
+
53
+ const tapTeams = async (count: number, extraOptions = {}) => {
54
+ const results: ActionResult[] = [];
55
+
56
+ for (let n = 1; n <= count; n++) {
57
+ results.push(
58
+ await localStorageToggleFlag(
59
+ { entry: teamEntry(n) },
60
+ tapAction(extraOptions)
61
+ )
62
+ );
63
+ }
64
+
65
+ return results;
66
+ };
67
+
68
+ describe("localStorageToggleFlag max_items", () => {
69
+ beforeEach(() => {
70
+ delete mockStore[KEY];
71
+ jest.clearAllMocks();
72
+ });
73
+
74
+ it("blocks the 11th team when max_items is in the action options", async () => {
75
+ const results = await tapTeams(11, { max_items: "10" });
76
+
77
+ expect(results.slice(0, 10)).toEqual(Array(10).fill(ActionResult.Success));
78
+ expect(results[10]).toBe(ActionResult.Cancel);
79
+ expect(mockStore[KEY]).toHaveLength(10);
80
+
81
+ expect(postEvent).toHaveBeenCalledWith(
82
+ TOGGLE_FLAG_MAX_ITEMS_REACHED_EVENT,
83
+ [expect.objectContaining({ maxItems: "10", tag: "TEAM 11" })]
84
+ );
85
+ });
86
+
87
+ it("still lets a selected team be removed at the limit", async () => {
88
+ await tapTeams(10, { max_items: "10" });
89
+
90
+ const result = await localStorageToggleFlag(
91
+ { entry: teamEntry(1) },
92
+ tapAction({ max_items: "10" })
93
+ );
94
+
95
+ expect(result).toBe(ActionResult.Success);
96
+ expect(mockStore[KEY]).toHaveLength(9);
97
+ });
98
+ });
@@ -59,3 +59,33 @@ describe("decorateFeed", () => {
59
59
  expect(decorated.entry[0].extensions.tap_actions).toBeUndefined();
60
60
  });
61
61
  });
62
+
63
+ describe("decorateFeed max_items", () => {
64
+ const toggleAction = (feed: ZappFeed) =>
65
+ decorateFeed(feed).entry[0].extensions.tap_actions.actions[0];
66
+
67
+ it.each([
68
+ ["screen", "screenToggleFlag"],
69
+ ["session", "sessionStorageToggleFlag"],
70
+ ["ctx", "localStorageToggleFlag"],
71
+ ])("passes max_items to the %s toggle action", (scope, type) => {
72
+ const feed = preferenceFeed("multi");
73
+ feed.extensions.preference_editor_options.scope = scope;
74
+ feed.extensions.preference_editor_options.max_items = "10";
75
+
76
+ const toggle = toggleAction(feed);
77
+
78
+ expect(toggle.type).toBe(type);
79
+ expect(toggle.options).toEqual({ key: "agree", max_items: 10 });
80
+ });
81
+
82
+ it.each([undefined, 0, "0", "ten", -1])(
83
+ "does not set a limit for max_items %p",
84
+ (maxItems) => {
85
+ const feed = preferenceFeed("multi");
86
+ feed.extensions.preference_editor_options.max_items = maxItems;
87
+
88
+ expect(toggleAction(feed).options).toEqual({ key: "agree" });
89
+ }
90
+ );
91
+ });
@@ -0,0 +1,90 @@
1
+ import { ActionExecutor, ActionResult } from "../ActionExecutor";
2
+ import { screenToggleFlagAction } from "../actions/screenToggleFlag";
3
+
4
+ jest.mock("../ActionExecutorContext", () => ({
5
+ log_error: jest.fn(),
6
+ log_info: jest.fn(),
7
+ }));
8
+
9
+ jest.mock("../StorageActions", () => ({
10
+ onMaxTagsReached: jest.fn(),
11
+ }));
12
+
13
+ // not used here, but it pulls the native bridge logger in at import time
14
+ jest.mock("../../storage/ScreenSingleValueProvider", () => ({}));
15
+
16
+ jest.mock("../../logger", () => ({
17
+ createLogger: () => ({
18
+ log_error: jest.fn(),
19
+ log_debug: jest.fn(),
20
+ log_info: jest.fn(),
21
+ }),
22
+ }));
23
+
24
+ const mockSelected: string[] = [];
25
+
26
+ jest.mock("../../storage/ScreenStateMultiSelectProvider", () => ({
27
+ ScreenMultiSelectProvider: {
28
+ getProvider: () => ({
29
+ getSelectedAsync: async () => [...mockSelected],
30
+ addItem: async (item: string) => {
31
+ mockSelected.push(item);
32
+ },
33
+ removeItem: async (item: string) => {
34
+ mockSelected.splice(mockSelected.indexOf(item), 1);
35
+ },
36
+ }),
37
+ },
38
+ }));
39
+
40
+ const toggleAction = (options = {}) =>
41
+ ({
42
+ type: "screenToggleFlag",
43
+ options: { key: "teams", selector: "extensions.tag", ...options },
44
+ }) as unknown as ActionType;
45
+
46
+ const context = (tag: string) => ({
47
+ screenRoute: "/screen",
48
+ screenStateStore: {},
49
+ entry: { id: tag, extensions: { tag } },
50
+ });
51
+
52
+ describe("screenToggleFlagAction at the max_items limit", () => {
53
+ beforeEach(() => {
54
+ mockSelected.length = 0;
55
+ });
56
+
57
+ it("returns the cancellation so the chained actions do not run", async () => {
58
+ const afterToggle = jest.fn();
59
+ const executor = new ActionExecutor();
60
+
61
+ executor.registerAction("screenToggleFlag", screenToggleFlagAction);
62
+
63
+ executor.registerAction("sendCloudEvent", async () => {
64
+ afterToggle();
65
+
66
+ return ActionResult.Success;
67
+ });
68
+
69
+ mockSelected.push("A", "B");
70
+
71
+ const result = await executor.handleActions(
72
+ [toggleAction({ max_items: 2 }), { type: "sendCloudEvent" }],
73
+ context("C") as never
74
+ );
75
+
76
+ expect(result).toBe(ActionResult.Cancel);
77
+ expect(afterToggle).not.toHaveBeenCalled();
78
+ expect(mockSelected).toEqual(["A", "B"]);
79
+ });
80
+
81
+ it("lets the chain continue below the limit", async () => {
82
+ const result = await screenToggleFlagAction(
83
+ toggleAction({ max_items: 2 }),
84
+ context("A") as never
85
+ );
86
+
87
+ expect(result).toBe(ActionResult.Success);
88
+ expect(mockSelected).toEqual(["A"]);
89
+ });
90
+ });
@@ -27,12 +27,10 @@ export const screenToggleFlagAction: ActionHandler<
27
27
  const screenRoute = context?.screenRoute;
28
28
  const screenStateStore = context?.screenStateStore;
29
29
 
30
- await screenToggleFlag(
30
+ return await screenToggleFlag(
31
31
  screenRoute,
32
32
  screenStateStore,
33
33
  { entry: context?.entry, options: action.options },
34
34
  action
35
35
  );
36
-
37
- return ActionResult.Success;
38
36
  };
@@ -47,6 +47,11 @@ function makeMultiSelect(feed: ZappFeed, key, decoratedFeed) {
47
47
  ? "sessionStorageToggleFlag"
48
48
  : "localStorageToggleFlag";
49
49
 
50
+ // the toggle actions read their limit from their own options
51
+ const maxItems = Number(
52
+ decoratedFeed.extensions?.preference_editor_options?.max_items
53
+ );
54
+
50
55
  decoratedFeed.entry?.forEach((entry: ZappEntry) => {
51
56
  entry.type.value = "action";
52
57
  entry.id = entry.extensions?.tag || entry.id;
@@ -59,6 +64,7 @@ function makeMultiSelect(feed: ZappFeed, key, decoratedFeed) {
59
64
  type: actionType,
60
65
  options: {
61
66
  key,
67
+ ...(maxItems > 0 && { max_items: maxItems }),
62
68
  },
63
69
  },
64
70
  ],
@@ -0,0 +1,62 @@
1
+ import { PlayerAnalyticsTracker } from "../playerAnalyticsTracker";
2
+ import { PLAYBACK_EVENT } from "../events";
3
+
4
+ const mockPostAnalyticEvent = jest.fn();
5
+
6
+ jest.mock("../manager", () => ({
7
+ postAnalyticEvent: (...args) => mockPostAnalyticEvent(...args),
8
+ }));
9
+
10
+ describe("Player Analytics Safeguards against nativeEvent leakage", () => {
11
+ beforeEach(() => {
12
+ jest.clearAllMocks();
13
+ });
14
+
15
+ const mockEntry = {
16
+ id: "vod_123",
17
+ title: "Test Video",
18
+ type: { value: "video" },
19
+ content: { type: "video/mp4" },
20
+ extensions: { duration: 120 },
21
+ };
22
+
23
+ const mockPlayer = {
24
+ playerPluginId: "quick_brick_player",
25
+ getEntry: () => mockEntry,
26
+ getState: () => ({ isPaused: false, contentPosition: 0.63 }),
27
+ };
28
+
29
+ it("omits nativeEvent from analytics payload if present in eventData", () => {
30
+ const tracker = new PlayerAnalyticsTracker();
31
+ tracker.setPlayer(mockPlayer as any);
32
+
33
+ const payload = tracker.prepareAnalyticPayload(PLAYBACK_EVENT.play, {
34
+ nativeEvent: { currentTime: 0.63, reason: "video_ready" },
35
+ player_type: "quick_brick_player",
36
+ } as any);
37
+
38
+ expect(payload.nativeEvent).toBeUndefined();
39
+ expect(payload.media_id).toBe("vod_123");
40
+ expect(payload.name).toBe("Test Video");
41
+ expect(payload.player_type).toBe("quick_brick_player");
42
+ });
43
+
44
+ it("properly prepares payload when eventData is unwrapped", () => {
45
+ const tracker = new PlayerAnalyticsTracker();
46
+ tracker.setPlayer(mockPlayer as any);
47
+
48
+ const payload = tracker.prepareAnalyticPayload(PLAYBACK_EVENT.play, {
49
+ currentTime: 0.63,
50
+ reason: "video_ready",
51
+ player_type: "quick_brick_player",
52
+ } as any);
53
+
54
+ expect(payload.nativeEvent).toBeUndefined();
55
+ expect(payload.currentTime).toBeUndefined();
56
+ expect(payload.current_position).toBe(0.63);
57
+ expect(payload.reason).toBe("video_ready");
58
+ expect(payload.media_id).toBe("vod_123");
59
+ expect(payload.name).toBe("Test Video");
60
+ expect(payload.player_type).toBe("quick_brick_player");
61
+ });
62
+ });
@@ -214,6 +214,7 @@ export class PlayerAnalyticsTracker implements PlayerAnalyticsTrackerI {
214
214
  "isLive",
215
215
  "event",
216
216
  "entry",
217
+ "nativeEvent",
217
218
  ],
218
219
  eventData
219
220
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-utils",
3
- "version": "16.0.0-rc.125",
3
+ "version": "16.0.0-rc.126",
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-rc.125",
30
+ "@applicaster/applicaster-types": "16.0.0-rc.126",
31
31
  "buffer": "^5.2.1",
32
32
  "camelize": "^1.0.0",
33
33
  "dayjs": "^1.11.10",
@@ -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
 
@@ -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) {
@@ -19,14 +19,6 @@ export const useLoadPipesDataDispatch = () => {
19
19
  (screenData as LegacyNavigationScreenData)?.targetScreen?.id ??
20
20
  screenData?.id;
21
21
 
22
- const onLoadCB = (options) => (data, _err) => {
23
- options?.callback?.();
24
-
25
- if (data) {
26
- applyScreenRouteDefaults(data, screenStateStore, pathname);
27
- }
28
- };
29
-
30
22
  return React.useCallback(
31
23
  (
32
24
  url: string,
@@ -36,6 +28,9 @@ export const useLoadPipesDataDispatch = () => {
36
28
  clearCache?: boolean;
37
29
  silentRefresh?: boolean;
38
30
  parentFeed?: string;
31
+ method?: "get" | "post";
32
+ bodyParams?: Record<string, unknown>;
33
+ loadLocalFavorites?: boolean;
39
34
  } = {},
40
35
  {
41
36
  withResolvers = false,
@@ -46,18 +41,24 @@ export const useLoadPipesDataDispatch = () => {
46
41
  } = {}
47
42
  ) => {
48
43
  if (url) {
49
- dispatch(
44
+ return dispatch(
50
45
  ZappPipes.loadPipesData(url, {
51
46
  riverId,
52
47
  resolvers: withResolvers ? resolvers : undefined,
53
48
  ...options,
54
49
  callback: withScreenRouteMapping
55
- ? onLoadCB(options)
50
+ ? (data, error) => {
51
+ options.callback?.(data, error);
52
+
53
+ if (data) {
54
+ applyScreenRouteDefaults(data, screenStateStore, pathname);
55
+ }
56
+ }
56
57
  : options?.callback,
57
58
  })
58
59
  );
59
60
  }
60
61
  },
61
- []
62
+ [dispatch, riverId, resolvers, screenStateStore, pathname]
62
63
  );
63
64
  };