@applicaster/zapp-react-dom-app 16.0.0-rc.126 → 16.0.0-rc.128

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.
@@ -59,7 +59,7 @@ const globalAny: any = global;
59
59
 
60
60
  const { log_debug, log_warning, log_info } = createLogger({
61
61
  category: "InteractionManager",
62
- subsystem: "zapp-react-dom-app",
62
+ subsystem: "app/remote_control",
63
63
  });
64
64
 
65
65
  const shouldUseOnScreenKeyboard = () =>
@@ -0,0 +1,221 @@
1
+ import axios from "axios";
2
+
3
+ jest.mock("axios", () => ({ get: jest.fn() }));
4
+
5
+ const mockAppData = {
6
+ accountsAccountId: "account",
7
+ bundleIdentifier: "com.app",
8
+ store: "samsung",
9
+ versionName: "1.0",
10
+ appFamilyId: "family",
11
+ // appData.json is camelized
12
+ riversConfigurationId: "default-layout",
13
+ };
14
+
15
+ jest.mock("@applicaster/zapp-react-native-redux/AppStore", () => ({
16
+ appStore: { get: () => mockAppData },
17
+ }));
18
+
19
+ const get = axios.get as jest.Mock;
20
+
21
+ const appUrl =
22
+ "https://assets-secure.applicaster.com/zapp/accounts/account/apps/com.app/samsung_app_store/1.0";
23
+
24
+ const familyUrl =
25
+ "https://assets-secure.applicaster.com/zapp/accounts/account/app_families/family";
26
+
27
+ const storage = new Map<string, string>();
28
+
29
+ (global as any).window = {
30
+ localStorage: {
31
+ getItem: (key) => (storage.has(key) ? storage.get(key) : null),
32
+ setItem: (key, value) => storage.set(key, String(value)),
33
+ },
34
+ };
35
+
36
+ const cacheKey = (key: string) => `loader_cache_::_${key}`;
37
+
38
+ const ok = (data: unknown, etag?: string) =>
39
+ Promise.resolve({
40
+ status: 200,
41
+ data: JSON.stringify(data),
42
+ headers: { etag },
43
+ });
44
+
45
+ const requestedUrls = () => get.mock.calls.map(([url]) => url);
46
+
47
+ let AppLoaderBridge;
48
+
49
+ beforeEach(() => {
50
+ storage.clear();
51
+ get.mockReset();
52
+ process.env.ENABLE_REMOTE_DATA_IN_DEV = "true";
53
+
54
+ jest.isolateModules(() => {
55
+ require("../../AppData").setAppData(mockAppData);
56
+ AppLoaderBridge = require("..").AppLoaderBridge;
57
+ });
58
+ });
59
+
60
+ afterAll(() => {
61
+ delete process.env.ENABLE_REMOTE_DATA_IN_DEV;
62
+ });
63
+
64
+ describe("getFile", () => {
65
+ it("resolves the parsed file and caches its text and etag", async () => {
66
+ get.mockReturnValue(ok({ id: "config" }, "e1"));
67
+
68
+ await expect(
69
+ AppLoaderBridge.getFile("remoteConfigurations")
70
+ ).resolves.toEqual({ id: "config" });
71
+
72
+ expect(get).toHaveBeenCalledWith(
73
+ `${appUrl}/remote_configurations/remote_configurations.json`,
74
+ expect.objectContaining({ headers: { "If-None-Match": null } })
75
+ );
76
+
77
+ expect(storage.get(cacheKey("remoteConfigurations"))).toBe(
78
+ '{"id":"config"}'
79
+ );
80
+
81
+ expect(storage.get(cacheKey("remoteConfigurationsEtag"))).toBe("e1");
82
+ });
83
+
84
+ it("resolves the cached file when the server answers 304", async () => {
85
+ storage.set(cacheKey("pipesEndpoints"), '{"id":"cached"}');
86
+ storage.set(cacheKey("pipesEndpointsEtag"), "e1");
87
+ get.mockResolvedValue({ status: 304, data: "", headers: {} });
88
+
89
+ await expect(AppLoaderBridge.getFile("pipesEndpoints")).resolves.toEqual({
90
+ id: "cached",
91
+ });
92
+
93
+ expect(get).toHaveBeenCalledWith(
94
+ `${familyUrl}/data_source_providers/endpoints.json`,
95
+ expect.objectContaining({ headers: { "If-None-Match": "e1" } })
96
+ );
97
+ });
98
+
99
+ it("rejects with the configuration error when the body is not JSON", async () => {
100
+ get.mockResolvedValue({ status: 200, data: "<html>", headers: {} });
101
+
102
+ await expect(AppLoaderBridge.getFile("layout")).rejects.toThrow(
103
+ "cannot retrieve configuration data for layout"
104
+ );
105
+ });
106
+ });
107
+
108
+ describe("prefetchFiles", () => {
109
+ it("sends the general requests before returning, and getFile uses them", async () => {
110
+ get.mockImplementation((url) => ok({ url }));
111
+
112
+ AppLoaderBridge.prefetchFiles();
113
+
114
+ expect(requestedUrls()).toEqual([
115
+ `${appUrl}/remote_configurations/remote_configurations.json`,
116
+ `${appUrl}/plugin_configurations/plugin_configurations.json`,
117
+ `${familyUrl}/data_source_providers/endpoints.json`,
118
+ ]);
119
+
120
+ await expect(
121
+ AppLoaderBridge.getFile("pluginConfigurations")
122
+ ).resolves.toEqual({
123
+ url: `${appUrl}/plugin_configurations/plugin_configurations.json`,
124
+ });
125
+
126
+ expect(get).toHaveBeenCalledTimes(3);
127
+ });
128
+
129
+ it("also requests the layout files for a layout id stored by a previous launch", () => {
130
+ storage.set(cacheKey("currentLayoutId"), "stored-layout");
131
+ get.mockImplementation((url) => ok({ url }));
132
+
133
+ AppLoaderBridge.prefetchFiles();
134
+
135
+ expect(requestedUrls()).toEqual(
136
+ expect.arrayContaining([
137
+ `${appUrl}/layouts/stored-layout.json`,
138
+ `${familyUrl}/layouts/stored-layout/cell_styles.json`,
139
+ `${familyUrl}/layouts/stored-layout/presets_mapping.json`,
140
+ ])
141
+ );
142
+
143
+ expect(get).toHaveBeenCalledTimes(6);
144
+ });
145
+
146
+ it("falls back to the built-in layout id and stores it as the default", async () => {
147
+ get.mockImplementation((url) => ok({ url }));
148
+
149
+ await expect(AppLoaderBridge.getFile("layout")).resolves.toEqual({
150
+ url: `${appUrl}/layouts/default-layout.json`,
151
+ });
152
+
153
+ expect(storage.get(cacheKey("defaultLayoutId"))).toBe("default-layout");
154
+ });
155
+
156
+ it("requests a file again when its URL changed after the prefetch", async () => {
157
+ storage.set(cacheKey("currentLayoutId"), "old-layout");
158
+ get.mockImplementation((url) => ok({ url }));
159
+
160
+ AppLoaderBridge.prefetchFiles();
161
+ await AppLoaderBridge.switchLayout("new-layout");
162
+
163
+ await expect(AppLoaderBridge.getFile("layout")).resolves.toEqual({
164
+ url: `${appUrl}/layouts/new-layout.json`,
165
+ });
166
+
167
+ expect(get).toHaveBeenCalledTimes(7);
168
+ });
169
+
170
+ it("uses each prefetched request once", async () => {
171
+ get.mockImplementation((url) => ok({ url }));
172
+
173
+ AppLoaderBridge.prefetchFiles();
174
+ await AppLoaderBridge.getFile("remoteConfigurations");
175
+ await AppLoaderBridge.getFile("remoteConfigurations");
176
+
177
+ expect(get).toHaveBeenCalledTimes(4);
178
+ });
179
+
180
+ it("requests a file again when its prefetch failed", async () => {
181
+ get.mockImplementation(() => Promise.reject(new Error("network")));
182
+
183
+ AppLoaderBridge.prefetchFiles();
184
+
185
+ get.mockImplementation((url) => ok({ url }));
186
+
187
+ await expect(
188
+ AppLoaderBridge.getFile("remoteConfigurations")
189
+ ).resolves.toEqual({
190
+ url: `${appUrl}/remote_configurations/remote_configurations.json`,
191
+ });
192
+
193
+ expect(requestedUrls()[3]).toBe(
194
+ `${appUrl}/remote_configurations/remote_configurations.json`
195
+ );
196
+
197
+ expect(get).toHaveBeenCalledTimes(4);
198
+ });
199
+
200
+ it("rejects getFile with the configuration error when the prefetch and its retry failed", async () => {
201
+ get.mockImplementation(() => Promise.reject(new Error("network")));
202
+
203
+ AppLoaderBridge.prefetchFiles();
204
+
205
+ await expect(
206
+ AppLoaderBridge.getFile("remoteConfigurations")
207
+ ).rejects.toThrow(
208
+ "cannot retrieve configuration data for remoteConfigurations"
209
+ );
210
+
211
+ expect(get).toHaveBeenCalledTimes(4);
212
+ });
213
+
214
+ it("does nothing in development unless remote data is enabled", () => {
215
+ delete process.env.ENABLE_REMOTE_DATA_IN_DEV;
216
+
217
+ AppLoaderBridge.prefetchFiles();
218
+
219
+ expect(get).not.toHaveBeenCalled();
220
+ });
221
+ });
@@ -1,67 +1,115 @@
1
- import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
1
+ import axios, { AxiosResponse } from "axios";
2
+ import { getAppData } from "../AppData";
2
3
  import { getStorageModule } from "../Storage";
3
4
  import { STORAGE } from "./utils/enums";
4
- import { createUrl } from "./utils/utils";
5
+ import {
6
+ buildUrl,
7
+ createUrl,
8
+ getLayoutIds,
9
+ readLoaderCache,
10
+ writeLoaderCache,
11
+ } from "./utils/utils";
5
12
 
6
13
  type StorageResponse = string | null;
7
14
 
15
+ type Prefetched = { url: string; response: Promise<AxiosResponse<string>> };
16
+
8
17
  const localStorage: any = getStorageModule("localStorage");
9
18
 
19
+ const GENERAL_FILES = [
20
+ "remoteConfigurations",
21
+ "pluginConfigurations",
22
+ "pipesEndpoints",
23
+ ];
24
+
25
+ const LAYOUT_FILES = ["layout", "cellStyles", "presetsMapping"];
26
+
27
+ function requestFile(type: string, url: string) {
28
+ return axios.get<string>(url, {
29
+ headers: {
30
+ // Sent even without a stored etag (as "null"). A conditional header
31
+ // keeps the browser's HTTP cache out of these requests, so every launch
32
+ // gets the current file.
33
+ "If-None-Match": readLoaderCache(`${type}Etag`),
34
+ },
35
+ validateStatus: (status: number): boolean =>
36
+ status === 200 || status === 304,
37
+ // Keep the body as text: it is stored as-is and parsed once in getFile.
38
+ transformResponse: [(body) => body],
39
+ });
40
+ }
41
+
10
42
  export const AppLoaderBridge = (function () {
11
- async function getFile(type: string): Promise<string> {
43
+ // In-flight requests started by prefetchFiles, each used by the first
44
+ // getFile call for the same file and URL, which requests the file again
45
+ // if the prefetch failed.
46
+ const prefetched = new Map<string, Prefetched>();
47
+
48
+ /**
49
+ * Resolves the parsed file. Native bridges resolve a JSON string instead;
50
+ * getFile callers accept both, which saves serializing and re-parsing
51
+ * every file on web.
52
+ */
53
+ async function getFile(type: string): Promise<unknown> {
12
54
  const url: string = await createUrl(type);
55
+ const pending = prefetched.get(type);
56
+ prefetched.delete(type);
13
57
 
14
58
  try {
15
- const currentEtag = await localStorage.getItem(
16
- `${type}Etag`,
17
- STORAGE.APP_LOADER_BRIDGE_NAME_SPACE
18
- );
19
-
20
- const config: AxiosRequestConfig = {
21
- method: "get",
22
- url,
23
- headers: {
24
- "If-None-Match": currentEtag,
25
- },
26
- validateStatus: (status: number): boolean =>
27
- status === 200 || status === 304,
28
- };
29
-
30
- const response: AxiosResponse = await axios(config);
31
-
32
- const {
33
- data,
34
- headers: { etag },
35
- status,
36
- } = response;
59
+ const { data, headers, status } = await (pending?.url === url
60
+ ? pending.response.catch(() => requestFile(type, url))
61
+ : requestFile(type, url));
37
62
 
38
63
  if (status === 304) {
39
- const res = await localStorage.getItem(
40
- type,
41
- STORAGE.APP_LOADER_BRIDGE_NAME_SPACE
42
- );
43
-
44
- return JSON.stringify(res);
64
+ return readLoaderCache(type);
45
65
  }
46
66
 
47
- await localStorage.setItem(
48
- `${type}Etag`,
49
- etag,
50
- STORAGE.APP_LOADER_BRIDGE_NAME_SPACE
51
- );
67
+ const file = JSON.parse(data);
52
68
 
53
- await localStorage.setItem(
54
- type,
55
- JSON.stringify(data),
56
- STORAGE.APP_LOADER_BRIDGE_NAME_SPACE
57
- );
69
+ writeLoaderCache(`${type}Etag`, headers.etag);
70
+ writeLoaderCache(type, data);
58
71
 
59
- return JSON.stringify(data);
72
+ return file;
60
73
  } catch (error) {
61
74
  throw new Error("cannot retrieve configuration data for " + type);
62
75
  }
63
76
  }
64
77
 
78
+ /**
79
+ * Starts the configuration requests while the app bundle is still
80
+ * evaluating, so the files download during it instead of after it.
81
+ * Layout files are only requested for a layout id stored by a previous
82
+ * launch; without one, the remote configuration may select another layout.
83
+ * A file whose URL changes by the time getFile asks for it is requested again.
84
+ */
85
+ function prefetchFiles(): void {
86
+ // Mirrors appRemoteDataLoader, which skips remote data in development.
87
+ if (__DEV__ && process.env.ENABLE_REMOTE_DATA_IN_DEV !== "true") {
88
+ return;
89
+ }
90
+
91
+ try {
92
+ const appData = getAppData();
93
+ const { storedLayoutId } = getLayoutIds(appData);
94
+
95
+ const types = storedLayoutId
96
+ ? [...GENERAL_FILES, ...LAYOUT_FILES]
97
+ : GENERAL_FILES;
98
+
99
+ types.forEach((type) => {
100
+ const url = buildUrl(type, appData, storedLayoutId);
101
+ const response = requestFile(type, url);
102
+
103
+ // getFile reports failures; this only stops an early or unused
104
+ // failure from surfacing as an unhandled rejection.
105
+ response.catch(() => undefined);
106
+ prefetched.set(type, { url, response });
107
+ });
108
+ } catch (error) {
109
+ // Nothing prefetched; getFile requests the files as before.
110
+ }
111
+ }
112
+
65
113
  async function switchLayout(layoutID: string): Promise<boolean> {
66
114
  return await localStorage.setItem(
67
115
  STORAGE.CURRENT_LAYOUT_ID,
@@ -86,6 +134,7 @@ export const AppLoaderBridge = (function () {
86
134
 
87
135
  return {
88
136
  getFile,
137
+ prefetchFiles,
89
138
  switchLayout,
90
139
  getCurrentLayoutId,
91
140
  getDefaultLayoutId,
@@ -1,57 +1,68 @@
1
1
  import { appStore } from "@applicaster/zapp-react-native-redux/AppStore";
2
- import { getStorageModule } from "../../Storage";
2
+ import { parseJsonIfNeeded } from "@applicaster/zapp-react-native-utils/functionUtils";
3
+ import { applyNamespaceToKeyName } from "../../Storage";
3
4
  import { STORAGE, STORE_PLATFORM } from "./enums";
4
5
 
5
- const localStorage: any = getStorageModule("localStorage");
6
+ const cacheKey = (key: string) =>
7
+ applyNamespaceToKeyName(key, STORAGE.APP_LOADER_BRIDGE_NAME_SPACE);
6
8
 
7
- export async function createUrl(type: string): Promise<string> {
8
- const appData = appStore.get("appData");
9
+ /**
10
+ * Reads the loader cache with the same keys and parsing as
11
+ * getStorageModule("localStorage"), but synchronously. Web storage is
12
+ * synchronous, and prefetchFiles needs it that way: it runs while the app
13
+ * bundle is still evaluating, and an awaited read would only resume after it.
14
+ */
15
+ export function readLoaderCache(key: string) {
16
+ return parseJsonIfNeeded(window.localStorage.getItem(cacheKey(key)));
17
+ }
9
18
 
10
- const {
11
- accountsAccountId,
12
- bundleIdentifier,
13
- store,
14
- versionName,
15
- appFamilyId,
16
- } = appData;
19
+ export function writeLoaderCache(key: string, value: string) {
20
+ window.localStorage.setItem(cacheKey(key), value);
21
+ }
17
22
 
23
+ /**
24
+ * The layout id to request, split by where it comes from.
25
+ * `storedLayoutId` is only set when a previous launch stored one.
26
+ */
27
+ export function getLayoutIds(appData) {
18
28
  // appData.json is camelized, so the built-in id is riversConfigurationId.
19
29
  const defaultLayoutId =
20
30
  appData.riversConfigurationId || appData.rivers_configuration_id;
21
31
 
22
- const storedDefault = await localStorage.getItem(
23
- STORAGE.DEFAULT_LAYOUT_ID,
24
- STORAGE.APP_LOADER_BRIDGE_NAME_SPACE
25
- );
32
+ const storedDefault = readLoaderCache(STORAGE.DEFAULT_LAYOUT_ID);
26
33
 
27
34
  if ((!storedDefault || storedDefault === "undefined") && defaultLayoutId) {
28
- await localStorage.setItem(
29
- STORAGE.DEFAULT_LAYOUT_ID,
30
- defaultLayoutId,
31
- STORAGE.APP_LOADER_BRIDGE_NAME_SPACE
32
- );
35
+ writeLoaderCache(STORAGE.DEFAULT_LAYOUT_ID, defaultLayoutId);
33
36
  }
34
37
 
35
- const currentLayoutId = await localStorage.getItem(
36
- STORAGE.CURRENT_LAYOUT_ID,
37
- STORAGE.APP_LOADER_BRIDGE_NAME_SPACE
38
- );
38
+ const currentLayoutId = readLoaderCache(STORAGE.CURRENT_LAYOUT_ID);
39
39
 
40
- const remoteConfig: RemoteConfigurations = await localStorage.getItem(
41
- STORAGE.REMOTE_CONFIGURATIONS,
42
- STORAGE.APP_LOADER_BRIDGE_NAME_SPACE
40
+ const remoteConfig: RemoteConfigurations = readLoaderCache(
41
+ STORAGE.REMOTE_CONFIGURATIONS
43
42
  );
44
43
 
45
44
  const remoteConfigLayoutId = remoteConfig?.general_settings?.layout_id;
46
45
 
47
46
  /**
48
- * It is very important to note the order used in the customLayoutId property.
47
+ * It is very important to note the order used for the layout id (createUrl uses storedLayoutId || defaultLayoutId).
49
48
  * First we are attempting ot use the id set by layout plugin, it is an override of the defined layout.
50
49
  * Or use layout id from the remote configuration, remote config is loaded first and it has current layoutId from zapp.
51
50
  * Or use layout id that was built with the app, this id can be found in appData.json and is loaded into redux store.
52
51
  */
53
- const customLayoutId =
54
- currentLayoutId || remoteConfigLayoutId || defaultLayoutId;
52
+ return {
53
+ storedLayoutId: currentLayoutId || remoteConfigLayoutId,
54
+ defaultLayoutId,
55
+ };
56
+ }
57
+
58
+ export function buildUrl(type: string, appData, layoutId: string): string {
59
+ const {
60
+ accountsAccountId,
61
+ bundleIdentifier,
62
+ store,
63
+ versionName,
64
+ appFamilyId,
65
+ } = appData;
55
66
 
56
67
  const baseURL = `https://assets-secure.applicaster.com/zapp/accounts/${accountsAccountId}`;
57
68
  const urlTemplate = `apps/${bundleIdentifier}/${STORE_PLATFORM[store]}/${versionName}`;
@@ -62,14 +73,21 @@ export async function createUrl(type: string): Promise<string> {
62
73
  case "pluginConfigurations":
63
74
  return `${baseURL}/${urlTemplate}/plugin_configurations/plugin_configurations.json`;
64
75
  case "layout":
65
- return `${baseURL}/${urlTemplate}/layouts/${customLayoutId}.json`;
76
+ return `${baseURL}/${urlTemplate}/layouts/${layoutId}.json`;
66
77
  case "pipesEndpoints":
67
78
  return `${baseURL}/app_families/${appFamilyId}/data_source_providers/endpoints.json`;
68
79
  case "cellStyles":
69
- return `${baseURL}/app_families/${appFamilyId}/layouts/${customLayoutId}/cell_styles.json`;
80
+ return `${baseURL}/app_families/${appFamilyId}/layouts/${layoutId}/cell_styles.json`;
70
81
  case "presetsMapping":
71
- return `${baseURL}/app_families/${appFamilyId}/layouts/${customLayoutId}/presets_mapping.json`;
82
+ return `${baseURL}/app_families/${appFamilyId}/layouts/${layoutId}/presets_mapping.json`;
72
83
  default:
73
84
  return null;
74
85
  }
75
86
  }
87
+
88
+ export async function createUrl(type: string): Promise<string> {
89
+ const appData = appStore.get("appData");
90
+ const { storedLayoutId, defaultLayoutId } = getLayoutIds(appData);
91
+
92
+ return buildUrl(type, appData, storedLayoutId || defaultLayoutId);
93
+ }
@@ -8,7 +8,7 @@ import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
8
8
 
9
9
  const { log_error, log_debug, log_warning } = createLogger({
10
10
  category: "QuickBrickCommunicationModule",
11
- subsystem: "zapp-react-dom-app",
11
+ subsystem: "react_native/bridge",
12
12
  });
13
13
 
14
14
  const QuickBrickEventEmitter = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-dom-app",
3
- "version": "16.0.0-rc.126",
3
+ "version": "16.0.0-rc.128",
4
4
  "description": "Zapp App Component for Applicaster's Quick Brick React Native App",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -22,11 +22,11 @@
22
22
  },
23
23
  "homepage": "https://github.com/applicaster/zapp-react-dom-app#readme",
24
24
  "dependencies": {
25
- "@applicaster/zapp-react-dom-ui-components": "16.0.0-rc.126",
26
- "@applicaster/zapp-react-native-bridge": "16.0.0-rc.126",
27
- "@applicaster/zapp-react-native-redux": "16.0.0-rc.126",
28
- "@applicaster/zapp-react-native-ui-components": "16.0.0-rc.126",
29
- "@applicaster/zapp-react-native-utils": "16.0.0-rc.126",
25
+ "@applicaster/zapp-react-dom-ui-components": "16.0.0-rc.128",
26
+ "@applicaster/zapp-react-native-bridge": "16.0.0-rc.128",
27
+ "@applicaster/zapp-react-native-redux": "16.0.0-rc.128",
28
+ "@applicaster/zapp-react-native-ui-components": "16.0.0-rc.128",
29
+ "@applicaster/zapp-react-native-utils": "16.0.0-rc.128",
30
30
  "abortcontroller-polyfill": "^1.7.5",
31
31
  "typeface-montserrat": "^0.0.54",
32
32
  "video.js": "7.14.3",