@applicaster/zapp-react-dom-app 16.0.0-rc.99 → 17.0.0-alpha.5804334713

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,319 @@
1
+ import { DISPLAY_STATES } from "@applicaster/zapp-react-native-ui-components/Contexts/DisplayStateContext";
2
+ import { confirmationDialogStore } from "@applicaster/zapp-react-native-ui-components/Contexts/ConfirmDialogState";
3
+ import {
4
+ focusManager,
5
+ KEYS,
6
+ playerManager,
7
+ } from "@applicaster/zapp-react-native-utils/appUtils";
8
+ import { KeyInputHandler } from "@applicaster/zapp-react-native-utils/appUtils/keyInputHandler/KeyInputHandler";
9
+ import { TVSeekController } from "@applicaster/zapp-react-native-utils/reactHooks/player/TVSeekControlller/TVSeekController";
10
+ import { SKIP_TIME_BASE } from "@applicaster/zapp-react-native-utils/reactHooks/player/const";
11
+ import { showConfirmationDialog } from "@applicaster/zapp-react-native-utils/alertUtils";
12
+
13
+ import { InteractionManagerClass } from "..";
14
+
15
+ // The web app runs with the web key map
16
+ jest.mock(
17
+ "@applicaster/zapp-react-native-utils/appUtils/keyCodes/keys/keys",
18
+ () =>
19
+ jest.requireActual(
20
+ "@applicaster/zapp-react-native-utils/appUtils/keyCodes/keys/keys.web"
21
+ )
22
+ );
23
+
24
+ jest.mock("@applicaster/zapp-react-native-utils/alertUtils", () => ({
25
+ showConfirmationDialog: jest.fn(),
26
+ }));
27
+
28
+ jest.mock(
29
+ "@applicaster/zapp-react-native-ui-components/Decorators/Navigator",
30
+ () => ({ withNavigator: (C) => C })
31
+ );
32
+
33
+ jest.mock("@applicaster/zapp-react-native-redux/utils/connectToStore", () => ({
34
+ connectToStore: () => (C) => C,
35
+ }));
36
+
37
+ jest.mock("@applicaster/zapp-react-native-utils/logger", () => {
38
+ const actual = jest.requireActual(
39
+ "@applicaster/zapp-react-native-utils/logger"
40
+ );
41
+
42
+ return {
43
+ ...actual,
44
+ getXray: () => ({ ...actual.getXray(), withXray: (C) => C }),
45
+ };
46
+ });
47
+
48
+ const globalAny: any = global;
49
+
50
+ const createBackEvent = () => ({
51
+ code: "Escape",
52
+ preventDefault: jest.fn(),
53
+ stopPropagation: jest.fn(),
54
+ });
55
+
56
+ const createManager = (props = {}) => {
57
+ const navigator = {
58
+ canGoBack: jest.fn(() => false),
59
+ goBack: jest.fn(),
60
+ replace: jest.fn(),
61
+ currentRoute: "/river/home",
62
+ };
63
+
64
+ const defaultProps = {
65
+ displayState: DISPLAY_STATES.DEFAULT,
66
+ setDisplayState: jest.fn(),
67
+ navigator,
68
+ rivers: {},
69
+ backToTopAction: jest.fn(() => "GO_BACK"),
70
+ emitFocusOnSelectedTopMenuItem: jest.fn(),
71
+ emitFocusOnSelectedTab: jest.fn(),
72
+ };
73
+
74
+ const mergedProps = { ...defaultProps, ...props };
75
+
76
+ return {
77
+ manager: new InteractionManagerClass(mergedProps as any),
78
+ props: mergedProps,
79
+ };
80
+ };
81
+
82
+ describe("InteractionManager (web)", () => {
83
+ beforeEach(() => {
84
+ jest.clearAllMocks();
85
+ (focusManager as any).setInitialFocus = jest.fn();
86
+ confirmationDialogStore.getState().hideDialog();
87
+ });
88
+
89
+ afterEach(() => {
90
+ delete globalAny.webOS;
91
+ });
92
+
93
+ describe("onBackPress with exit dialog visible", () => {
94
+ it.each([DISPLAY_STATES.DEFAULT, DISPLAY_STATES.PLAYER])(
95
+ "dismisses the dialog instead of navigating (displayState: %s)",
96
+ (displayState) => {
97
+ const { manager, props } = createManager({ displayState });
98
+ confirmationDialogStore.getState().showDialog();
99
+
100
+ const event = createBackEvent();
101
+ manager.onBackPress(event);
102
+
103
+ expect(confirmationDialogStore.getState().isDialogVisible).toBe(false);
104
+ expect(props.backToTopAction).not.toHaveBeenCalled();
105
+ expect(props.navigator.goBack).not.toHaveBeenCalled();
106
+ expect(event.preventDefault).toHaveBeenCalled();
107
+
108
+ expect(focusManager.setInitialFocus).toHaveBeenCalledWith(undefined, {
109
+ source: "cancel",
110
+ preserveScroll: true,
111
+ });
112
+ }
113
+ );
114
+
115
+ it("resolves the back action normally when the dialog is hidden", () => {
116
+ const { manager, props } = createManager();
117
+
118
+ manager.onBackPress(createBackEvent());
119
+
120
+ expect(props.backToTopAction).toHaveBeenCalled();
121
+ expect(props.navigator.canGoBack).toHaveBeenCalled();
122
+ });
123
+ });
124
+
125
+ describe("handleWebOsBack", () => {
126
+ it("uses the webOS platformBack API", () => {
127
+ const platformBack = jest.fn();
128
+ globalAny.webOS = { platformBack };
129
+
130
+ const { manager } = createManager();
131
+ manager.handleWebOsBack();
132
+
133
+ expect(platformBack).toHaveBeenCalledTimes(1);
134
+ expect(showConfirmationDialog).not.toHaveBeenCalled();
135
+ });
136
+
137
+ it("falls back to the custom exit dialog when platformBack is unavailable", () => {
138
+ globalAny.webOS = {};
139
+
140
+ const { manager } = createManager();
141
+ manager.handleWebOsBack();
142
+
143
+ expect(showConfirmationDialog).toHaveBeenCalledWith(
144
+ expect.objectContaining({ title: "" })
145
+ );
146
+ });
147
+ });
148
+
149
+ describe("Back at Home on VIDAA", () => {
150
+ it("shows the exit dialog and closes the window on confirm", () => {
151
+ const navigator = window.navigator;
152
+ const close = window.close;
153
+ window.close = jest.fn();
154
+
155
+ (window as any).navigator = {
156
+ userAgent: "Mozilla/5.0 Model/Hisense-MT9602 VIDAA/5.0",
157
+ };
158
+
159
+ const { manager } = createManager({
160
+ backToTopAction: jest.fn(() => "PLATFORM_TO_BACKGROUND"),
161
+ });
162
+
163
+ manager.onBackPress(createBackEvent());
164
+
165
+ expect(showConfirmationDialog).toHaveBeenCalledTimes(1);
166
+
167
+ (
168
+ showConfirmationDialog as jest.Mock
169
+ ).mock.calls[0][0].confirmCompletion();
170
+
171
+ expect(window.close).toHaveBeenCalledTimes(1);
172
+
173
+ (window as any).navigator = navigator;
174
+ window.close = close;
175
+ });
176
+ });
177
+
178
+ describe("Forward / Rewind keys", () => {
179
+ const POSITION = 100;
180
+
181
+ const createPlayer = (supportsNativeControls: boolean) => ({
182
+ supportsNativeControls: () => supportsNativeControls,
183
+ isPaused: () => false,
184
+ isLive: () => false,
185
+ getPosition: () => POSITION,
186
+ getSeekableDuration: () => 1000,
187
+ pause: jest.fn(),
188
+ play: jest.fn(),
189
+ seekTo: jest.fn(),
190
+ forward: jest.fn(),
191
+ rewind: jest.fn(),
192
+ });
193
+
194
+ let removeListener = () => {};
195
+
196
+ // Same wiring as usePlayerControllerSetup
197
+ const setup = ({ supportsNativeControls, ...props }: any) => {
198
+ const player = createPlayer(supportsNativeControls);
199
+
200
+ jest
201
+ .spyOn(playerManager, "getInstanceController")
202
+ .mockReturnValue(player as any);
203
+
204
+ removeListener = KeyInputHandler.getInstance().addListener(
205
+ new TVSeekController(player as any)
206
+ );
207
+
208
+ return { player, ...createManager(props) };
209
+ };
210
+
211
+ const press = (manager, key) => {
212
+ manager.onKeyDown(KEYS[key]);
213
+ manager.onKeyUp(KEYS[key]);
214
+ jest.runAllTimers();
215
+ };
216
+
217
+ beforeEach(() => jest.useFakeTimers());
218
+
219
+ afterEach(() => {
220
+ removeListener();
221
+ jest.useRealTimers();
222
+ jest.restoreAllMocks();
223
+ });
224
+
225
+ describe("with a player rendering its own controls", () => {
226
+ const expectUntouched = (player, props) => {
227
+ expect(player.seekTo).not.toHaveBeenCalled();
228
+ expect(player.forward).not.toHaveBeenCalled();
229
+ expect(player.rewind).not.toHaveBeenCalled();
230
+ expect(player.pause).not.toHaveBeenCalled();
231
+ expect(props.setDisplayState).not.toHaveBeenCalled();
232
+ };
233
+
234
+ it.each(["Forward", "Rewind"])("%s is left to the player", (key) => {
235
+ const { player, manager, props } = setup({
236
+ supportsNativeControls: true,
237
+ });
238
+
239
+ const event = { ...KEYS[key], stopPropagation: jest.fn() };
240
+
241
+ manager.onKeyDown(event);
242
+ manager.onKeyUp(event);
243
+ jest.runAllTimers();
244
+
245
+ expectUntouched(player, props);
246
+ // the key has to reach the player's own handler
247
+ expect(event.stopPropagation).not.toHaveBeenCalled();
248
+ });
249
+
250
+ it("does not seek the player while the play-next overlay is visible", () => {
251
+ const { player, manager } = setup({ supportsNativeControls: true });
252
+
253
+ (manager.props.navigator as any).getPlayNextOverlay = () => ({});
254
+
255
+ press(manager, "Forward");
256
+
257
+ expect(player.seekTo).not.toHaveBeenCalled();
258
+ expect(player.forward).not.toHaveBeenCalled();
259
+ });
260
+
261
+ it("keeps the keys while a dialog is visible", () => {
262
+ const { player, manager } = setup({ supportsNativeControls: true });
263
+
264
+ confirmationDialogStore.getState().showDialog();
265
+
266
+ press(manager, "Forward");
267
+
268
+ expect(player.seekTo).toHaveBeenCalledWith(POSITION + SKIP_TIME_BASE);
269
+ });
270
+
271
+ it("sends the key-up to the owner of the key-down when a dialog closes in between", () => {
272
+ const { player, manager } = setup({ supportsNativeControls: true });
273
+
274
+ confirmationDialogStore.getState().showDialog();
275
+ manager.onKeyDown(KEYS.Forward);
276
+ confirmationDialogStore.getState().hideDialog();
277
+ manager.onKeyUp(KEYS.Forward);
278
+ jest.runAllTimers();
279
+
280
+ // a single tap seek, not a long press left running
281
+ expect(player.seekTo).toHaveBeenCalledTimes(1);
282
+ expect(player.seekTo).toHaveBeenCalledWith(POSITION + SKIP_TIME_BASE);
283
+ });
284
+
285
+ it("leaves the key-up to the player when a dialog opens after a deferred key-down", () => {
286
+ const { player, manager, props } = setup({
287
+ supportsNativeControls: true,
288
+ });
289
+
290
+ manager.onKeyDown(KEYS.Forward);
291
+ confirmationDialogStore.getState().showDialog();
292
+ manager.onKeyUp(KEYS.Forward);
293
+ jest.runAllTimers();
294
+
295
+ expectUntouched(player, props);
296
+ });
297
+ });
298
+
299
+ describe("with QuickBrick's transport controls", () => {
300
+ it.each([
301
+ ["Forward", POSITION + SKIP_TIME_BASE],
302
+ ["Rewind", POSITION - SKIP_TIME_BASE],
303
+ ])("%s keeps the delayed seek", (key, expectedPosition) => {
304
+ const { player, manager, props } = setup({
305
+ supportsNativeControls: false,
306
+ displayState: DISPLAY_STATES.PLAYER,
307
+ });
308
+
309
+ press(manager, key);
310
+
311
+ expect(props.setDisplayState).toHaveBeenCalledWith(DISPLAY_STATES.HUD);
312
+ expect(player.seekTo).toHaveBeenCalledWith(expectedPosition);
313
+ expect(player.play).toHaveBeenCalled();
314
+ expect(player.forward).not.toHaveBeenCalled();
315
+ expect(player.rewind).not.toHaveBeenCalled();
316
+ });
317
+ });
318
+ });
319
+ });
@@ -52,13 +52,14 @@ import { KeyboardLongPressManager } from "@applicaster/zapp-react-dom-ui-compone
52
52
  import { KeyInputHandler } from "@applicaster/zapp-react-native-utils/appUtils/keyInputHandler/KeyInputHandler";
53
53
  import { getHomeRiver } from "@applicaster/zapp-react-native-utils/reactHooks/state";
54
54
  import { withBackToTopActionHOC } from "./hoc";
55
+ import { isVidaaPlatform } from "../Loader/utils/platform";
55
56
 
56
57
  const { withXray } = getXray();
57
58
  const globalAny: any = global;
58
59
 
59
60
  const { log_debug, log_warning, log_info } = createLogger({
60
61
  category: "InteractionManager",
61
- subsystem: "zapp-react-dom-app",
62
+ subsystem: "app/remote_control",
62
63
  });
63
64
 
64
65
  const shouldUseOnScreenKeyboard = () =>
@@ -89,7 +90,7 @@ type KeyCode = { code: string; keyCode: number | string };
89
90
  const VIDEO_PLAYER_CONTROLS_NAVIGATION_MESSAGE =
90
91
  "Video player controls - use left/right to navigate";
91
92
 
92
- class InteractionManagerClass extends React.Component<Props, State> {
93
+ export class InteractionManagerClass extends React.Component<Props, State> {
93
94
  onKeyDownListener: (keyCode: KeyCode) => void;
94
95
  onMouseDownListener: (event: MouseEvent) => void;
95
96
  onMouseMoveListener: (event: MouseEvent) => void;
@@ -101,6 +102,8 @@ class InteractionManagerClass extends React.Component<Props, State> {
101
102
  private sequenceTimeout: NodeJS.Timeout | null;
102
103
  private userSequence: number[] = [];
103
104
  private isDismissing = false;
105
+ // Whether each key's key-down was left to the player's own controls
106
+ private deferredKeyDowns = new Map<number, boolean>();
104
107
  private confirmDialog: ConfirmationDialogState =
105
108
  confirmationDialogStore.getState();
106
109
 
@@ -250,10 +253,14 @@ class InteractionManagerClass extends React.Component<Props, State> {
250
253
  if (isVizioPlatform()) {
251
254
  window?.VIZIO?.exitApplication();
252
255
  }
256
+
257
+ if (isVidaaPlatform()) {
258
+ window.close();
259
+ }
253
260
  }
254
261
 
255
262
  handleWebOsBack() {
256
- const systemBack = globalAny?.webOS?.platformToBackground;
263
+ const systemBack = globalAny?.webOS?.platformBack;
257
264
 
258
265
  if (systemBack) {
259
266
  systemBack();
@@ -277,7 +284,7 @@ class InteractionManagerClass extends React.Component<Props, State> {
277
284
  platformToBackground() {
278
285
  try {
279
286
  // TODO: temporary hack until this code is refactored
280
- if (isSamsungPlatform() || isVizioPlatform()) {
287
+ if (isSamsungPlatform() || isVizioPlatform() || isVidaaPlatform()) {
281
288
  const { isDialogVisible } = confirmationDialogStore.getState();
282
289
 
283
290
  if (isDialogVisible) {
@@ -368,6 +375,18 @@ class InteractionManagerClass extends React.Component<Props, State> {
368
375
  focusManager.pressOut();
369
376
  }
370
377
 
378
+ // The key-up goes to whoever got the key-down, even if a dialog or the
379
+ // play-next overlay opened or closed in between.
380
+ const deferred =
381
+ this.deferredKeyDowns.get(event.keyCode) ??
382
+ this.isDeferredToNativeControls(event);
383
+
384
+ this.deferredKeyDowns.delete(event.keyCode);
385
+
386
+ if (deferred) {
387
+ return;
388
+ }
389
+
371
390
  if (
372
391
  keyCode(event).matches(KEYS.Forward) ||
373
392
  (__DEV__ && keyCode(event).matches(KEYS.C))
@@ -420,18 +439,20 @@ class InteractionManagerClass extends React.Component<Props, State> {
420
439
  return;
421
440
  }
422
441
 
442
+ // Focus is inside the dialog, so backToTopAction() can't resolve a
443
+ // meaningful action — Back must dismiss the dialog in every display state.
444
+ if (isDialogVisible) {
445
+ this.onConfirmDialogClose();
446
+ event.preventDefault();
447
+ event.stopPropagation();
448
+
449
+ return;
450
+ }
451
+
423
452
  const action = backToTopAction();
424
453
 
425
454
  const effectiveDisplayState = this.getEffectiveDisplayState();
426
455
 
427
- // TEMP(native-controls): remove after device verification
428
- if (effectiveDisplayState !== displayState) {
429
- log_debug("[NativeControls] Back handled as PLAYER", {
430
- displayState,
431
- action,
432
- });
433
- }
434
-
435
456
  switch (effectiveDisplayState) {
436
457
  case DISPLAY_STATES.DEFAULT:
437
458
  if (action === "PLATFORM_TO_BACKGROUND") {
@@ -481,9 +502,7 @@ class InteractionManagerClass extends React.Component<Props, State> {
481
502
 
482
503
  break;
483
504
  case DISPLAY_STATES.PLAYER:
484
- if (isDialogVisible) {
485
- this.onConfirmDialogClose();
486
- } else if (navigator.canGoBack()) {
505
+ if (navigator.canGoBack()) {
487
506
  navigator.goBack();
488
507
  } else if (this.isHomeScreen()) {
489
508
  this.platformToBackground();
@@ -559,12 +578,22 @@ class InteractionManagerClass extends React.Component<Props, State> {
559
578
  return (
560
579
  this.isNativePlayerControlsEnabled() &&
561
580
  !this.isPlayNextOverlayVisible() &&
562
- (keyCode(event).matchesAny(...ARROW_KEYS) ||
563
- keyCode(event).matches(KEYS.Enter) ||
564
- (__DEV__ && keyCode(event).matches(KEYS.X)))
581
+ (keyCode(event).matchesAny(
582
+ ...ARROW_KEYS,
583
+ KEYS.Enter,
584
+ // The player seeks on its own with these keys
585
+ KEYS.Forward,
586
+ KEYS.Rewind
587
+ ) ||
588
+ (__DEV__ && keyCode(event).matchesAny(KEYS.X, KEYS.C, KEYS.Z)))
565
589
  );
566
590
  };
567
591
 
592
+ // An open dialog owns the remote, whatever the player renders.
593
+ isDeferredToNativeControls = (event: KeyboardEvent) =>
594
+ !confirmationDialogStore.getState().isDialogVisible &&
595
+ this.shouldDeferPlaybackNavigationToNativeControls(event);
596
+
568
597
  onKeyDown(event) {
569
598
  const { setDisplayState, navigator, xray } = this.props;
570
599
  const displayState = this.getEffectiveDisplayState();
@@ -603,18 +632,11 @@ class InteractionManagerClass extends React.Component<Props, State> {
603
632
  // Only handle repeat events for arrow keys
604
633
  if (event.repeat && !keyCode(event).matchesAny(...ARROW_KEYS)) return;
605
634
 
606
- if (
607
- !isDialogVisible &&
608
- this.shouldDeferPlaybackNavigationToNativeControls(event)
609
- ) {
610
- // The player's own spatial navigation handles these keys.
611
- // TEMP(native-controls): remove after device verification
612
- log_debug("[NativeControls] key deferred to player", {
613
- key: event.key,
614
- displayState,
615
- activeElement: document.activeElement?.className,
616
- });
635
+ const deferred = this.isDeferredToNativeControls(event);
636
+ this.deferredKeyDowns.set(event.keyCode, deferred);
617
637
 
638
+ if (deferred) {
639
+ // The player's own key handling takes these keys.
618
640
  return;
619
641
  }
620
642
 
@@ -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,54 +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
- rivers_configuration_id: defaultLayoutId,
16
- appFamilyId,
17
- } = appData;
19
+ export function writeLoaderCache(key: string, value: string) {
20
+ window.localStorage.setItem(cacheKey(key), value);
21
+ }
18
22
 
19
- if (
20
- !(await localStorage.getItem(
21
- STORAGE.DEFAULT_LAYOUT_ID,
22
- STORAGE.APP_LOADER_BRIDGE_NAME_SPACE
23
- ))
24
- ) {
25
- await localStorage.setItem(
26
- STORAGE.DEFAULT_LAYOUT_ID,
27
- defaultLayoutId,
28
- STORAGE.APP_LOADER_BRIDGE_NAME_SPACE
29
- );
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) {
28
+ // appData.json is camelized, so the built-in id is riversConfigurationId.
29
+ const defaultLayoutId =
30
+ appData.riversConfigurationId || appData.rivers_configuration_id;
31
+
32
+ const storedDefault = readLoaderCache(STORAGE.DEFAULT_LAYOUT_ID);
33
+
34
+ if ((!storedDefault || storedDefault === "undefined") && defaultLayoutId) {
35
+ writeLoaderCache(STORAGE.DEFAULT_LAYOUT_ID, defaultLayoutId);
30
36
  }
31
37
 
32
- const currentLayoutId = await localStorage.getItem(
33
- STORAGE.CURRENT_LAYOUT_ID,
34
- STORAGE.APP_LOADER_BRIDGE_NAME_SPACE
35
- );
38
+ const currentLayoutId = readLoaderCache(STORAGE.CURRENT_LAYOUT_ID);
36
39
 
37
- const remoteConfig: RemoteConfigurations = await localStorage.getItem(
38
- STORAGE.REMOTE_CONFIGURATIONS,
39
- STORAGE.APP_LOADER_BRIDGE_NAME_SPACE
40
+ const remoteConfig: RemoteConfigurations = readLoaderCache(
41
+ STORAGE.REMOTE_CONFIGURATIONS
40
42
  );
41
43
 
42
44
  const remoteConfigLayoutId = remoteConfig?.general_settings?.layout_id;
43
45
 
44
46
  /**
45
- * 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).
46
48
  * First we are attempting ot use the id set by layout plugin, it is an override of the defined layout.
47
49
  * Or use layout id from the remote configuration, remote config is loaded first and it has current layoutId from zapp.
48
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.
49
51
  */
50
- const customLayoutId =
51
- 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;
52
66
 
53
67
  const baseURL = `https://assets-secure.applicaster.com/zapp/accounts/${accountsAccountId}`;
54
68
  const urlTemplate = `apps/${bundleIdentifier}/${STORE_PLATFORM[store]}/${versionName}`;
@@ -59,14 +73,21 @@ export async function createUrl(type: string): Promise<string> {
59
73
  case "pluginConfigurations":
60
74
  return `${baseURL}/${urlTemplate}/plugin_configurations/plugin_configurations.json`;
61
75
  case "layout":
62
- return `${baseURL}/${urlTemplate}/layouts/${customLayoutId}.json`;
76
+ return `${baseURL}/${urlTemplate}/layouts/${layoutId}.json`;
63
77
  case "pipesEndpoints":
64
78
  return `${baseURL}/app_families/${appFamilyId}/data_source_providers/endpoints.json`;
65
79
  case "cellStyles":
66
- return `${baseURL}/app_families/${appFamilyId}/layouts/${customLayoutId}/cell_styles.json`;
80
+ return `${baseURL}/app_families/${appFamilyId}/layouts/${layoutId}/cell_styles.json`;
67
81
  case "presetsMapping":
68
- return `${baseURL}/app_families/${appFamilyId}/layouts/${customLayoutId}/presets_mapping.json`;
82
+ return `${baseURL}/app_families/${appFamilyId}/layouts/${layoutId}/presets_mapping.json`;
69
83
  default:
70
84
  return null;
71
85
  }
72
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 = {};
@@ -9,9 +9,6 @@ import "abortcontroller-polyfill/dist/polyfill-patch-fetch";
9
9
  import "core-js/stable";
10
10
  import "regenerator-runtime/runtime";
11
11
 
12
- // Polyfill for ShadowDOM, Array.from, Object.assign, and more for webcomponents
13
- import "@webcomponents/webcomponentsjs";
14
-
15
12
  const PLATFORM_KEYS = {
16
13
  samsung: "samsung_tv",
17
14
  lg: "lg_tv",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-dom-app",
3
- "version": "16.0.0-rc.99",
3
+ "version": "17.0.0-alpha.5804334713",
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.99",
26
- "@applicaster/zapp-react-native-bridge": "16.0.0-rc.99",
27
- "@applicaster/zapp-react-native-redux": "16.0.0-rc.99",
28
- "@applicaster/zapp-react-native-ui-components": "16.0.0-rc.99",
29
- "@applicaster/zapp-react-native-utils": "16.0.0-rc.99",
25
+ "@applicaster/zapp-react-dom-ui-components": "17.0.0-alpha.5804334713",
26
+ "@applicaster/zapp-react-native-bridge": "17.0.0-alpha.5804334713",
27
+ "@applicaster/zapp-react-native-redux": "17.0.0-alpha.5804334713",
28
+ "@applicaster/zapp-react-native-ui-components": "17.0.0-alpha.5804334713",
29
+ "@applicaster/zapp-react-native-utils": "17.0.0-alpha.5804334713",
30
30
  "abortcontroller-polyfill": "^1.7.5",
31
31
  "typeface-montserrat": "^0.0.54",
32
32
  "video.js": "7.14.3",