@applicaster/zapp-react-native-utils 16.0.0-rc.101 → 16.0.0-rc.104

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-utils",
3
- "version": "16.0.0-rc.101",
3
+ "version": "16.0.0-rc.104",
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.101",
30
+ "@applicaster/applicaster-types": "16.0.0-rc.104",
31
31
  "buffer": "^5.2.1",
32
32
  "camelize": "^1.0.0",
33
33
  "dayjs": "^1.11.10",
@@ -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
+ });
@@ -3,46 +3,91 @@ import React from "react";
3
3
  import { getDatasourceUrl } from "@applicaster/zapp-react-native-ui-components/Decorators/RiverFeedLoader/utils/getDatasourceUrl";
4
4
  import { usePipesContexts } from "@applicaster/zapp-react-native-ui-components/Decorators/RiverFeedLoader/utils/usePipesContexts";
5
5
  import { markPipesDataStale } from "@applicaster/zapp-react-native-redux/ZappPipes";
6
-
7
- import { useRoute } from "../navigation";
8
6
  import { useAppDispatch } from "@applicaster/zapp-react-native-redux";
7
+ import { isTrue } from "../../booleanUtils";
8
+ import { refreshCoordinator } from "../../refreshUtils/RefreshCoordinator";
9
+ import { useNavigation, useRoute } from "../navigation";
10
+ import { usePrevious } from "../utils";
11
+
12
+ const getRefreshableComponents = (components: ZappUIComponent[]) =>
13
+ components?.filter(
14
+ (c) => isTrue(c?.rules?.clear_cache_on_reload) && c?.data?.source
15
+ ) || [];
9
16
 
10
17
  /**
11
18
  * Mark a river's `clear_cache_on_reload` feeds as stale when the screen is
12
19
  * unmounted. The cached data is kept (so a screen sharing the same feed does
13
20
  * not lose it mid-mount) and revalidated on next access.
21
+ *
22
+ * Also triggers refresh for mounted components and marks feeds stale when
23
+ * returning back to this screen from a deeper route in the Transitioner
24
+ * (where the screen was kept mounted in the background).
25
+ *
14
26
  * @param {string} riverId screen id
15
27
  * @param {Array} riverComponents list of UI components
16
28
  */
17
- export const useMarkPipesDataStale = (riverId, riverComponents) => {
29
+ export const useMarkPipesDataStale = (
30
+ riverId: string,
31
+ riverComponents: ZappUIComponent[]
32
+ ) => {
18
33
  const dispatch = useAppDispatch();
19
34
  const { screenData, pathname } = useRoute();
20
35
  const pipesContexts = usePipesContexts(riverId, pathname);
36
+ const { currentRoute } = useNavigation();
37
+ const prevRoute = usePrevious(currentRoute);
21
38
 
22
- React.useEffect(() => {
23
- return () => {
24
- riverComponents.forEach((component) => {
25
- if (
26
- component?.rules?.clear_cache_on_reload &&
27
- component?.data?.source &&
28
- // We don't want to refresh cache when closing/minimising videoModal
29
- !pathname.includes("videoModal")
30
- ) {
31
- const url = getDatasourceUrl(
32
- {
33
- screenData,
34
- ...pipesContexts,
35
- },
36
- component
37
- );
38
-
39
- if (url) {
40
- dispatch(markPipesDataStale(url, { riverId }));
41
- }
39
+ const isBackToScreen = Boolean(
40
+ prevRoute && prevRoute !== currentRoute && currentRoute === pathname
41
+ );
42
+
43
+ const invalidateComponents = React.useCallback(
44
+ (components: ZappUIComponent[]) => {
45
+ components.forEach((component) => {
46
+ const url = getDatasourceUrl(
47
+ { screenData, ...pipesContexts },
48
+ component
49
+ );
50
+
51
+ if (url) {
52
+ dispatch(markPipesDataStale(url, { riverId }));
42
53
  }
43
54
  });
55
+ },
56
+ [dispatch, screenData, pipesContexts, riverId]
57
+ );
58
+
59
+ // Stale invalidation on unmount (existing behavior)
60
+ /* eslint-disable @wogns3623/better-exhaustive-deps/exhaustive-deps */
61
+ React.useEffect(() => {
62
+ return () => {
63
+ if (pathname?.includes("videoModal")) return;
64
+
65
+ invalidateComponents(getRefreshableComponents(riverComponents));
44
66
  };
45
67
  }, []);
68
+ /* eslint-enable @wogns3623/better-exhaustive-deps/exhaustive-deps */
69
+
70
+ // Reload when returning back to this screen from another screen
71
+ React.useEffect(() => {
72
+ if (!isBackToScreen || pathname?.includes("videoModal")) {
73
+ return;
74
+ }
75
+
76
+ const refreshable = getRefreshableComponents(riverComponents);
77
+
78
+ if (refreshable.length === 0) {
79
+ return;
80
+ }
81
+
82
+ invalidateComponents(refreshable);
83
+ refreshCoordinator.triggerRefresh(refreshable, riverId);
84
+ }, [
85
+ isBackToScreen,
86
+ pathname,
87
+ riverComponents,
88
+ riverId,
89
+ invalidateComponents,
90
+ ]);
46
91
  };
47
92
 
48
93
  /**