@applicaster/zapp-react-native-utils 15.0.0-alpha.9102699023 → 15.0.0-alpha.9102777840
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/README.md +0 -6
- package/appUtils/focusManagerAux/utils/index.ts +2 -4
- package/appUtils/focusManagerAux/utils/utils.ios.ts +6 -3
- package/manifestUtils/defaultManifestConfigurations/player.js +6 -0
- package/numberUtils/__tests__/toNumber.test.ts +27 -12
- package/numberUtils/__tests__/toPositiveNumber.test.ts +32 -4
- package/numberUtils/index.ts +5 -1
- package/package.json +2 -2
- package/reactHooks/feed/index.ts +0 -2
- package/reactHooks/state/__tests__/useComponentScreenState.test.ts +246 -0
- package/reactHooks/state/index.ts +2 -0
- package/reactHooks/state/useComponentScreenState.ts +45 -0
- package/refreshUtils/RefreshCoordinator/__tests__/refreshCoordinator.test.ts +161 -0
- package/refreshUtils/RefreshCoordinator/index.ts +216 -0
- package/refreshUtils/RefreshCoordinator/utils/__tests__/getDataRefreshConfig.test.ts +104 -0
- package/refreshUtils/RefreshCoordinator/utils/index.ts +29 -0
- package/screenPickerUtils/index.ts +5 -0
- package/screenUtils/index.ts +3 -0
- package/utils/__tests__/clone.test.ts +158 -0
- package/utils/__tests__/path.test.ts +7 -0
- package/utils/clone.ts +7 -0
- package/utils/index.ts +2 -1
- package/reactHooks/feed/__tests__/useFeedRefresh.test.tsx +0 -75
- package/reactHooks/feed/useFeedRefresh.tsx +0 -65
package/README.md
CHANGED
|
@@ -245,12 +245,6 @@ const connectionType = useConnectionInfo(true);
|
|
|
245
245
|
|
|
246
246
|
`@applicaster/zapp-react-native/reactHooks`
|
|
247
247
|
|
|
248
|
-
- `useFeedRefresh: ({ reloadData: function, component: { id: boolean | string, rules: {enable_data_refreshing: boolean, refreshing_interval: number} } }) => void` - Hook will call `reloadData` function, in the specified intervals if `enable_data_refreshing` is set to true;
|
|
249
|
-
|
|
250
|
-
```javascript
|
|
251
|
-
useFeedRefresh({ reloadData, component });
|
|
252
|
-
```
|
|
253
|
-
|
|
254
248
|
- `useFeedLoader: ({ feedUrl: string, pipesOptions?: { clearCache?: boolean, loadLocalFavourites?: boolean, silentRefresh?: boolean} }) => ({data: ?ApplicasterFeed, loading: boolean, url: string, error: Error,reloadData: (silentRefresh?: boolean) => void, loadNext: () => void})` - Hook will load data to the redux store and return a feed for the provided DSP URL. If the data for the provided url was already loaded, it will return that value
|
|
255
249
|
|
|
256
250
|
```javascript
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
15
|
getFocusableId,
|
|
16
|
-
|
|
16
|
+
isTabsScreenContentContainerId,
|
|
17
17
|
} from "@applicaster/zapp-react-native-utils/screenPickerUtils";
|
|
18
18
|
|
|
19
19
|
// run check each 300 ms
|
|
@@ -24,8 +24,6 @@ const isTopMenu = (node) => startsWith(QUICK_BRICK_NAVBAR, node?.id);
|
|
|
24
24
|
const isContent = (node) => startsWith(QUICK_BRICK_CONTENT, node?.id);
|
|
25
25
|
const isRoot = (node) => node?.id === "root";
|
|
26
26
|
|
|
27
|
-
const isScrenPicker = (node) => startsWith(SCREEN_PICKER_CONTAINER, node?.id);
|
|
28
|
-
|
|
29
27
|
type Props = {
|
|
30
28
|
maxTimeout: number;
|
|
31
29
|
conditionFn: () => boolean;
|
|
@@ -136,7 +134,7 @@ export const isTabsScreenOnContentFocused = (node) => {
|
|
|
136
134
|
return false;
|
|
137
135
|
}
|
|
138
136
|
|
|
139
|
-
if (
|
|
137
|
+
if (isTabsScreenContentContainerId(node?.id)) {
|
|
140
138
|
return true;
|
|
141
139
|
}
|
|
142
140
|
|
|
@@ -106,9 +106,8 @@ const focusableNativeViewRegistration = ({ focusableView, focusableGroup }) => {
|
|
|
106
106
|
);
|
|
107
107
|
};
|
|
108
108
|
|
|
109
|
-
export const
|
|
109
|
+
export const firstFocusableViewInContentRegistrationFactory = () =>
|
|
110
110
|
focusableViewRegistrationSubject$.pipe(
|
|
111
|
-
take(1), // we care about only first FocusableView registration
|
|
112
111
|
switchMap((focusableView) =>
|
|
113
112
|
// start waiting registration of its parent FocusableGroup
|
|
114
113
|
focusableGroupRegistrationSubject$.pipe(
|
|
@@ -126,7 +125,11 @@ export const firstFocusableViewRegistrationFactory = () =>
|
|
|
126
125
|
focusableView,
|
|
127
126
|
focusableGroup,
|
|
128
127
|
})
|
|
129
|
-
)
|
|
128
|
+
),
|
|
129
|
+
filter(({ focusableView }) =>
|
|
130
|
+
isPartOfContent(focusManager.focusableTree, focusableView.id)
|
|
131
|
+
),
|
|
132
|
+
take(1) // we care about only first FocusableView registration
|
|
130
133
|
);
|
|
131
134
|
|
|
132
135
|
// registration on RN level(into RN focusManager)
|
|
@@ -123,6 +123,12 @@ function getPlayerConfiguration({ platform, version }) {
|
|
|
123
123
|
key: "skip_button_localization_text_skip_intro",
|
|
124
124
|
initial_value: "Skip Intro",
|
|
125
125
|
},
|
|
126
|
+
{
|
|
127
|
+
type: "text_input",
|
|
128
|
+
label: "Stream error message",
|
|
129
|
+
key: "stream_error_message",
|
|
130
|
+
initial_value: "Cannot play stream",
|
|
131
|
+
},
|
|
126
132
|
{
|
|
127
133
|
type: "text_input",
|
|
128
134
|
label: "Locked message",
|
|
@@ -11,16 +11,6 @@ describe("toNumber", () => {
|
|
|
11
11
|
});
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
it("return number if input is BigInt", () => {
|
|
15
|
-
const inputs = [5n, -5n];
|
|
16
|
-
expect.assertions(inputs.length);
|
|
17
|
-
|
|
18
|
-
inputs.forEach((input) => {
|
|
19
|
-
const output = toNumber(input);
|
|
20
|
-
expect(output).toBe(Number(input));
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
|
|
24
14
|
it("return number if input is string as number", () => {
|
|
25
15
|
const inputs = ["-1", "0", "1", "100", "0.2"];
|
|
26
16
|
expect.assertions(inputs.length);
|
|
@@ -34,6 +24,8 @@ describe("toNumber", () => {
|
|
|
34
24
|
it("return undefined if input is not a number", () => {
|
|
35
25
|
const inputs = [
|
|
36
26
|
"vfdvf",
|
|
27
|
+
"5n",
|
|
28
|
+
"-5n",
|
|
37
29
|
null,
|
|
38
30
|
undefined,
|
|
39
31
|
NaN,
|
|
@@ -42,8 +34,6 @@ describe("toNumber", () => {
|
|
|
42
34
|
{ test: 1 },
|
|
43
35
|
[],
|
|
44
36
|
[1],
|
|
45
|
-
"5n",
|
|
46
|
-
"-5n",
|
|
47
37
|
];
|
|
48
38
|
|
|
49
39
|
expect.assertions(inputs.length);
|
|
@@ -53,4 +43,29 @@ describe("toNumber", () => {
|
|
|
53
43
|
expect(output).toBeUndefined();
|
|
54
44
|
});
|
|
55
45
|
});
|
|
46
|
+
|
|
47
|
+
describe("BigInt support", () => {
|
|
48
|
+
// Conditional test based on BigInt availability
|
|
49
|
+
const isBigIntSupported = typeof BigInt !== "undefined";
|
|
50
|
+
|
|
51
|
+
if (isBigIntSupported) {
|
|
52
|
+
it("converts BigInt to number when BigInt is supported", () => {
|
|
53
|
+
expect(toNumber(BigInt(5))).toBe(5);
|
|
54
|
+
expect(toNumber(BigInt(0))).toBe(0);
|
|
55
|
+
expect(toNumber(BigInt(-10))).toBe(-10);
|
|
56
|
+
expect(toNumber(BigInt(1000))).toBe(1000);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("handles large BigInt values that may lose precision", () => {
|
|
60
|
+
const largeBigInt = BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1);
|
|
61
|
+
const result = toNumber(largeBigInt);
|
|
62
|
+
expect(result).toBe(Number(largeBigInt));
|
|
63
|
+
});
|
|
64
|
+
} else {
|
|
65
|
+
it("skips BigInt tests when BigInt is not supported", () => {
|
|
66
|
+
// Placeholder test to indicate BigInt is not available
|
|
67
|
+
expect(typeof BigInt).toBe("undefined");
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
});
|
|
56
71
|
});
|
|
@@ -156,10 +156,38 @@ describe("toPositiveNumber", () => {
|
|
|
156
156
|
it("handles Symbol values", () => {
|
|
157
157
|
expect(toPositiveNumber(Symbol("test"))).toBeUndefined();
|
|
158
158
|
});
|
|
159
|
+
});
|
|
159
160
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
describe("BigInt support", () => {
|
|
162
|
+
// Conditional test based on BigInt availability
|
|
163
|
+
const isBigIntSupported = typeof BigInt !== "undefined";
|
|
164
|
+
|
|
165
|
+
if (isBigIntSupported) {
|
|
166
|
+
it("converts positive BigInt values to numbers when BigInt is supported", () => {
|
|
167
|
+
expect(toPositiveNumber(BigInt(5))).toBe(5);
|
|
168
|
+
expect(toPositiveNumber(BigInt(100))).toBe(100);
|
|
169
|
+
expect(toPositiveNumber(BigInt(1000))).toBe(1000);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("returns undefined for zero BigInt", () => {
|
|
173
|
+
expect(toPositiveNumber(BigInt(0))).toBeUndefined();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("returns undefined for negative BigInt values", () => {
|
|
177
|
+
expect(toPositiveNumber(BigInt(-5))).toBeUndefined();
|
|
178
|
+
expect(toPositiveNumber(BigInt(-100))).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("handles large positive BigInt values", () => {
|
|
182
|
+
const largeBigInt = BigInt(Number.MAX_SAFE_INTEGER);
|
|
183
|
+
const result = toPositiveNumber(largeBigInt);
|
|
184
|
+
expect(result).toBe(Number(largeBigInt));
|
|
185
|
+
});
|
|
186
|
+
} else {
|
|
187
|
+
it("skips BigInt tests when BigInt is not supported", () => {
|
|
188
|
+
// Placeholder test to indicate BigInt is not available
|
|
189
|
+
expect(typeof BigInt).toBe("undefined");
|
|
190
|
+
});
|
|
191
|
+
}
|
|
164
192
|
});
|
|
165
193
|
});
|
package/numberUtils/index.ts
CHANGED
|
@@ -8,7 +8,11 @@ export const toNumber = (value: unknown): number | undefined => {
|
|
|
8
8
|
return undefined;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// Feature-detect BigInt support to avoid ReferenceError on older runtimes
|
|
12
|
+
const isBigIntSupported = typeof BigInt !== "undefined";
|
|
13
|
+
const isBigIntValue = isBigIntSupported && R.is(BigInt, value);
|
|
14
|
+
|
|
15
|
+
if (R.is(Number, value) || isBigIntValue || R.is(String, value)) {
|
|
12
16
|
const numberOrNan = Number(value);
|
|
13
17
|
|
|
14
18
|
return Number.isNaN(numberOrNan) ? undefined : numberOrNan;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@applicaster/zapp-react-native-utils",
|
|
3
|
-
"version": "15.0.0-alpha.
|
|
3
|
+
"version": "15.0.0-alpha.9102777840",
|
|
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": "15.0.0-alpha.
|
|
30
|
+
"@applicaster/applicaster-types": "15.0.0-alpha.9102777840",
|
|
31
31
|
"buffer": "^5.2.1",
|
|
32
32
|
"camelize": "^1.0.0",
|
|
33
33
|
"dayjs": "^1.11.10",
|
package/reactHooks/feed/index.ts
CHANGED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { renderHook, act } from "@testing-library/react-native";
|
|
2
|
+
import { useComponentScreenState } from "../useComponentScreenState";
|
|
3
|
+
import { useScreenContextV2 } from "@applicaster/zapp-react-native-utils/reactHooks/screen/useScreenContext";
|
|
4
|
+
|
|
5
|
+
import { create } from "zustand";
|
|
6
|
+
|
|
7
|
+
const createMockStore = () => create(() => ({}));
|
|
8
|
+
|
|
9
|
+
// Mock the external dependencies
|
|
10
|
+
jest.mock(
|
|
11
|
+
"@applicaster/zapp-react-native-utils/reactHooks/screen/useScreenContext",
|
|
12
|
+
() => ({
|
|
13
|
+
useScreenContextV2: jest.fn(),
|
|
14
|
+
})
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const mockUseScreenContextV2 = useScreenContextV2 as jest.MockedFunction<
|
|
18
|
+
typeof useScreenContextV2
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
describe("useComponentScreenState", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
jest.clearAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should return initial value when no state exists for componentId", () => {
|
|
27
|
+
const mockStore = createMockStore();
|
|
28
|
+
|
|
29
|
+
mockUseScreenContextV2.mockReturnValue({
|
|
30
|
+
_componentStateStore: mockStore,
|
|
31
|
+
} as any);
|
|
32
|
+
|
|
33
|
+
const initialValue = "default-value";
|
|
34
|
+
const componentId = "test-component";
|
|
35
|
+
|
|
36
|
+
const { result } = renderHook(() =>
|
|
37
|
+
useComponentScreenState(componentId, initialValue)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(result.current[0]).toBe(initialValue);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should return stored value when state exists for componentId", () => {
|
|
44
|
+
const mockStore = createMockStore();
|
|
45
|
+
mockStore.setState({ "existing-component": "stored-value" });
|
|
46
|
+
|
|
47
|
+
mockUseScreenContextV2.mockReturnValue({
|
|
48
|
+
_componentStateStore: mockStore,
|
|
49
|
+
} as any);
|
|
50
|
+
|
|
51
|
+
const { result } = renderHook(() =>
|
|
52
|
+
useComponentScreenState("existing-component", "default")
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(result.current[0]).toBe("stored-value");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should update state when setter is called", () => {
|
|
59
|
+
const mockStore = createMockStore();
|
|
60
|
+
|
|
61
|
+
mockUseScreenContextV2.mockReturnValue({
|
|
62
|
+
_componentStateStore: mockStore,
|
|
63
|
+
} as any);
|
|
64
|
+
|
|
65
|
+
const { result } = renderHook(() =>
|
|
66
|
+
useComponentScreenState("update-test", "initial")
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Verify initial value
|
|
70
|
+
expect(result.current[0]).toBe("initial");
|
|
71
|
+
|
|
72
|
+
// Update the value
|
|
73
|
+
act(() => {
|
|
74
|
+
result.current[1]("updated-value");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Verify updated value
|
|
78
|
+
expect(result.current[0]).toBe("updated-value");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should handle different data types correctly", () => {
|
|
82
|
+
const mockStore = createMockStore();
|
|
83
|
+
|
|
84
|
+
mockUseScreenContextV2.mockReturnValue({
|
|
85
|
+
_componentStateStore: mockStore,
|
|
86
|
+
} as any);
|
|
87
|
+
|
|
88
|
+
// Test with number
|
|
89
|
+
const { result: numberResult } = renderHook(() =>
|
|
90
|
+
useComponentScreenState<number>("number-test", 42)
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(numberResult.current[0]).toBe(42);
|
|
94
|
+
|
|
95
|
+
// Test with boolean
|
|
96
|
+
const { result: boolResult } = renderHook(() =>
|
|
97
|
+
useComponentScreenState<boolean>("bool-test", true)
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(boolResult.current[0]).toBe(true);
|
|
101
|
+
|
|
102
|
+
// Test with object
|
|
103
|
+
const testObj = { key: "value" };
|
|
104
|
+
|
|
105
|
+
const { result: objResult } = renderHook(() =>
|
|
106
|
+
useComponentScreenState<{ key: string }>("obj-test", testObj)
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(objResult.current[0]).toEqual(testObj);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should maintain separate states for different componentIds", () => {
|
|
113
|
+
const mockStore = createMockStore();
|
|
114
|
+
|
|
115
|
+
mockUseScreenContextV2.mockReturnValue({
|
|
116
|
+
_componentStateStore: mockStore,
|
|
117
|
+
} as any);
|
|
118
|
+
|
|
119
|
+
const { result: firstResult } = renderHook(() =>
|
|
120
|
+
useComponentScreenState("first", "first-initial")
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const { result: secondResult } = renderHook(() =>
|
|
124
|
+
useComponentScreenState("second", "second-initial")
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Verify initial values are separate
|
|
128
|
+
expect(firstResult.current[0]).toBe("first-initial");
|
|
129
|
+
expect(secondResult.current[0]).toBe("second-initial");
|
|
130
|
+
|
|
131
|
+
// Update first component's state
|
|
132
|
+
act(() => {
|
|
133
|
+
firstResult.current[1]("first-updated");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Verify only first component's state changed
|
|
137
|
+
expect(firstResult.current[0]).toBe("first-updated");
|
|
138
|
+
expect(secondResult.current[0]).toBe("second-initial");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("should return a memoized setter function", () => {
|
|
142
|
+
const mockStore = createMockStore();
|
|
143
|
+
|
|
144
|
+
mockUseScreenContextV2.mockReturnValue({
|
|
145
|
+
_componentStateStore: mockStore,
|
|
146
|
+
} as any);
|
|
147
|
+
|
|
148
|
+
const { result, rerender } = renderHook(
|
|
149
|
+
({ id, initial }) => useComponentScreenState(id, initial),
|
|
150
|
+
{
|
|
151
|
+
initialProps: { id: "memo-test", initial: "initial" },
|
|
152
|
+
}
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const firstSetter = result.current[1];
|
|
156
|
+
|
|
157
|
+
// Rerender with same props
|
|
158
|
+
rerender({ id: "memo-test", initial: "initial" });
|
|
159
|
+
|
|
160
|
+
const secondSetter = result.current[1];
|
|
161
|
+
|
|
162
|
+
// Setter should be the same instance due to useCallback
|
|
163
|
+
expect(firstSetter).toBe(secondSetter);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("should update setter when componentId changes", () => {
|
|
167
|
+
const mockStore = createMockStore();
|
|
168
|
+
|
|
169
|
+
mockUseScreenContextV2.mockReturnValue({
|
|
170
|
+
_componentStateStore: mockStore,
|
|
171
|
+
} as any);
|
|
172
|
+
|
|
173
|
+
const { result, rerender } = renderHook(
|
|
174
|
+
({ id, initial }) => useComponentScreenState(id, initial),
|
|
175
|
+
{
|
|
176
|
+
initialProps: { id: "old-id", initial: "initial" },
|
|
177
|
+
}
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const firstSetter = result.current[1];
|
|
181
|
+
|
|
182
|
+
// Rerender with different componentId
|
|
183
|
+
rerender({ id: "new-id", initial: "initial" });
|
|
184
|
+
|
|
185
|
+
const secondSetter = result.current[1];
|
|
186
|
+
|
|
187
|
+
// Setter should be different because componentId changed
|
|
188
|
+
expect(firstSetter).not.toBe(secondSetter);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("should call store.setState with correct parameters", () => {
|
|
192
|
+
const mockStore = createMockStore();
|
|
193
|
+
const setStateSpy = jest.spyOn(mockStore, "setState");
|
|
194
|
+
|
|
195
|
+
mockUseScreenContextV2.mockReturnValue({
|
|
196
|
+
_componentStateStore: mockStore,
|
|
197
|
+
} as any);
|
|
198
|
+
|
|
199
|
+
const { result } = renderHook(() =>
|
|
200
|
+
useComponentScreenState("state-test", "initial")
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const newValue = "new-value";
|
|
204
|
+
|
|
205
|
+
act(() => {
|
|
206
|
+
result.current[1](newValue);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(setStateSpy).toHaveBeenCalledWith({
|
|
210
|
+
"state-test": newValue,
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("should handle falsy initial values correctly", () => {
|
|
215
|
+
const mockStore = createMockStore();
|
|
216
|
+
|
|
217
|
+
mockUseScreenContextV2.mockReturnValue({
|
|
218
|
+
_componentStateStore: mockStore,
|
|
219
|
+
} as any);
|
|
220
|
+
|
|
221
|
+
// Test with falsy values
|
|
222
|
+
const { result: nullResult } = renderHook(() =>
|
|
223
|
+
useComponentScreenState("null-test", null)
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
expect(nullResult.current[0]).toBeNull();
|
|
227
|
+
|
|
228
|
+
const { result: falseResult } = renderHook(() =>
|
|
229
|
+
useComponentScreenState("false-test", false)
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(falseResult.current[0]).toBe(false);
|
|
233
|
+
|
|
234
|
+
const { result: zeroResult } = renderHook(() =>
|
|
235
|
+
useComponentScreenState("zero-test", 0)
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(zeroResult.current[0]).toBe(0);
|
|
239
|
+
|
|
240
|
+
const { result: emptyStrResult } = renderHook(() =>
|
|
241
|
+
useComponentScreenState("empty-test", "")
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
expect(emptyStrResult.current[0]).toBe("");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { useScreenContextV2 } from "@applicaster/zapp-react-native-utils/reactHooks/screen/useScreenContext";
|
|
3
|
+
import { useStore } from "zustand";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A custom hook that provides persistent state storage across component re-mounts
|
|
7
|
+
* using the screen's component state store.
|
|
8
|
+
*
|
|
9
|
+
* @see {@link DOCS/adr/0010-useComponentScreenState.md} - ADR explaining why useComponentScreenState is used for List/Hero/Grid/Gallery components
|
|
10
|
+
*
|
|
11
|
+
* The state is shared across all components using the same componentId within the same screen context.
|
|
12
|
+
* If no value exists for the componentId, the initial value is returned as a fallback but is not persisted in the store.
|
|
13
|
+
* The initial value must be explicitly set using the returned setter function to persist it.
|
|
14
|
+
*
|
|
15
|
+
* @template T - The type of value being stored
|
|
16
|
+
* @param componentId - Unique identifier for this state in the component state store
|
|
17
|
+
* @param initialValue - The default value if no value exists for the componentId
|
|
18
|
+
* @returns A tuple containing the current value and a memoized setter function
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* const [count, setCount] = useComponentScreenState<number>('counter', 0);
|
|
23
|
+
* const [name, setName] = useComponentScreenState<string>('username', 'Anonymous');
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export const useComponentScreenState = <T = unknown>(
|
|
27
|
+
componentId: string,
|
|
28
|
+
initialValue: T
|
|
29
|
+
): [T, (value: T) => void] => {
|
|
30
|
+
const store = useScreenContextV2()._componentStateStore;
|
|
31
|
+
|
|
32
|
+
const value = useStore(
|
|
33
|
+
store,
|
|
34
|
+
(state) => (state[componentId] as T | undefined) ?? initialValue
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const setValue = React.useCallback(
|
|
38
|
+
(nextValue: T) => {
|
|
39
|
+
store.setState({ [componentId]: nextValue });
|
|
40
|
+
},
|
|
41
|
+
[componentId, store]
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return [value, setValue] as const;
|
|
45
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { refreshCoordinator } from "..";
|
|
2
|
+
import { getDataRefreshConfig } from "../utils";
|
|
3
|
+
import { Subscription } from "rxjs";
|
|
4
|
+
|
|
5
|
+
jest.mock("../utils", () => ({
|
|
6
|
+
getDataRefreshConfig: jest.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
describe("RefreshCoordinator", () => {
|
|
10
|
+
const component = { id: "c1" } as ZappUIComponent;
|
|
11
|
+
const screenId = "screen-1";
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
jest.clearAllMocks();
|
|
15
|
+
refreshCoordinator.clear();
|
|
16
|
+
jest.useFakeTimers();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
jest.runOnlyPendingTimers();
|
|
21
|
+
jest.useRealTimers();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// ----------------------------------------
|
|
25
|
+
// Singleton
|
|
26
|
+
// ----------------------------------------
|
|
27
|
+
it("returns the same instance via getInstance", () => {
|
|
28
|
+
const instance1 = refreshCoordinator;
|
|
29
|
+
const instance2 = (refreshCoordinator as any).constructor.getInstance();
|
|
30
|
+
expect(instance1).toBe(instance2);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ----------------------------------------
|
|
34
|
+
// register & unregister
|
|
35
|
+
// ----------------------------------------
|
|
36
|
+
it("register starts a timer and returns an unregister function", () => {
|
|
37
|
+
(getDataRefreshConfig as jest.Mock).mockReturnValue({
|
|
38
|
+
refreshIntervalMs: 1000,
|
|
39
|
+
isRefreshingEnabled: true,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const unregister = refreshCoordinator.register(component, screenId);
|
|
43
|
+
|
|
44
|
+
expect(typeof unregister).toBe("function");
|
|
45
|
+
expect((refreshCoordinator as any).timers.size).toBe(1);
|
|
46
|
+
|
|
47
|
+
// Calling unregister should remove timer
|
|
48
|
+
unregister();
|
|
49
|
+
expect((refreshCoordinator as any).timers.size).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("does not register if refreshing is disabled", () => {
|
|
53
|
+
(getDataRefreshConfig as jest.Mock).mockReturnValue({
|
|
54
|
+
refreshIntervalMs: 1000,
|
|
55
|
+
isRefreshingEnabled: false,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const unregister = refreshCoordinator.register(component, screenId);
|
|
59
|
+
|
|
60
|
+
expect(unregister).toBeInstanceOf(Function);
|
|
61
|
+
expect((refreshCoordinator as any).timers.size).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("replaces existing timer if component is re-registered", () => {
|
|
65
|
+
(getDataRefreshConfig as jest.Mock).mockReturnValue({
|
|
66
|
+
refreshIntervalMs: 1000,
|
|
67
|
+
isRefreshingEnabled: true,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const unregister1 = refreshCoordinator.register(component, screenId);
|
|
71
|
+
const unregister2 = refreshCoordinator.register(component, screenId);
|
|
72
|
+
|
|
73
|
+
expect((refreshCoordinator as any).timers.size).toBe(1);
|
|
74
|
+
expect(unregister1).toBeInstanceOf(Function);
|
|
75
|
+
expect(unregister2).toBeInstanceOf(Function);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ----------------------------------------
|
|
79
|
+
// subscribeTo
|
|
80
|
+
// ----------------------------------------
|
|
81
|
+
it("subscribeTo only triggers listener for matching component & screen", () => {
|
|
82
|
+
(getDataRefreshConfig as jest.Mock).mockReturnValue({
|
|
83
|
+
refreshIntervalMs: 1000,
|
|
84
|
+
isRefreshingEnabled: true,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const listener = jest.fn();
|
|
88
|
+
refreshCoordinator.register(component, screenId);
|
|
89
|
+
const sub = refreshCoordinator.subscribeTo(component, screenId, listener);
|
|
90
|
+
|
|
91
|
+
// Emit a refresh manually via private Subject
|
|
92
|
+
(refreshCoordinator as any).refresh$.next({ component, screenId });
|
|
93
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
94
|
+
|
|
95
|
+
// Non-matching component should not call listener
|
|
96
|
+
(refreshCoordinator as any).refresh$.next({
|
|
97
|
+
component: { id: "other" } as ZappUIComponent,
|
|
98
|
+
screenId,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
102
|
+
|
|
103
|
+
sub.unsubscribe();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ----------------------------------------
|
|
107
|
+
// subscribeToAny
|
|
108
|
+
// ----------------------------------------
|
|
109
|
+
it("subscribeToAny triggers listener with throttling and replaces previous listener", () => {
|
|
110
|
+
const listener1 = jest.fn();
|
|
111
|
+
const listener2 = jest.fn();
|
|
112
|
+
|
|
113
|
+
// First subscription
|
|
114
|
+
const sub1: Subscription = refreshCoordinator.subscribeToAny(listener1);
|
|
115
|
+
expect(sub1).toBeInstanceOf(Subscription);
|
|
116
|
+
|
|
117
|
+
// Second subscription replaces first
|
|
118
|
+
const sub2: Subscription = refreshCoordinator.subscribeToAny(listener2);
|
|
119
|
+
expect(sub2).toBeInstanceOf(Subscription);
|
|
120
|
+
|
|
121
|
+
// Emit multiple events quickly
|
|
122
|
+
(refreshCoordinator as any).refresh$.next({ component, screenId });
|
|
123
|
+
(refreshCoordinator as any).refresh$.next({ component, screenId });
|
|
124
|
+
|
|
125
|
+
// Because of throttleTime(500ms), fast events are ignored
|
|
126
|
+
jest.advanceTimersByTime(500);
|
|
127
|
+
expect(listener1).not.toHaveBeenCalled();
|
|
128
|
+
expect(listener2).toHaveBeenCalledTimes(1);
|
|
129
|
+
|
|
130
|
+
sub2.unsubscribe();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ----------------------------------------
|
|
134
|
+
// clear
|
|
135
|
+
// ----------------------------------------
|
|
136
|
+
it("clear stops all timers, anyListener, and recreates subject", () => {
|
|
137
|
+
(getDataRefreshConfig as jest.Mock).mockReturnValue({
|
|
138
|
+
refreshIntervalMs: 1000,
|
|
139
|
+
isRefreshingEnabled: true,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const listener = jest.fn();
|
|
143
|
+
refreshCoordinator.register(component, screenId);
|
|
144
|
+
refreshCoordinator.subscribeToAny(listener);
|
|
145
|
+
|
|
146
|
+
expect((refreshCoordinator as any).timers.size).toBe(1);
|
|
147
|
+
expect((refreshCoordinator as any).anyListenerSubscription).toBeDefined();
|
|
148
|
+
|
|
149
|
+
refreshCoordinator.clear();
|
|
150
|
+
|
|
151
|
+
expect((refreshCoordinator as any).timers.size).toBe(0);
|
|
152
|
+
expect((refreshCoordinator as any).anyListenerSubscription).toBeUndefined();
|
|
153
|
+
|
|
154
|
+
// New events on new Subject still work
|
|
155
|
+
const newListener = jest.fn();
|
|
156
|
+
refreshCoordinator.subscribeToAny(newListener);
|
|
157
|
+
(refreshCoordinator as any).refresh$.next({ component, screenId });
|
|
158
|
+
jest.advanceTimersByTime(500);
|
|
159
|
+
expect(newListener).toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
});
|