@applicaster/zapp-react-native-ui-components 16.0.0-rc.108 → 16.0.0-rc.109

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.
@@ -6,7 +6,7 @@
6
6
  * Green (configurable): background, image, nowPlayingAsset, nowPlayingLabel,
7
7
  * textLabel1, textLabel2
8
8
  * White (hardcoded): container padding/gutters, nowPlaying container layout,
9
- * button sizes/padding/assets
9
+ * button sizes/padding. Play/pause icons use plugin assets when set.
10
10
  *
11
11
  * Variants (VARIANT_*) are preset configuration bundles — not passed as a field.
12
12
  */
@@ -94,6 +94,10 @@ export type ActionButtonAssetsConfiguration = {
94
94
  removeAsset?: string;
95
95
  /** Trailing drag handle on Edit Order / queue rows. */
96
96
  dragAsset?: string;
97
+ /** Shown on the Now Playing row while the track is paused. */
98
+ playAsset?: string;
99
+ /** Shown on the Now Playing row while the track is playing. */
100
+ pauseAsset?: string;
97
101
  };
98
102
 
99
103
  export type ItemConfiguration = {
@@ -504,6 +508,23 @@ function ItemButton({
504
508
  const renderIcon = () => {
505
509
  switch (action) {
506
510
  case "playPause": {
511
+ const playbackAsset = isSelected
512
+ ? actionButtonAssets?.pauseAsset
513
+ : actionButtonAssets?.playAsset;
514
+
515
+ if (playbackAsset) {
516
+ return (
517
+ <Image
518
+ source={{ uri: playbackAsset }}
519
+ style={{
520
+ width: layout.assetWidth,
521
+ height: layout.assetHeight,
522
+ }}
523
+ resizeMode="contain"
524
+ />
525
+ );
526
+ }
527
+
507
528
  const iconSize = layout.assetWidth;
508
529
  const iconRadius = iconSize / 2;
509
530
 
@@ -1,8 +1,10 @@
1
1
  import React from "react";
2
- import { Pressable } from "react-native";
2
+ import { Image, Pressable } from "react-native";
3
3
  import { fireEvent, render, screen } from "@testing-library/react-native";
4
+ import { ModalBlocksStyleContext } from "../../../ModalBlocksStyleContext";
4
5
  import {
5
6
  AudioPlayerTrackItem,
7
+ VARIANT_NOW_PLAYING,
6
8
  VARIANT_PLAYLIST_ITEM,
7
9
  mapMenuItemToNowPlayingData,
8
10
  } from "../Item";
@@ -101,6 +103,55 @@ describe("mapMenuItemToNowPlayingData", () => {
101
103
  expect(data.textLabel2).toBe("Artist Name");
102
104
  });
103
105
 
106
+ it("uses the plugin play asset while paused and the pause asset while playing", () => {
107
+ const menuItem = {
108
+ id: "item_1",
109
+ title: "Track Title",
110
+ summary: "Artist Name",
111
+ };
112
+
113
+ const style = {
114
+ nowPlayingItem: {
115
+ actionButtons: {
116
+ default: {
117
+ playAsset: "https://example.com/play.png",
118
+ pauseAsset: "https://example.com/pause.png",
119
+ },
120
+ },
121
+ },
122
+ };
123
+
124
+ const { UNSAFE_getAllByType, rerender } = render(
125
+ <ModalBlocksStyleContext.Provider value={style}>
126
+ <AudioPlayerTrackItem
127
+ configuration={VARIANT_NOW_PLAYING}
128
+ data={mapMenuItemToNowPlayingData(menuItem as any, true)}
129
+ />
130
+ </ModalBlocksStyleContext.Provider>
131
+ );
132
+
133
+ expect(
134
+ UNSAFE_getAllByType(Image).some(
135
+ (node) => node.props.source?.uri === "https://example.com/play.png"
136
+ )
137
+ ).toBe(true);
138
+
139
+ rerender(
140
+ <ModalBlocksStyleContext.Provider value={style}>
141
+ <AudioPlayerTrackItem
142
+ configuration={VARIANT_NOW_PLAYING}
143
+ data={mapMenuItemToNowPlayingData(menuItem as any, false)}
144
+ />
145
+ </ModalBlocksStyleContext.Provider>
146
+ );
147
+
148
+ expect(
149
+ UNSAFE_getAllByType(Image).some(
150
+ (node) => node.props.source?.uri === "https://example.com/pause.png"
151
+ )
152
+ ).toBe(true);
153
+ });
154
+
104
155
  it("uses custom nowPlayingLabel when provided", () => {
105
156
  const data = mapMenuItemToNowPlayingData(
106
157
  item as any,
@@ -131,6 +131,43 @@ describe("mergeItemConfiguration background", () => {
131
131
  "https://placehold.co/24x24/FF3B30/FFFFFF/png?text=RH"
132
132
  );
133
133
  });
134
+
135
+ it("applies a text label font family and ignores a non-string font family", () => {
136
+ const withFont = mergeItemConfiguration(base as any, {
137
+ textLabel1: { default: { fontFamily: "Roboto-Bold" } },
138
+ });
139
+
140
+ expect(withFont.textLabel1.iosFontFamily).toBe("Roboto-Bold");
141
+ expect(withFont.textLabel1.androidFontFamily).toBe("Roboto-Bold");
142
+
143
+ const withEmpty = mergeItemConfiguration(base as any, {
144
+ textLabel1: {
145
+ default: { fontFamily: {}, iosFontFamily: "SFProText-Semibold" },
146
+ },
147
+ });
148
+
149
+ expect(withEmpty.textLabel1.iosFontFamily).toBe("SFProText-Semibold");
150
+ expect(withEmpty.textLabel1.androidFontFamily).toBeUndefined();
151
+ });
152
+
153
+ it("applies play and pause assets onto actionButtonAssets", () => {
154
+ const merged = mergeItemConfiguration(base as any, {
155
+ actionButtons: {
156
+ default: {
157
+ playAsset: "https://example.com/play.png",
158
+ pauseAsset: "https://example.com/pause.png",
159
+ },
160
+ },
161
+ });
162
+
163
+ expect(merged.actionButtonAssets?.playAsset).toBe(
164
+ "https://example.com/play.png"
165
+ );
166
+
167
+ expect(merged.actionButtonAssets?.pauseAsset).toBe(
168
+ "https://example.com/pause.png"
169
+ );
170
+ });
134
171
  });
135
172
 
136
173
  describe("mergeButtonConfiguration background", () => {
@@ -82,7 +82,7 @@ function mergeItemLabel(
82
82
  ...(override.lineHeight !== undefined
83
83
  ? { lineHeight: override.lineHeight }
84
84
  : {}),
85
- ...(override.fontFamily
85
+ ...(typeof override.fontFamily === "string" && override.fontFamily
86
86
  ? {
87
87
  iosFontFamily: override.fontFamily,
88
88
  androidFontFamily: override.fontFamily,
@@ -147,6 +147,8 @@ function mergeItemActionButtonAssets(
147
147
  ...base,
148
148
  ...(override.removeAsset ? { removeAsset: override.removeAsset } : {}),
149
149
  ...(override.dragAsset ? { dragAsset: override.dragAsset } : {}),
150
+ ...(override.playAsset ? { playAsset: override.playAsset } : {}),
151
+ ...(override.pauseAsset ? { pauseAsset: override.pauseAsset } : {}),
150
152
  };
151
153
  }
152
154
 
@@ -115,7 +115,6 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
115
115
  iconPlacement,
116
116
  contentComponent: ContentComponent,
117
117
  contentComponentProps = {},
118
- styleOverrides: propStyleOverrides,
119
118
  measurementPhase = false,
120
119
  emptyMessage,
121
120
  } = props;
@@ -157,78 +156,10 @@ function _BottomSheetModalContent(props: ModalComponentProps) {
157
156
  const configuration =
158
157
  plugin?.configuration || (plugin as any)?.configuration_json;
159
158
 
160
- const parsed =
161
- configuration && themePluginId
162
- ? parseModalBlocksConfiguration(configuration, themePluginId)
163
- : null;
164
-
165
- if (!propStyleOverrides) {
166
- return parsed;
167
- }
168
-
169
- // hydrate(QueueStyles) only emits sectionHeader after the plugin class
170
- // includes it. Until then, Studio/local keys in configuration_json still
171
- // apply via the parser.
172
- const hydratedSection = propStyleOverrides.sectionHeader;
173
-
174
- const hasTitleStyle = (style?: Record<string, any>) =>
175
- Boolean(
176
- style &&
177
- (style.fontColor ||
178
- style.fontSize !== undefined ||
179
- style.lineHeight !== undefined ||
180
- style.fontFamily ||
181
- style.iosFontFamily ||
182
- style.androidFontFamily ||
183
- style.letterSpacing !== undefined ||
184
- style.iosLetterSpacing !== undefined ||
185
- style.androidLetterSpacing !== undefined ||
186
- style.textTransform ||
187
- style.alignment)
188
- );
189
-
190
- const hasActionStyle = (style?: Record<string, any>) =>
191
- Boolean(
192
- style &&
193
- (style.backgroundColor ||
194
- style.fontColor ||
195
- style.fontSize !== undefined ||
196
- style.lineHeight !== undefined ||
197
- style.fontFamily ||
198
- style.iosFontFamily ||
199
- style.androidFontFamily ||
200
- style.letterSpacing !== undefined ||
201
- style.iosLetterSpacing !== undefined ||
202
- style.androidLetterSpacing !== undefined ||
203
- style.textTransform ||
204
- style.borderRadius !== undefined ||
205
- style.cornerRadius !== undefined ||
206
- style.paddingTop !== undefined ||
207
- style.paddingRight !== undefined ||
208
- style.paddingBottom !== undefined ||
209
- style.paddingLeft !== undefined)
210
- );
211
-
212
- const actionNode = hydratedSection?.actionButtons;
213
-
214
- // An empty `default: {}` is truthy and would hide fields set on the flat
215
- // object or on the focused bucket.
216
- const hasHydratedSection =
217
- hasTitleStyle(hydratedSection?.title?.default) ||
218
- hasTitleStyle(hydratedSection?.title) ||
219
- hasActionStyle(actionNode?.default) ||
220
- hasActionStyle(actionNode?.focused) ||
221
- hasActionStyle(actionNode?.focus) ||
222
- hasActionStyle(actionNode);
223
-
224
- return {
225
- ...parsed,
226
- ...propStyleOverrides,
227
- sectionHeader: hasHydratedSection
228
- ? hydratedSection
229
- : parsed?.sectionHeader,
230
- };
231
- }, [propStyleOverrides, plugin, themePluginId]);
159
+ return configuration && themePluginId
160
+ ? parseModalBlocksConfiguration(configuration, themePluginId)
161
+ : null;
162
+ }, [plugin, themePluginId]);
232
163
 
233
164
  const pluginLocalizations = useLocalizedStrings({
234
165
  localizations:
@@ -66,7 +66,7 @@ export class AnimationManager {
66
66
  *
67
67
  * @param {object} to contains style to animate and scenes index
68
68
  * @param {string} action push|back
69
- * @param {number} stepsBack number of steps back
69
+ * @param {number} [stepsBack] number of steps back, used by the back action only
70
70
  */
71
71
  stateForAction({ to, scenes }, action, stepsBack) {
72
72
  let rootIndex = to.index;
@@ -17,13 +17,14 @@ const fullWidthDimensions = {
17
17
  type Props = {
18
18
  children: any;
19
19
  style: any;
20
- pointerEvents: ViewProps["pointerEvents"];
21
- overlayStyle: ViewStyle;
22
- animating: boolean;
20
+ pointerEvents?: ViewProps["pointerEvents"];
21
+ overlayStyle?: ViewStyle;
22
+ animating?: boolean;
23
23
  contentStyle: ViewStyle;
24
24
  pathname: string;
25
25
  isActive: boolean;
26
26
  screenData: NavigationScreenData;
27
+ screenUniqueId?: string;
27
28
  };
28
29
 
29
30
  export function CurrentScreenContextProvider({
@@ -1,6 +1,6 @@
1
1
  import * as React from "react";
2
2
  import { View } from "react-native";
3
- import { append, compose, identity, tail, take, update } from "ramda";
3
+ import { append, compose, identity, tail, update } from "ramda";
4
4
 
5
5
  import { AnimationManager } from "./AnimationManager";
6
6
  import { Scene } from "./Scene";
@@ -18,11 +18,23 @@ export const NAV_ACTION_REPLACE = "REPLACE";
18
18
 
19
19
  export const NAV_ACTION_BACK = "POP";
20
20
 
21
- type SceneProps = {
22
- children: React.ReactNode;
23
- overlayStyle: any;
24
- style: any;
25
- animating: boolean;
21
+ const RIVER_SEGMENT_REGEX = /river\//g;
22
+
23
+ // Number of "river/" segments in the route, i.e. its depth in the navigation stack
24
+ function countRiverSegments(route?: string): number {
25
+ return (route?.match(RIVER_SEGMENT_REGEX) || []).length;
26
+ }
27
+
28
+ // The screen element rendered for the current route
29
+ type SceneChild = React.ReactElement<{
30
+ pathname: string;
31
+ screenData: NavigationScreenData;
32
+ }>;
33
+
34
+ type TransitionerScene = {
35
+ children: SceneChild;
36
+ key: string;
37
+ screenUniqueId: string;
26
38
  };
27
39
 
28
40
  /**
@@ -31,7 +43,7 @@ type SceneProps = {
31
43
  * @param {Object} children
32
44
  * @returns { children: Object, key: string }
33
45
  */
34
- function prepareScene(children) {
46
+ function prepareScene(children: SceneChild): TransitionerScene {
35
47
  return {
36
48
  children,
37
49
  key: children.props.pathname,
@@ -45,7 +57,10 @@ function prepareScene(children) {
45
57
  * @param {any} props Component's props
46
58
  * @param {any} state Component's state
47
59
  */
48
- function scenesForBackTransition(props: SceneProps, { scenes }) {
60
+ function scenesForBackTransition(
61
+ props: Pick<Props, "children">,
62
+ { scenes }: Pick<State, "scenes">
63
+ ): TransitionerScene[] {
49
64
  return scenes.length > 1
50
65
  ? identity(scenes)
51
66
  : [prepareScene(props.children), scenes[0]];
@@ -53,7 +68,8 @@ function scenesForBackTransition(props: SceneProps, { scenes }) {
53
68
 
54
69
  type Props = {
55
70
  transitionConfig: any;
56
- children: React.ReactNode;
71
+ isRTL?: boolean;
72
+ children: SceneChild;
57
73
  navigator: {
58
74
  previousAction: string;
59
75
  currentRoute: string;
@@ -78,13 +94,12 @@ type Props = {
78
94
  };
79
95
 
80
96
  type State = {
81
- animatedValue: any;
82
97
  animating: boolean;
83
98
  duration: number;
84
99
  easing: any;
85
100
  from: any;
86
101
  to: any;
87
- scenes: any;
102
+ scenes: TransitionerScene[];
88
103
  };
89
104
 
90
105
  const styles = {
@@ -92,11 +107,18 @@ const styles = {
92
107
  } as const;
93
108
 
94
109
  export class TransitionerComponent extends React.PureComponent<Props, State> {
110
+ private animationManager: AnimationManager;
111
+
112
+ private transitionId: number;
113
+
95
114
  constructor(props: Props) {
96
115
  super(props);
97
116
 
98
117
  this.animationManager = new AnimationManager(props);
99
118
 
119
+ // Bumped by every animated transition; end callbacks of older ones are ignored
120
+ this.transitionId = 0;
121
+
100
122
  this.state = {
101
123
  scenes: [prepareScene(props.children)], // starting with index: 0
102
124
  ...this.animationManager.initialState(),
@@ -113,28 +135,36 @@ export class TransitionerComponent extends React.PureComponent<Props, State> {
113
135
  * and trigger push animation with callbacks.
114
136
  */
115
137
  pushTransitionStart() {
138
+ const transitionId = ++this.transitionId;
139
+
116
140
  this.setState(
117
141
  {
118
- scenes: append(prepareScene(this.props.children), this.state.scenes),
142
+ // Append right after the visible scene: stateForAction(PUSH) animates to.index + 1
143
+ scenes: [
144
+ ...this.state.scenes.slice(0, this.state.to.index + 1),
145
+ prepareScene(this.props.children),
146
+ ],
119
147
  ...this.animationManager.stateForAction(this.state, NAV_ACTION_PUSH),
120
148
  },
121
149
  () => {
122
- this.animationManager.animate(this.state, this.pushTransitionEnd);
150
+ this.animationManager.animate(this.state, () =>
151
+ this.pushTransitionEnd(transitionId)
152
+ );
123
153
  }
124
154
  );
125
155
  }
126
156
 
127
157
  /**
128
158
  * Trigger back animation.
129
- * The last scene in the array is removed in the "backTransitionEnd" callback,
159
+ * The scenes after the target one are removed in the "backTransitionEnd" callback,
130
160
  * only after the animation is complete.
131
161
  */
132
162
  backTransitionStart(previousRoute: string) {
163
+ const transitionId = ++this.transitionId;
133
164
  const currentRoute = this.props.navigator?.currentRoute;
134
165
 
135
166
  const diffLengthRoutes =
136
- (previousRoute?.match(/river\//g) || []).length -
137
- (currentRoute?.match(/river\//g) || []).length;
167
+ countRiverSegments(previousRoute) - countRiverSegments(currentRoute);
138
168
 
139
169
  const stepsBack =
140
170
  diffLengthRoutes && diffLengthRoutes > 0 ? diffLengthRoutes : 1;
@@ -150,30 +180,32 @@ export class TransitionerComponent extends React.PureComponent<Props, State> {
150
180
  },
151
181
  () => {
152
182
  this.animationManager.animate(this.state, () =>
153
- this.backTransitionEnd(stepsBack)
183
+ this.backTransitionEnd(transitionId)
154
184
  );
155
185
  }
156
186
  );
157
187
  }
158
188
 
159
- shouldSkipAnimationForRoute(route) {
189
+ shouldSkipAnimationForRoute(route: string) {
160
190
  // We are skipping animations for some routes, likes hooks & the player mainly
161
191
 
162
192
  return route.includes("/playable") || route.includes("/hook");
163
193
  }
164
194
 
165
- shouldResetStateForRoute(route) {
195
+ shouldResetStateForRoute(route: string) {
166
196
  // We are resetting the transitioner state for root routes
167
197
 
168
- return (route?.match(/river\//g) || []).length === 1;
198
+ return countRiverSegments(route) === 1;
169
199
  }
170
200
 
171
- transitionWithoutAnimation(resetState) {
172
- const { scenes } = this.state;
201
+ transitionWithoutAnimation(resetState?: boolean) {
202
+ // Scenes after the visible one are only leaving (back animation in flight),
203
+ // so the new scene replaces the one being backed to
204
+ const scenes = this.state.scenes.slice(0, this.state.to.index + 1);
173
205
  const newScene = prepareScene(this.props.children);
174
206
  const isLastSceneHook = scenes[scenes.length - 1].key.startsWith("/hook");
175
207
 
176
- let newState;
208
+ let newState: Pick<State, "scenes"> | Pick<State, "scenes" | "to">;
177
209
 
178
210
  if (resetState) {
179
211
  newState = {
@@ -263,20 +295,27 @@ export class TransitionerComponent extends React.PureComponent<Props, State> {
263
295
  }
264
296
  }
265
297
 
266
- // Sets state.animating to false
267
- pushTransitionEnd() {
298
+ // Sets state.animating to false,
299
+ // unless a newer transition has started since this one
300
+ pushTransitionEnd(transitionId: number) {
301
+ if (transitionId !== this.transitionId) return;
302
+
268
303
  this.setState({
269
304
  animating: false,
270
305
  });
271
306
  }
272
307
 
273
308
  // Sets state.animating to false
274
- // and remove last scene from scenes stack
275
- backTransitionEnd(stepsBack) {
276
- this.setState({
309
+ // and drops every scene after the target one. Reads the latest state,
310
+ // so it's correct however many backs were merged into this animation.
311
+ // Ignored if a newer transition has started since this one
312
+ backTransitionEnd(transitionId: number) {
313
+ if (transitionId !== this.transitionId) return;
314
+
315
+ this.setState(({ scenes, to }) => ({
277
316
  animating: false,
278
- scenes: take(this.state.scenes.length - stepsBack, this.state.scenes),
279
- });
317
+ scenes: scenes.slice(0, to.index + 1),
318
+ }));
280
319
  }
281
320
 
282
321
  // On initial state, both "to" and "from" point to a single scene
@@ -1,10 +1,12 @@
1
1
  import React from "react";
2
- import { Text, View } from "react-native";
2
+ import { Animated, Text, View } from "react-native";
3
+ import { v4 as uuidV4 } from "uuid";
3
4
 
4
5
  import { AnimationManager } from "../AnimationManager";
5
6
  import {
6
7
  NAV_ACTION_BACK,
7
8
  NAV_ACTION_PUSH,
9
+ NAV_ACTION_REPLACE,
8
10
  TransitionerComponent,
9
11
  } from "../Transitioner";
10
12
  import { act, render } from "@testing-library/react-native";
@@ -66,6 +68,26 @@ const sceneFor = (route) => (
66
68
  </View>
67
69
  );
68
70
 
71
+ const root = "/river/root";
72
+ const child = `${root}/river/child`;
73
+ const grandchild = `${child}/river/grandchild`;
74
+
75
+ const propsFor = (route, previousAction, children = sceneFor(route)) => ({
76
+ navigator: {
77
+ previousAction,
78
+ currentRoute: route,
79
+ screenData: {},
80
+ },
81
+ transitionConfig: sceneTransitionConfig,
82
+ layoutData: {
83
+ isTablet: false,
84
+ isTabletPortrait: false,
85
+ },
86
+ children,
87
+ });
88
+
89
+ const sceneKeys = (ref) => ref.current.state.scenes.map((scene) => scene.key);
90
+
69
91
  describe("<TransitionerComponent />", () => {
70
92
  it("renders correctly", () => {
71
93
  const wrapper = render(
@@ -83,26 +105,12 @@ describe("<TransitionerComponent />", () => {
83
105
  .mockImplementation((_, done) => done());
84
106
 
85
107
  const ref = React.createRef();
86
- const root = "/river/root";
87
- const child = `${root}/river/child`;
88
- const grandchild = `${child}/river/grandchild`;
89
-
90
- const propsFor = (route, previousAction) => ({
91
- navigator: {
92
- previousAction,
93
- currentRoute: route,
94
- screenData: {},
95
- },
96
- transitionConfig: sceneTransitionConfig,
97
- layoutData: {
98
- isTablet: false,
99
- isTabletPortrait: false,
100
- },
101
- children: sceneFor(route),
102
- });
103
108
 
104
109
  const { rerender } = render(
105
- <TransitionerComponent ref={ref} {...propsFor(root, "REPLACE")} />
110
+ <TransitionerComponent
111
+ ref={ref}
112
+ {...propsFor(root, NAV_ACTION_REPLACE)}
113
+ />
106
114
  );
107
115
 
108
116
  act(() => {
@@ -129,6 +137,286 @@ describe("<TransitionerComponent />", () => {
129
137
  );
130
138
  });
131
139
 
132
- expect(ref.current.state.scenes.map((scene) => scene.key)).toEqual([root]);
140
+ expect(sceneKeys(ref)).toEqual([root]);
141
+ });
142
+
143
+ describe("with interruptible animations", () => {
144
+ // End callback of the animation that is currently "running"
145
+ let runningAnimationEnd = null;
146
+
147
+ const finishAnimation = () => {
148
+ act(() => {
149
+ const done = runningAnimationEnd;
150
+ runningAnimationEnd = null;
151
+ done?.();
152
+ });
153
+ };
154
+
155
+ const mountTransitioner = ({
156
+ initialRoute = root,
157
+ renderScene = sceneFor,
158
+ } = {}) => {
159
+ const ref = React.createRef();
160
+
161
+ const transitioner = (route, action) => (
162
+ <TransitionerComponent
163
+ ref={ref}
164
+ {...propsFor(route, action, renderScene(route))}
165
+ />
166
+ );
167
+
168
+ const { rerender } = render(
169
+ transitioner(initialRoute, NAV_ACTION_REPLACE)
170
+ );
171
+
172
+ const navigate = (route, action) => {
173
+ act(() => {
174
+ rerender(transitioner(route, action));
175
+ });
176
+ };
177
+
178
+ return { ref, navigate };
179
+ };
180
+
181
+ beforeEach(() => {
182
+ let sceneCount = 0;
183
+ uuidV4.mockImplementation(() => `scene-${++sceneCount}`);
184
+
185
+ jest
186
+ .spyOn(AnimationManager.prototype, "animate")
187
+ .mockImplementation((_, done) => {
188
+ runningAnimationEnd = done;
189
+ });
190
+
191
+ const setValue = Animated.Value.prototype.setValue;
192
+
193
+ // RN 0.77: setValue() stops the running animation, which calls
194
+ // its end callback synchronously with { finished: false }
195
+ jest
196
+ .spyOn(Animated.Value.prototype, "setValue")
197
+ .mockImplementation(function (value) {
198
+ const interrupted = runningAnimationEnd;
199
+ runningAnimationEnd = null;
200
+ interrupted?.();
201
+
202
+ return setValue.call(this, value);
203
+ });
204
+ });
205
+
206
+ afterEach(() => {
207
+ runningAnimationEnd = null;
208
+ jest.restoreAllMocks();
209
+ uuidV4.mockReset();
210
+ });
211
+
212
+ it("drops interrupted scenes when going back twice during a back animation", () => {
213
+ const { ref, navigate } = mountTransitioner();
214
+
215
+ navigate(child, NAV_ACTION_PUSH);
216
+ finishAnimation();
217
+ navigate(grandchild, NAV_ACTION_PUSH);
218
+ finishAnimation();
219
+
220
+ navigate(child, NAV_ACTION_BACK);
221
+ navigate(root, NAV_ACTION_BACK);
222
+
223
+ expect(ref.current.state.animating).toBe(true);
224
+
225
+ finishAnimation();
226
+
227
+ expect(sceneKeys(ref)).toEqual([root]);
228
+ expect(ref.current.state.animating).toBe(false);
229
+ });
230
+
231
+ it("remounts the screen when revisiting it after a quick double back", () => {
232
+ const mounts = {};
233
+
234
+ const MountProbe = ({ pathname }) => {
235
+ React.useEffect(() => {
236
+ mounts[pathname] = (mounts[pathname] || 0) + 1;
237
+ }, [pathname]);
238
+
239
+ return <Text>{pathname}</Text>;
240
+ };
241
+
242
+ const { ref, navigate } = mountTransitioner({
243
+ renderScene: (route) => <MountProbe pathname={route} />,
244
+ });
245
+
246
+ navigate(child, NAV_ACTION_PUSH);
247
+ finishAnimation();
248
+
249
+ const originalChildId = ref.current.state.scenes[1].screenUniqueId;
250
+
251
+ navigate(grandchild, NAV_ACTION_PUSH);
252
+ finishAnimation();
253
+ navigate(child, NAV_ACTION_BACK);
254
+ navigate(root, NAV_ACTION_BACK);
255
+ finishAnimation();
256
+
257
+ navigate(child, NAV_ACTION_PUSH);
258
+ finishAnimation();
259
+
260
+ const { scenes, to } = ref.current.state;
261
+
262
+ expect(scenes).toHaveLength(2);
263
+ expect(to.index).toBe(1);
264
+ expect(scenes[1].screenUniqueId).not.toBe(originalChildId);
265
+ expect(mounts[child]).toBe(2);
266
+ });
267
+
268
+ it("keeps a single scene when going back from the first scene", () => {
269
+ const { ref, navigate } = mountTransitioner({ initialRoute: child });
270
+
271
+ navigate(root, NAV_ACTION_BACK);
272
+ finishAnimation();
273
+
274
+ expect(sceneKeys(ref)).toEqual([root]);
275
+ expect(ref.current.state.to.index).toBe(0);
276
+ expect(ref.current.state.animating).toBe(false);
277
+ });
278
+
279
+ it("pushes a fresh scene after a completed single back", () => {
280
+ const { ref, navigate } = mountTransitioner();
281
+
282
+ navigate(child, NAV_ACTION_PUSH);
283
+ finishAnimation();
284
+
285
+ const originalChildId = ref.current.state.scenes[1].screenUniqueId;
286
+
287
+ navigate(root, NAV_ACTION_BACK);
288
+ finishAnimation();
289
+ navigate(child, NAV_ACTION_PUSH);
290
+ finishAnimation();
291
+
292
+ const { scenes, to } = ref.current.state;
293
+
294
+ expect(sceneKeys(ref)).toEqual([root, child]);
295
+ expect(to.index).toBe(1);
296
+ expect(scenes[1].screenUniqueId).not.toBe(originalChildId);
297
+ });
298
+
299
+ it("ignores the end of a back animation that a newer back superseded", () => {
300
+ const { ref, navigate } = mountTransitioner();
301
+
302
+ navigate(child, NAV_ACTION_PUSH);
303
+ finishAnimation();
304
+ navigate(grandchild, NAV_ACTION_PUSH);
305
+ finishAnimation();
306
+
307
+ navigate(child, NAV_ACTION_BACK);
308
+
309
+ const supersededEnd = runningAnimationEnd;
310
+
311
+ // Deliver the interrupted animation's end late, after the newer one started
312
+ Animated.Value.prototype.setValue.mockImplementation(() => {});
313
+
314
+ navigate(root, NAV_ACTION_BACK);
315
+
316
+ act(() => {
317
+ supersededEnd();
318
+ });
319
+
320
+ expect(ref.current.state.animating).toBe(true);
321
+ expect(sceneKeys(ref)).toEqual([root, child, grandchild]);
322
+
323
+ finishAnimation();
324
+
325
+ expect(sceneKeys(ref)).toEqual([root]);
326
+ expect(ref.current.state.animating).toBe(false);
327
+ });
328
+
329
+ it("drops the pushed scene when going back before its push animation ends", () => {
330
+ const { ref, navigate } = mountTransitioner();
331
+
332
+ navigate(child, NAV_ACTION_PUSH);
333
+ navigate(root, NAV_ACTION_BACK);
334
+
335
+ expect(ref.current.state.animating).toBe(true);
336
+
337
+ finishAnimation();
338
+
339
+ expect(sceneKeys(ref)).toEqual([root]);
340
+ expect(ref.current.state.animating).toBe(false);
341
+ });
342
+
343
+ it("pushes right after the visible scene even when stale scenes are left behind", () => {
344
+ const { ref, navigate } = mountTransitioner();
345
+
346
+ navigate(child, NAV_ACTION_PUSH);
347
+ finishAnimation();
348
+
349
+ // Leftover past to.index, as some other path could leave behind
350
+ act(() => {
351
+ ref.current.setState(({ scenes }) => ({
352
+ scenes: [
353
+ ...scenes,
354
+ {
355
+ ...scenes[1],
356
+ key: `${root}/river/stale`,
357
+ screenUniqueId: "stale",
358
+ },
359
+ ],
360
+ }));
361
+ });
362
+
363
+ navigate(grandchild, NAV_ACTION_PUSH);
364
+ finishAnimation();
365
+
366
+ const { scenes, to } = ref.current.state;
367
+
368
+ expect(sceneKeys(ref)).toEqual([root, child, grandchild]);
369
+ expect(scenes[to.index].key).toBe(grandchild);
370
+ });
371
+
372
+ it("shows the replaced screen when going home during a back animation", () => {
373
+ const home = "/river/home";
374
+ const { ref, navigate } = mountTransitioner();
375
+
376
+ navigate(child, NAV_ACTION_PUSH);
377
+ finishAnimation();
378
+
379
+ navigate(root, NAV_ACTION_BACK);
380
+ navigate(home, NAV_ACTION_REPLACE);
381
+
382
+ const { scenes, to } = ref.current.state;
383
+
384
+ expect(scenes[to.index].key).toBe(home);
385
+
386
+ finishAnimation();
387
+
388
+ expect(sceneKeys(ref)).toEqual([home]);
389
+ expect(ref.current.state.animating).toBe(false);
390
+ });
391
+
392
+ it("shows the pushed screen when pushing during a back animation", () => {
393
+ const sibling = `${root}/river/sibling`;
394
+ const { ref, navigate } = mountTransitioner();
395
+
396
+ navigate(child, NAV_ACTION_PUSH);
397
+ finishAnimation();
398
+
399
+ navigate(root, NAV_ACTION_BACK);
400
+ navigate(sibling, NAV_ACTION_PUSH);
401
+
402
+ const { scenes, to } = ref.current.state;
403
+
404
+ expect(scenes[to.index].key).toBe(sibling);
405
+
406
+ finishAnimation();
407
+
408
+ expect(sceneKeys(ref)).not.toContain(child);
409
+ expect(ref.current.state.animating).toBe(false);
410
+ });
411
+
412
+ it("keeps both scenes when pushing during a push animation", () => {
413
+ const { ref, navigate } = mountTransitioner();
414
+
415
+ navigate(child, NAV_ACTION_PUSH);
416
+ navigate(grandchild, NAV_ACTION_PUSH);
417
+
418
+ expect(sceneKeys(ref)).toEqual([child, grandchild]);
419
+ expect(ref.current.state.to.index).toBe(1);
420
+ });
133
421
  });
134
422
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-ui-components",
3
- "version": "16.0.0-rc.108",
3
+ "version": "16.0.0-rc.109",
4
4
  "description": "Applicaster Zapp React Native ui components for the Quick Brick App",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -28,10 +28,10 @@
28
28
  },
29
29
  "homepage": "https://github.com/applicaster/quickbrick#readme",
30
30
  "dependencies": {
31
- "@applicaster/applicaster-types": "16.0.0-rc.108",
32
- "@applicaster/zapp-react-native-bridge": "16.0.0-rc.108",
33
- "@applicaster/zapp-react-native-redux": "16.0.0-rc.108",
34
- "@applicaster/zapp-react-native-utils": "16.0.0-rc.108",
31
+ "@applicaster/applicaster-types": "16.0.0-rc.109",
32
+ "@applicaster/zapp-react-native-bridge": "16.0.0-rc.109",
33
+ "@applicaster/zapp-react-native-redux": "16.0.0-rc.109",
34
+ "@applicaster/zapp-react-native-utils": "16.0.0-rc.109",
35
35
  "fast-json-stable-stringify": "^2.1.0",
36
36
  "promise": "^8.3.0",
37
37
  "react-native-sortables": "1.7.1",