mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
fix(ui): synchronize swipe action gesture handlers with latest props
Fixes #30. PanResponder in SwipeableRow was created once on mount and captured initial props in a stale closure. Using mutable refs ensures gesture handlers always read and execute the latest leftAction, rightAction, mode, and onAction props.
This commit is contained in:
@@ -73,6 +73,15 @@ export function SwipeableRow({
|
||||
const claimed = useRef(false);
|
||||
const widthRef = useRef(Dimensions.get('window').width);
|
||||
|
||||
const leftActionRef = useRef(leftAction);
|
||||
leftActionRef.current = leftAction;
|
||||
const rightActionRef = useRef(rightAction);
|
||||
rightActionRef.current = rightAction;
|
||||
const modeRef = useRef(mode);
|
||||
modeRef.current = mode;
|
||||
const onActionRef = useRef(onAction);
|
||||
onActionRef.current = onAction;
|
||||
|
||||
// Reveal-mode-only state: which side (if any) is currently sitting open.
|
||||
const openSideRef = useRef<'left' | 'right' | null>(null);
|
||||
const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null);
|
||||
@@ -101,13 +110,13 @@ export function SwipeableRow({
|
||||
duration: 180,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
onAction(action);
|
||||
onActionRef.current(action);
|
||||
// The row is about to be removed; reset translation in case it isn't
|
||||
// (e.g. the action failed silently) so we don't leave it off-screen.
|
||||
dx.setValue(0);
|
||||
});
|
||||
} else {
|
||||
onAction(action);
|
||||
onActionRef.current(action);
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
}
|
||||
};
|
||||
@@ -118,9 +127,9 @@ export function SwipeableRow({
|
||||
if (exitsRow(action)) {
|
||||
// Let the row collapse a frame, then fire so the parent's list update
|
||||
// has a clean starting point.
|
||||
requestAnimationFrame(() => onAction(action));
|
||||
requestAnimationFrame(() => onActionRef.current(action));
|
||||
} else {
|
||||
onAction(action);
|
||||
onActionRef.current(action);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,22 +139,26 @@ export function SwipeableRow({
|
||||
onMoveShouldSetPanResponder: (_, g) => {
|
||||
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
|
||||
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
|
||||
const currentMode = modeRef.current;
|
||||
const currentRightAction = rightActionRef.current;
|
||||
const currentLeftAction = leftActionRef.current;
|
||||
// Reveal mode: if a side is already open, allow the gesture so the
|
||||
// user can drag it closed.
|
||||
if (mode === 'reveal' && openSideRef.current === null) {
|
||||
if (g.dx > 0 && rightAction === 'none') return false;
|
||||
if (g.dx < 0 && leftAction === 'none') return false;
|
||||
} else if (mode !== 'reveal') {
|
||||
if (g.dx > 0 && rightAction === 'none') return false;
|
||||
if (g.dx < 0 && leftAction === 'none') return false;
|
||||
if (currentMode === 'reveal' && openSideRef.current === null) {
|
||||
if (g.dx > 0 && currentRightAction === 'none') return false;
|
||||
if (g.dx < 0 && currentLeftAction === 'none') return false;
|
||||
} else if (currentMode !== 'reveal') {
|
||||
if (g.dx > 0 && currentRightAction === 'none') return false;
|
||||
if (g.dx < 0 && currentLeftAction === 'none') return false;
|
||||
}
|
||||
claimed.current = true;
|
||||
return true;
|
||||
},
|
||||
onPanResponderMove: (_, g) => {
|
||||
const overshoot = mode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
|
||||
const currentMode = modeRef.current;
|
||||
const overshoot = currentMode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
|
||||
const base =
|
||||
mode === 'reveal'
|
||||
currentMode === 'reveal'
|
||||
? openSideRef.current === 'right' ? REVEAL_WIDTH
|
||||
: openSideRef.current === 'left' ? -REVEAL_WIDTH
|
||||
: 0
|
||||
@@ -156,15 +169,18 @@ export function SwipeableRow({
|
||||
},
|
||||
onPanResponderRelease: (_, g) => {
|
||||
claimed.current = false;
|
||||
if (mode === 'reveal') {
|
||||
const currentMode = modeRef.current;
|
||||
const currentRightAction = rightActionRef.current;
|
||||
const currentLeftAction = leftActionRef.current;
|
||||
if (currentMode === 'reveal') {
|
||||
const base =
|
||||
openSideRef.current === 'right' ? REVEAL_WIDTH
|
||||
: openSideRef.current === 'left' ? -REVEAL_WIDTH
|
||||
: 0;
|
||||
const finalDx = base + g.dx;
|
||||
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
|
||||
if (finalDx > ACTIVATION_THRESHOLD && currentRightAction !== 'none') {
|
||||
openTo('right');
|
||||
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
|
||||
} else if (finalDx < -ACTIVATION_THRESHOLD && currentLeftAction !== 'none') {
|
||||
openTo('left');
|
||||
} else {
|
||||
close();
|
||||
@@ -172,16 +188,16 @@ export function SwipeableRow({
|
||||
return;
|
||||
}
|
||||
// Instant mode: fire on release-past-threshold, no second tap.
|
||||
if ((g.dx >= COMMIT_THRESHOLD || (g.dx > 40 && g.vx >= 0.5)) && rightAction !== 'none') {
|
||||
fly(widthRef.current || EXIT_DISTANCE, rightAction);
|
||||
} else if ((g.dx <= -COMMIT_THRESHOLD || (g.dx < -40 && g.vx <= -0.5)) && leftAction !== 'none') {
|
||||
fly(-(widthRef.current || EXIT_DISTANCE), leftAction);
|
||||
if ((g.dx >= COMMIT_THRESHOLD || (g.dx > 40 && g.vx >= 0.5)) && currentRightAction !== 'none') {
|
||||
fly(widthRef.current || EXIT_DISTANCE, currentRightAction);
|
||||
} else if ((g.dx <= -COMMIT_THRESHOLD || (g.dx < -40 && g.vx <= -0.5)) && currentLeftAction !== 'none') {
|
||||
fly(-(widthRef.current || EXIT_DISTANCE), currentLeftAction);
|
||||
} else {
|
||||
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
}
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
if (mode === 'reveal') close();
|
||||
if (modeRef.current === 'reveal') close();
|
||||
else Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
|
||||
claimed.current = false;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
describe('SwipeableRow logic', () => {
|
||||
it('should use mutable refs so updated props are immediately reflected in gesture callbacks', () => {
|
||||
let leftAction = 'read';
|
||||
let rightAction = 'archive';
|
||||
const onAction = vi.fn();
|
||||
|
||||
const leftActionRef = { current: leftAction };
|
||||
const rightActionRef = { current: rightAction };
|
||||
const onActionRef = { current: onAction };
|
||||
|
||||
const fireAction = (side: 'left' | 'right') => {
|
||||
const action = side === 'left' ? leftActionRef.current : rightActionRef.current;
|
||||
onActionRef.current(action);
|
||||
};
|
||||
|
||||
// Initially left action is 'read'
|
||||
fireAction('left');
|
||||
expect(onAction).toHaveBeenCalledWith('read');
|
||||
|
||||
// Simulate prop change on re-render (e.g. user updated setting to 'delete')
|
||||
leftAction = 'delete';
|
||||
leftActionRef.current = leftAction;
|
||||
|
||||
// Call gesture release handler again without re-creating gesture instance
|
||||
fireAction('left');
|
||||
expect(onAction).toHaveBeenLastCalledWith('delete');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user