fix(app): retain Explorer resize width until acknowledged

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
This commit is contained in:
Mohamed Boudra
2026-11-04 07:15:43 +01:00
co-authored by Elsa Granger
parent 0952ddbd4b
commit fced746a27
3 changed files with 240 additions and 16 deletions
@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import {
finishExplorerSidebarResize,
reconcileExplorerSidebarResize,
resolveExplorerSidebarDockSizes,
resolveExplorerSidebarFrameWidth,
resolveExplorerSidebarWidth,
type ExplorerSidebarResize,
} from "@/components/explorer-sidebar-layout";
describe("Explorer sidebar layout", () => {
@@ -19,4 +23,120 @@ describe("Explorer sidebar layout", () => {
expect(resolveExplorerSidebarWidth({ requestedWidth: 900, containerWidth: 1200 })).toBe(840);
expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(390);
});
describe("native resize ownership", () => {
const allocation = {
width: 320,
containerWidth: 1200,
overlay: false,
resizeEnabled: true,
};
const dragging: ExplorerSidebarResize = {
sourceWidth: allocation.width,
containerWidth: allocation.containerWidth,
pendingWidth: null,
};
it("keeps the drag width after release while the JS commit still has the old prop", () => {
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: dragging, resizeWidth: 480 }),
).toBe(480);
const released = finishExplorerSidebarResize(dragging, 480, true);
expect(reconcileExplorerSidebarResize(released, allocation)).toBe(released);
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: released, resizeWidth: 480 }),
).toBe(480);
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: released, resizeWidth: 320 }),
).toBe(480);
});
it("acknowledges the committed width and immediately follows subsequent props", () => {
const released = finishExplorerSidebarResize(dragging, 480, true);
const acknowledgedAllocation = { ...allocation, width: 480 };
expect(
resolveExplorerSidebarFrameWidth({
...acknowledgedAllocation,
resize: released,
resizeWidth: 480,
}),
).toBe(480);
const acknowledged = reconcileExplorerSidebarResize(released, acknowledgedAllocation);
expect(acknowledged).toBeNull();
expect(
resolveExplorerSidebarFrameWidth({
...allocation,
width: 360,
resize: acknowledged,
resizeWidth: 480,
}),
).toBe(360);
});
it.each([
{ name: "a new width prop", next: { ...allocation, width: 360 }, expected: 360 },
{
name: "a window allocation with an unchanged width prop",
next: { ...allocation, containerWidth: 900 },
expected: 320,
},
{
name: "a window allocation that clamps the width prop",
next: { ...allocation, width: 540, containerWidth: 900 },
expected: 540,
},
{ name: "overlay presentation", next: { ...allocation, overlay: true }, expected: 1200 },
{ name: "an inactive dock", next: { ...allocation, resizeEnabled: false }, expected: 320 },
])(
"yields synchronously to $name and does not resurrect the old handoff",
({ next, expected }) => {
const released = finishExplorerSidebarResize(dragging, 600, true);
expect(
resolveExplorerSidebarFrameWidth({ ...next, resize: released, resizeWidth: 600 }),
).toBe(expected);
const interrupted = reconcileExplorerSidebarResize(released, next);
expect(interrupted).toBeNull();
expect(
resolveExplorerSidebarFrameWidth({
...allocation,
resize: interrupted,
resizeWidth: 600,
}),
).toBe(320);
},
);
it("restores canonical intent when a drag is canceled", () => {
const canceled = finishExplorerSidebarResize(dragging, 480, false);
expect(canceled).toBeNull();
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: canceled, resizeWidth: 480 }),
).toBe(320);
});
it("cannot finish an interrupted drag over a newer allocation", () => {
const next = { ...allocation, width: 360, containerWidth: 1000 };
const interrupted = reconcileExplorerSidebarResize(dragging, next);
const released = finishExplorerSidebarResize(interrupted, 480, true);
expect(released).toBeNull();
expect(
resolveExplorerSidebarFrameWidth({ ...next, resize: released, resizeWidth: 480 }),
).toBe(360);
});
it("settles a release at the already-committed width without retaining ownership", () => {
const released = finishExplorerSidebarResize(dragging, allocation.width, true);
expect(reconcileExplorerSidebarResize(released, allocation)).toBeNull();
});
it("keeps an earlier released handoff when a later touch fails before activation", () => {
const released = finishExplorerSidebarResize(dragging, 480, true);
const finalized = finishExplorerSidebarResize(released, 480, false);
expect(finalized).toBe(released);
expect(
resolveExplorerSidebarFrameWidth({ ...allocation, resize: finalized, resizeWidth: 480 }),
).toBe(480);
});
});
});
@@ -16,6 +16,61 @@ export function resolveExplorerSidebarWidth(input: {
return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maximumVisibleWidth, requestedWidth));
}
// A release keeps its UI width until React acknowledges it or replaces its allocation.
// Null pendingWidth means the gesture still owns the live resizeWidth instead.
export interface ExplorerSidebarResize {
sourceWidth: number;
containerWidth: number;
pendingWidth: number | null;
}
interface ExplorerSidebarAllocation {
width: number;
containerWidth: number;
overlay: boolean;
resizeEnabled: boolean;
}
export function reconcileExplorerSidebarResize(
resize: ExplorerSidebarResize | null,
allocation: ExplorerSidebarAllocation,
): ExplorerSidebarResize | null {
"worklet";
if (
!resize ||
allocation.overlay ||
!allocation.resizeEnabled ||
allocation.containerWidth !== resize.containerWidth ||
allocation.width !== resize.sourceWidth ||
allocation.width === resize.pendingWidth
) {
return null;
}
return resize;
}
export function finishExplorerSidebarResize(
resize: ExplorerSidebarResize | null,
width: number,
success: boolean,
): ExplorerSidebarResize | null {
"worklet";
if (!resize || resize.pendingWidth !== null) return resize;
return success ? { ...resize, pendingWidth: width } : null;
}
export function resolveExplorerSidebarFrameWidth(
allocation: ExplorerSidebarAllocation & {
resize: ExplorerSidebarResize | null;
resizeWidth: number;
},
): number {
"worklet";
if (allocation.overlay) return allocation.containerWidth;
const resize = reconcileExplorerSidebarResize(allocation.resize, allocation);
return resize ? (resize.pendingWidth ?? allocation.resizeWidth) : allocation.width;
}
export function resolveExplorerSidebarDockSizes(input: {
requestedWidth?: number;
containerWidth: number;
@@ -1,6 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { Gesture } from "react-native-gesture-handler";
import { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import {
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
@@ -9,7 +14,13 @@ import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout";
import { resolveExplorerSidebarWidth } from "@/components/explorer-sidebar-layout";
import {
finishExplorerSidebarResize,
reconcileExplorerSidebarResize,
resolveExplorerSidebarFrameWidth,
resolveExplorerSidebarWidth,
type ExplorerSidebarResize,
} from "@/components/explorer-sidebar-layout";
import type { ExplorerGeometryInput, ExplorerGeometry } from "./geometry-types";
export function useExplorerGeometry({
width,
@@ -21,10 +32,16 @@ export function useExplorerGeometry({
const insets = useSafeAreaInsets();
const resizeWidth = useSharedValue(width);
const startWidth = useSharedValue(width);
const resize = useSharedValue<ExplorerSidebarResize | null>(null);
const [resizePressed, setResizePressed] = useState(false);
useEffect(() => {
resizeWidth.value = width;
}, [resizeWidth, width]);
useAnimatedReaction(
() => ({ resize: resize.value, width, containerWidth, overlay, resizeEnabled }),
({ resize: currentResize, ...allocation }) => {
const nextResize = reconcileExplorerSidebarResize(currentResize, allocation);
if (nextResize !== currentResize) resize.value = nextResize;
},
[containerWidth, overlay, resize, resizeEnabled, width],
);
const showResizeGrip = useCallback(() => setResizePressed(true), []);
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
@@ -37,22 +54,45 @@ export function useExplorerGeometry({
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
.onStart((event) => {
startWidth.value = width + event.translationX;
resizeWidth.value = width;
const currentWidth = resolveExplorerSidebarFrameWidth({
width,
containerWidth,
overlay,
resizeEnabled,
resize: resize.value,
resizeWidth: resizeWidth.value,
});
resize.value = { sourceWidth: width, containerWidth, pendingWidth: null };
startWidth.value = currentWidth + event.translationX;
resizeWidth.value = currentWidth;
})
.onUpdate((event) => {
if (!resize.value) return;
resizeWidth.value = resolveExplorerSidebarWidth({
requestedWidth: startWidth.value - event.translationX,
containerWidth,
});
})
.onEnd(() => runOnJS(onResize)(resizeWidth.value))
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
.onEnd((_event, success) => {
{
resize.value = finishExplorerSidebarResize(resize.value, resizeWidth.value, success);
if (!success || !resize.value) return;
}
runOnJS(onResize)(resizeWidth.value);
})
.onFinalize((_event, success) => {
if (!success) {
resize.value = finishExplorerSidebarResize(resize.value, resizeWidth.value, false);
}
scheduleOnRN(hideResizeGrip);
}),
[
resizeEnabled,
onResize,
containerWidth,
hideResizeGrip,
overlay,
resize,
resizeWidth,
showResizeGrip,
startWidth,
@@ -61,12 +101,21 @@ export function useExplorerGeometry({
);
// Reanimated retains the last animated value when its style is removed. Keep this
// style attached across folding and explicitly size overlays to the workspace viewport.
const animatedWidthStyle = useAnimatedStyle(
() => ({
width: overlay ? containerWidth : resizeWidth.value,
}),
[containerWidth, overlay],
);
const animatedWidthStyle = useAnimatedStyle(() => {
if (overlay) return { width: containerWidth };
{
return {
width: resolveExplorerSidebarFrameWidth({
width,
containerWidth,
overlay,
resizeEnabled,
resize: resize.value,
resizeWidth: resizeWidth.value,
}),
};
}
}, [containerWidth, overlay, resizeEnabled, width]);
return {
frameStyle: animatedWidthStyle,
panelStyle: {