Skip to content

Commit fec5014

Browse files
committed
add handle-based transitions
1 parent e2f64a8 commit fec5014

7 files changed

Lines changed: 148 additions & 63 deletions

File tree

src/components/Modal/ReanimatedModal/index.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions';
1010
import blurActiveElement from '@libs/Accessibility/blurActiveElement';
1111
import getPlatform from '@libs/getPlatform';
1212
import TransitionTracker from '@libs/Navigation/TransitionTracker';
13+
import type {TransitionHandle} from '@libs/Navigation/TransitionTracker';
1314
import variables from '@styles/variables';
1415
import CONST from '@src/CONST';
1516
import Backdrop from './Backdrop';
@@ -58,6 +59,7 @@ function ReanimatedModal({
5859

5960
const backHandlerListener = useRef<NativeEventSubscription | null>(null);
6061
const handleRef = useRef<number | undefined>(undefined);
62+
const transitionHandleRef = useRef<TransitionHandle | null>(null);
6163

6264
const styles = useThemeStyles();
6365

@@ -104,7 +106,10 @@ function ReanimatedModal({
104106
// eslint-disable-next-line @typescript-eslint/no-deprecated
105107
InteractionManager.clearInteractionHandle(handleRef.current);
106108
}
107-
TransitionTracker.endTransition();
109+
if (transitionHandleRef.current) {
110+
TransitionTracker.endTransition(transitionHandleRef.current);
111+
transitionHandleRef.current = null;
112+
}
108113

109114
setIsVisibleState(false);
110115
setIsContainerOpen(false);
@@ -117,15 +122,15 @@ function ReanimatedModal({
117122
if (isVisible && !isContainerOpen && !isTransitioning) {
118123
// eslint-disable-next-line @typescript-eslint/no-deprecated
119124
handleRef.current = InteractionManager.createInteractionHandle();
120-
TransitionTracker.startTransition();
125+
transitionHandleRef.current = TransitionTracker.startTransition();
121126
onModalWillShow();
122127

123128
setIsVisibleState(true);
124129
setIsTransitioning(true);
125130
} else if (!isVisible && isContainerOpen && !isTransitioning) {
126131
// eslint-disable-next-line @typescript-eslint/no-deprecated
127132
handleRef.current = InteractionManager.createInteractionHandle();
128-
TransitionTracker.startTransition();
133+
transitionHandleRef.current = TransitionTracker.startTransition();
129134
onModalWillHide();
130135

131136
blurActiveElement();
@@ -146,7 +151,10 @@ function ReanimatedModal({
146151
// eslint-disable-next-line @typescript-eslint/no-deprecated
147152
InteractionManager.clearInteractionHandle(handleRef.current);
148153
}
149-
TransitionTracker.endTransition();
154+
if (transitionHandleRef.current) {
155+
TransitionTracker.endTransition(transitionHandleRef.current);
156+
transitionHandleRef.current = null;
157+
}
150158
onModalShow();
151159
}, [onModalShow]);
152160

@@ -157,7 +165,10 @@ function ReanimatedModal({
157165
// eslint-disable-next-line @typescript-eslint/no-deprecated
158166
InteractionManager.clearInteractionHandle(handleRef.current);
159167
}
160-
TransitionTracker.endTransition();
168+
if (transitionHandleRef.current) {
169+
TransitionTracker.endTransition(transitionHandleRef.current);
170+
transitionHandleRef.current = null;
171+
}
161172

162173
// Because on Android, the Modal's onDismiss callback does not work reliably. There's a reported issue at:
163174
// https://stackoverflow.com/questions/58937956/react-native-modal-ondismiss-not-invoked

src/libs/Navigation/PlatformStackNavigation/ScreenLayout.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {ParamListBase, ScreenLayoutArgs} from '@react-navigation/native';
2-
import React, {useLayoutEffect} from 'react';
2+
import React, {useLayoutEffect, useRef} from 'react';
33
import TransitionTracker from '@libs/Navigation/TransitionTracker';
4+
import type {TransitionHandle} from '@libs/Navigation/TransitionTracker';
45
import type {PlatformSpecificNavigationOptions, PlatformStackNavigationOptions, PlatformStackNavigationProp} from './types';
56

67
// screenLayout is invoked as a render function (not JSX), so we need this wrapper to create a proper React component boundary for hooks.
@@ -19,12 +20,18 @@ function ScreenLayout({
1920
children,
2021
navigation,
2122
}: ScreenLayoutArgs<ParamListBase, string, PlatformSpecificNavigationOptions | PlatformStackNavigationOptions, PlatformStackNavigationProp<ParamListBase>>) {
23+
const transitionHandleRef = useRef<TransitionHandle | null>(null);
24+
2225
useLayoutEffect(() => {
2326
const transitionStartListener = navigation.addListener('transitionStart', () => {
24-
TransitionTracker.startTransition();
27+
transitionHandleRef.current = TransitionTracker.startTransition();
2528
});
2629
const transitionEndListener = navigation.addListener('transitionEnd', () => {
27-
TransitionTracker.endTransition();
30+
if (!transitionHandleRef.current) {
31+
return;
32+
}
33+
TransitionTracker.endTransition(transitionHandleRef.current);
34+
transitionHandleRef.current = null;
2835
});
2936

3037
return () => {

src/libs/Navigation/TransitionTracker.ts

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import Log from '@libs/Log';
22
import CONST from '@src/CONST';
33

4+
type TransitionHandle = symbol;
5+
46
type CancelHandle = {cancel: () => void};
57

68
type RunAfterTransitionsOptions = {
@@ -16,9 +18,7 @@ type RunAfterTransitionsOptions = {
1618
waitForUpcomingTransition?: boolean;
1719
};
1820

19-
let activeCount = 0;
20-
21-
const activeTimeouts: Array<ReturnType<typeof setTimeout>> = [];
21+
const activeTransitions = new Map<TransitionHandle, ReturnType<typeof setTimeout>>();
2222

2323
let pendingCallbacks: Array<() => void> = [];
2424

@@ -44,24 +44,23 @@ function flushCallbacks(): void {
4444
}
4545

4646
/**
47-
* Decrements the active count and flushes callbacks when all transitions are idle.
47+
* Flushes callbacks when all transitions are idle.
4848
* Shared by {@link endTransition} (manual) and the auto-timeout.
4949
*/
5050
function decrementAndFlush(): void {
51-
activeCount = Math.max(0, activeCount - 1);
52-
53-
if (activeCount === 0) {
54-
flushCallbacks();
51+
if (activeTransitions.size !== 0) {
52+
return;
5553
}
54+
flushCallbacks();
5655
}
5756

5857
/**
59-
* Increments the active transition count.
60-
* Multiple overlapping transitions are counted.
61-
* Each transition automatically ends after {@link MAX_TRANSITION_DURATION_MS} as a safety net.
58+
* Increments the active transition count and returns a handle that must be passed to {@link endTransition}.
59+
* Multiple overlapping transitions are tracked independently.
60+
* Each transition automatically ends after {@link CONST.MAX_TRANSITION_DURATION_MS} as a safety net.
6261
*/
63-
function startTransition(): void {
64-
activeCount += 1;
62+
function startTransition(): TransitionHandle {
63+
const handle: TransitionHandle = Symbol('transition');
6564

6665
const resolve = nextTransitionStartResolve;
6766
if (resolve) {
@@ -73,27 +72,29 @@ function startTransition(): void {
7372
}
7473

7574
const timeout = setTimeout(() => {
76-
const idx = activeTimeouts.indexOf(timeout);
77-
if (idx !== -1) {
78-
activeTimeouts.splice(idx, 1);
79-
}
75+
activeTransitions.delete(handle);
8076
decrementAndFlush();
8177
}, CONST.MAX_TRANSITION_DURATION_MS);
8278

83-
activeTimeouts.push(timeout);
79+
activeTransitions.set(handle, timeout);
80+
81+
return handle;
8482
}
8583

8684
/**
87-
* Decrements the active transition count.
88-
* Clears the corresponding auto-timeout since the transition ended normally.
89-
* When the count reaches zero, flushes all pending callbacks.
85+
* Ends the transition identified by {@link handle}.
86+
* Clears the corresponding safety timeout since the transition ended normally.
87+
* When no active transitions remain, flushes all pending callbacks.
88+
* If the handle is unknown (already ended or already expired via safety timeout), this is a no-op.
9089
*/
91-
function endTransition(): void {
92-
const timeout = activeTimeouts.shift();
93-
if (timeout !== undefined) {
94-
clearTimeout(timeout);
90+
function endTransition(handle: TransitionHandle): void {
91+
const timeout = activeTransitions.get(handle);
92+
if (timeout === undefined) {
93+
return;
9594
}
9695

96+
clearTimeout(timeout);
97+
activeTransitions.delete(handle);
9798
decrementAndFlush();
9899
}
99100

@@ -133,12 +134,13 @@ function runAfterTransitions({callback, runImmediately = false, waitForUpcomingT
133134
return {
134135
cancel: () => {
135136
cancelled = true;
137+
clearTimeout(transitionStartTimeoutId);
136138
innerHandle?.cancel();
137139
},
138140
};
139141
}
140142

141-
if (activeCount === 0 || runImmediately) {
143+
if (activeTransitions.size === 0 || runImmediately) {
142144
callback();
143145
return {cancel: () => {}};
144146
}
@@ -162,4 +164,4 @@ const TransitionTracker = {
162164
};
163165

164166
export default TransitionTracker;
165-
export type {CancelHandle};
167+
export type {CancelHandle, TransitionHandle};

src/utils/keyboard/index.android.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@ const dismiss = (options?: DismissKeyboardOptions): Promise<void> => {
3232
return;
3333
}
3434

35-
TransitionTracker.startTransition();
35+
const transitionHandle = TransitionTracker.startTransition();
3636
const subscription = Keyboard.addListener('keyboardDidHide', () => {
3737
resolve();
38-
TransitionTracker.endTransition();
38+
TransitionTracker.endTransition(transitionHandle);
3939
subscription.remove();
4040
});
4141
Keyboard.dismiss();

src/utils/keyboard/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@ const dismiss = (options?: DismissKeyboardOptions): Promise<void> => {
3131
return;
3232
}
3333

34-
TransitionTracker.startTransition();
34+
const transitionHandle = TransitionTracker.startTransition();
3535
const subscription = Keyboard.addListener('keyboardDidHide', () => {
3636
resolve();
37-
TransitionTracker.endTransition();
37+
TransitionTracker.endTransition(transitionHandle);
3838
subscription.remove();
3939
});
4040
Keyboard.dismiss();

src/utils/keyboard/index.website.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ const dismiss = (options?: DismissKeyboardOptions): Promise<void> => {
4747
return;
4848
}
4949

50+
const transitionHandle = TransitionTracker.startTransition();
51+
5052
const handleDismissResize = () => {
5153
const viewportHeight = window?.visualViewport?.height;
5254

@@ -60,11 +62,10 @@ const dismiss = (options?: DismissKeyboardOptions): Promise<void> => {
6062
}
6163

6264
window.visualViewport?.removeEventListener('resize', handleDismissResize);
63-
TransitionTracker.endTransition();
65+
TransitionTracker.endTransition(transitionHandle);
6466
return resolve();
6567
};
6668

67-
TransitionTracker.startTransition();
6869
window.visualViewport?.addEventListener('resize', handleDismissResize);
6970
Keyboard.dismiss();
7071

0 commit comments

Comments
 (0)