@applicaster/zapp-react-native-utils 16.0.0-alpha.1593888345 → 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 (110) hide show
  1. package/actionUtils/index.ts +3 -1
  2. package/actionsExecutor/ActionExecutor.ts +10 -1
  3. package/actionsExecutor/ActionExecutorContext.tsx +3 -0
  4. package/actionsExecutor/__tests__/ActionExecutor.test.ts +71 -0
  5. package/actionsExecutor/__tests__/StorageActions.test.ts +98 -0
  6. package/actionsExecutor/__tests__/feedDecorator.test.ts +30 -0
  7. package/actionsExecutor/__tests__/screenToggleFlag.test.ts +90 -0
  8. package/actionsExecutor/actions/__tests__/navigateToScreen.test.ts +139 -5
  9. package/actionsExecutor/actions/__tests__/openBottomSheet.customContent.test.ts +24 -0
  10. package/actionsExecutor/actions/__tests__/openBottomSheet.inlineItems.test.ts +160 -0
  11. package/actionsExecutor/actions/__tests__/showAlert.test.ts +49 -0
  12. package/actionsExecutor/actions/__tests__/showToast.test.ts +2 -2
  13. package/actionsExecutor/actions/index.ts +2 -0
  14. package/actionsExecutor/actions/navigateToScreen.ts +29 -45
  15. package/actionsExecutor/actions/openBottomSheet.ts +21 -6
  16. package/actionsExecutor/actions/screenToggleFlag.ts +1 -3
  17. package/actionsExecutor/actions/showAlert.ts +51 -0
  18. package/actionsExecutor/actions/showToast.ts +2 -2
  19. package/actionsExecutor/consts.ts +1 -0
  20. package/actionsExecutor/feedDecorator.ts +6 -0
  21. package/analyticsUtils/__tests__/playerAnalyticsSafeguard.test.ts +62 -0
  22. package/analyticsUtils/playerAnalyticsTracker.ts +1 -0
  23. package/appUtils/contextKeysManager/__tests__/getKey/failure.test.ts +1 -22
  24. package/appUtils/contextKeysManager/__tests__/getKey/success/fullSearch.test.ts +1 -25
  25. package/appUtils/contextKeysManager/__tests__/getKey/success/searchInSecure.test.ts +1 -7
  26. package/appUtils/contextKeysManager/__tests__/removeKey/failure.test.ts +0 -25
  27. package/appUtils/contextKeysManager/__tests__/removeKey/success.test.ts +0 -6
  28. package/appUtils/contextKeysManager/__tests__/setKey/failure/failedToSaveInStorage.test.ts +0 -40
  29. package/appUtils/contextKeysManager/__tests__/setKey/{success/saveItemAndReference.test.ts → success.test.ts} +2 -42
  30. package/appUtils/contextKeysManager/index.ts +28 -129
  31. package/appUtils/playerManager/OverlayObserver/OverlaysObserver.ts +4 -11
  32. package/appUtils/playerManager/OverlayObserver/__tests__/getUpcomingQueue.test.ts +67 -0
  33. package/appUtils/playerManager/OverlayObserver/__tests__/utils.test.ts +9 -2
  34. package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.ts +15 -2
  35. package/appUtils/playerManager/OverlayObserver/utils.ts +2 -5
  36. package/appUtils/playerManager/__tests__/playerDiagnostics.test.ts +62 -0
  37. package/appUtils/playerManager/__tests__/playerNative.test.ts +76 -0
  38. package/appUtils/playerManager/__tests__/playerNativeCommand.test.ts +62 -0
  39. package/appUtils/playerManager/__tests__/playerToolbox.test.ts +281 -0
  40. package/appUtils/playerManager/__tests__/usePlayerControllerSetup.test.tsx +62 -0
  41. package/appUtils/playerManager/const.ts +13 -0
  42. package/appUtils/playerManager/index.ts +7 -0
  43. package/appUtils/playerManager/player.ts +25 -0
  44. package/appUtils/playerManager/playerNative.ts +22 -0
  45. package/appUtils/playerManager/playerNativeCommand.ts +3 -6
  46. package/appUtils/playerManager/playerToolbox.ts +196 -0
  47. package/appUtils/playerManager/usePlayerControllerSetup.tsx +6 -0
  48. package/arrayUtils/__tests__/reorderByIds.test.ts +13 -0
  49. package/arrayUtils/index.ts +26 -43
  50. package/cellUtils/__tests__/getButtonsEnabled.test.ts +28 -0
  51. package/cellUtils/__tests__/getCellNavigationEnabled.test.ts +36 -0
  52. package/cellUtils/__tests__/getIsCellNavigationEnabled.test.ts +35 -0
  53. package/cellUtils/__tests__/isCellNavigationEnabled.test.ts +21 -0
  54. package/cellUtils/index.ts +40 -0
  55. package/configurationUtils/__tests__/modalBlocksParser.test.ts +189 -46
  56. package/configurationUtils/modalBlocksParser.ts +57 -101
  57. package/entryActions/__tests__/buildEntryActions.test.ts +97 -18
  58. package/entryActions/aliasPresentation.ts +26 -42
  59. package/entryActions/buildEntryActions.ts +1 -0
  60. package/entryActions/index.ts +0 -2
  61. package/focusManager/FocusManager.ts +26 -0
  62. package/focusManager/__tests__/FocusManager.test.ts +9 -0
  63. package/localizationUtils/index.ts +12 -6
  64. package/logger/__tests__/xrayToolbox.test.ts +69 -0
  65. package/logger/xrayToolbox.ts +67 -0
  66. package/manifestUtils/defaultManifestConfigurations/player.js +8 -0
  67. package/manifestUtils/keys.js +7 -2
  68. package/manifestUtils/mobileAction/container/__tests__/mobileActionButtonsContainer.test.js +21 -0
  69. package/manifestUtils/mobileAction/groups/__tests__/buildMobileActionButtonGroups.test.js +1 -1
  70. package/manifestUtils/mobileAction/groups/defaults.js +2 -1
  71. package/modalState/ModalOrchestrator.ts +2 -0
  72. package/modalState/RemoteEditableCollection.ts +9 -1
  73. package/modalState/__tests__/ModalOrchestrator.phase3.test.ts +39 -0
  74. package/modalState/__tests__/RemoteEditableCollection.test.ts +90 -1
  75. package/modalState/__tests__/useModalStoreState.test.ts +142 -0
  76. package/modalState/index.ts +2 -0
  77. package/modalState/store.ts +9 -2
  78. package/modalState/types.ts +14 -0
  79. package/modalState/useBottomSheetContent.ts +17 -12
  80. package/navigationUtils/xrayNavigator.ts +12 -0
  81. package/package.json +3 -2
  82. package/pluginUtils/__tests__/pluginUtils.test.js +32 -0
  83. package/pluginUtils/index.ts +29 -0
  84. package/quickBrickAssets/__tests__/index.test.ts +18 -0
  85. package/quickBrickAssets/index.ts +35 -0
  86. package/reactHooks/__tests__/getPluginConfiguration.test.ts +82 -0
  87. package/reactHooks/actions/index.ts +29 -23
  88. package/reactHooks/cell-click/__tests__/index.test.js +64 -0
  89. package/reactHooks/cell-click/index.ts +21 -14
  90. package/reactHooks/connection/index.tsx +8 -5
  91. package/reactHooks/feed/__tests__/feedInvalidation.test.tsx +338 -0
  92. package/reactHooks/feed/__tests__/useBatchLoading.test.tsx +8 -2
  93. package/reactHooks/feed/__tests__/useMarkPipesDataStale.test.tsx +243 -0
  94. package/reactHooks/feed/useFeedLoader.tsx +28 -1
  95. package/reactHooks/feed/useLoadPipesDataDispatch.ts +12 -11
  96. package/reactHooks/feed/useMarkPipesDataStale.ts +68 -23
  97. package/reactHooks/navigation/__tests__/index.test.tsx +128 -1
  98. package/reactHooks/navigation/useRoute.ts +8 -4
  99. package/reactHooks/usePluginConfiguration.ts +84 -46
  100. package/riverComponetsMeasurementProvider/index.tsx +3 -1
  101. package/uiActionsRegistrator/__tests__/subscribe.test.ts +26 -0
  102. package/uiActionsRegistrator/registry.ts +16 -0
  103. package/appUtils/contextKeysManager/__tests__/getKey/success/byReference.test.ts +0 -56
  104. package/appUtils/contextKeysManager/__tests__/getKeyByReference/failure.test.ts +0 -265
  105. package/appUtils/contextKeysManager/__tests__/getKeyByReference/success.test.ts +0 -303
  106. package/appUtils/contextKeysManager/__tests__/removeReferenceForKey/failure.test.ts +0 -82
  107. package/appUtils/contextKeysManager/__tests__/removeReferenceForKey/success.test.ts +0 -41
  108. package/appUtils/contextKeysManager/__tests__/setKey/success/saveItemButNotReference.test.ts +0 -208
  109. package/appUtils/contextKeysManager/__tests__/setReferenceForKey/failure.test.ts +0 -93
  110. package/appUtils/contextKeysManager/__tests__/setReferenceForKey/success.test.ts +0 -45
@@ -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) {
@@ -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
  };
@@ -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
  /**
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { PathnameContext } from "@applicaster/zapp-react-native-ui-components/Contexts/PathnameContext";
3
- import { renderHook } from "@testing-library/react-native";
3
+ import { act, render, renderHook } from "@testing-library/react-native";
4
4
  import { isNavBarVisible, useRoute } from "../";
5
5
  import { Provider } from "react-redux";
6
6
  import configureMockStore from "redux-mock-store";
@@ -181,6 +181,45 @@ const hookModalWrapper = ({ children }) => (
181
181
  </Provider>
182
182
  );
183
183
 
184
+ // Reports every render of useRoute so the tests below can assert on route
185
+ // transitions and on subscription behaviour, not just on a one-shot result.
186
+ function RouteProbe({ onRender }: { onRender: (route: any) => void }) {
187
+ onRender(useRoute());
188
+
189
+ return null;
190
+ }
191
+
192
+ const openRiverModal = () =>
193
+ act(() => {
194
+ modalStore.getState().openModal({
195
+ item: rivers["river-general"] as any,
196
+ props: { entry: modalEntry },
197
+ });
198
+ });
199
+
200
+ const renderRouteProbe = (pathname: string, onRender: (route: any) => void) => {
201
+ const tree = (currentPathname: string) => (
202
+ <Provider store={store}>
203
+ <ScreenDataContext.Provider value={homeStack.state}>
204
+ <NavigationContext.Provider value={videoModalNavigator}>
205
+ <PathnameContext.Provider value={currentPathname}>
206
+ <RouteProbe onRender={onRender} />
207
+ </PathnameContext.Provider>
208
+ </NavigationContext.Provider>
209
+ </ScreenDataContext.Provider>
210
+ </Provider>
211
+ );
212
+
213
+ const { rerender } = render(tree(pathname));
214
+
215
+ return {
216
+ navigateTo: (nextPathname: string) =>
217
+ act(() => {
218
+ rerender(tree(nextPathname));
219
+ }),
220
+ };
221
+ };
222
+
184
223
  describe("navigation", () => {
185
224
  describe("useRoute", () => {
186
225
  describe("Main stack components", () => {
@@ -279,6 +318,94 @@ describe("navigation", () => {
279
318
  );
280
319
  });
281
320
  });
321
+
322
+ describe("store subscriptions", () => {
323
+ afterEach(() => {
324
+ act(() => {
325
+ modalStore.getState().dismissModal();
326
+ });
327
+ });
328
+
329
+ // Every hook in useRoute must run on every render. When the modal and
330
+ // hook-modal subscriptions lived inside the route branches, walking a
331
+ // single mounted component through the route types changed the hook
332
+ // count and React threw "Rendered more hooks than during the previous
333
+ // render".
334
+ it("keeps a stable hook order when the route type changes", () => {
335
+ let route: any;
336
+
337
+ const { navigateTo } = renderRouteProbe(homeStack.route, (current) => {
338
+ route = current;
339
+ });
340
+
341
+ expect(route.screenData.id).toEqual(entry.id);
342
+ expect(route.screenData.targetScreen.id).toEqual(screen.id);
343
+
344
+ openRiverModal();
345
+ navigateTo(modalPathname);
346
+
347
+ expect(route.pathname).toEqual(modalPathname);
348
+ expect(route.screenData.id).toEqual(modalEntry.id);
349
+
350
+ expect(route.screenData.targetScreen.id).toEqual(
351
+ rivers["river-general"].id
352
+ );
353
+
354
+ navigateTo(hooksModalPathname);
355
+
356
+ expect(route.pathname).toEqual(hooksModalPathname);
357
+
358
+ expect(route.screenData.id).toEqual(
359
+ hookModalContextState.state.screenData.payload.id
360
+ );
361
+
362
+ navigateTo(videoModalPathname);
363
+
364
+ expect(route.pathname).toEqual(videoModalPathname);
365
+ expect(route.screenData.id).toEqual(videoItem.id);
366
+
367
+ expect(route.screenData.targetScreen.id).toEqual(
368
+ rivers["river-player"].id
369
+ );
370
+
371
+ navigateTo(homeStack.route);
372
+
373
+ expect(route.pathname).toEqual(homeStack.route);
374
+ expect(route.screenData.id).toEqual(entry.id);
375
+ });
376
+
377
+ it("does not re-render a non-modal route when modal state changes", () => {
378
+ let renders = 0;
379
+
380
+ renderRouteProbe(homeStack.route, () => {
381
+ renders += 1;
382
+ });
383
+
384
+ const rendersBeforeOpen = renders;
385
+
386
+ openRiverModal();
387
+
388
+ expect(renders).toBe(rendersBeforeOpen);
389
+ });
390
+
391
+ it("re-renders a modal route when modal state changes", () => {
392
+ let route: any;
393
+
394
+ renderRouteProbe(modalPathname, (current) => {
395
+ route = current;
396
+ });
397
+
398
+ expect(route.screenData).toEqual({});
399
+
400
+ openRiverModal();
401
+
402
+ expect(route.screenData.id).toEqual(modalEntry.id);
403
+
404
+ expect(route.screenData.targetScreen.id).toEqual(
405
+ rivers["river-general"].id
406
+ );
407
+ });
408
+ });
282
409
  });
283
410
 
284
411
  describe("isNavBarVisible", () => {
@@ -50,14 +50,18 @@ export const useRoute = (
50
50
  const rivers = useRivers();
51
51
  const contentTypes = useContentTypes();
52
52
 
53
- const modalState = useModalStoreState();
54
-
55
- const modalScreenData = modalState.screen;
56
-
53
+ // useRoute runs in every cell, so both modal stores are subscribed to only on
54
+ // the routes that actually read them. Off those routes the selectors return a
55
+ // stable reference, which keeps an unrelated modal opening or closing from
56
+ // re-rendering the whole tree.
57
57
  const hookModalScreenData = useHookModalScreenData(
58
58
  !isHookModalPathname(pathname)
59
59
  );
60
60
 
61
+ const modalState = useModalStoreState(!isModalPathname(pathname));
62
+
63
+ const modalScreenData = modalState.screen;
64
+
61
65
  const videoModalScreenData =
62
66
  navigator?.videoModalState?.item &&
63
67
  legacyScreenData(