From c147a1d1dbb41ae8a7b79e9f9173d27ca1b27ed2 Mon Sep 17 00:00:00 2001 From: waterWang Date: Tue, 11 Aug 2026 13:32:24 +0800 Subject: [PATCH] fix(Group): debounce ResizeObserver to prevent iPadOS transient resize corruption (#731) On iPadOS Safari, backgrounding the app (App Switcher) fires a rapid sequence of ResizeObserver events with transiently wrong container widths (e.g. 1180px -> 820px -> 585px -> 1180px within ~700ms). During the transient narrow reading, a Panel with a pixel minSize triggers a percentage inflation that is never re-derived when the container returns to its real width, because the preserve-relative-size behavior keeps the inflated percentage. The fix adds a short (50ms) debounce to the Group element's ResizeObserver callback. Only the final settled size in a burst is applied, so the transient narrow readings never corrupt the layout. Fixes #731 --- lib/global/mountGroup.ts | 124 +++++++++++++++++++++++---------------- 1 file changed, 73 insertions(+), 51 deletions(-) diff --git a/lib/global/mountGroup.ts b/lib/global/mountGroup.ts index cf7c53e4b..237729bf9 100644 --- a/lib/global/mountGroup.ts +++ b/lib/global/mountGroup.ts @@ -40,64 +40,81 @@ export function mountGroup(group: RegisteredGroup) { const panelIds = new Set(); const separatorIds = new Set(); + // Debounce timer to skip transient resize bursts (e.g. iPadOS Safari + // backgrounding fires a rapid sequence of fake resize events, see #731). + let resizeTimer: ReturnType | null = null; + + function handleGroupResize() { + if (!isMounted) { + return; + } + + const groupSize = calculateAvailableGroupSize({ group }); + if (groupSize === 0) { + // Can't calculate anything meaningful if the group has a width/height of 0 + // (This could indicate that it's within a hidden subtree) + return; + } + + const groupState = getMountedGroupState(group.id); + if (!groupState) { + // Not mounted yet + return; + } + + // Update non-percentage based constraints + const nextDerivedPanelConstraints = calculatePanelConstraints(group); + + // Revalidate layout in case constraints have changed or group size changed + const prevLayout = groupState.defaultLayoutDeferred + ? calculateDefaultLayout(nextDerivedPanelConstraints) + : groupState.layout; + const unsafeLayout = preserveFixedPanelSizes({ + group, + nextGroupSize: groupSize, + prevGroupSize: groupState.groupSize, + prevLayout + }); + const nextLayout = validatePanelGroupLayout({ + layout: unsafeLayout, + panelConstraints: nextDerivedPanelConstraints + }); + + if ( + !groupState.defaultLayoutDeferred && + layoutsEqual(groupState.layout, nextLayout) && + objectsEqual( + groupState.derivedPanelConstraints, + nextDerivedPanelConstraints + ) && + groupState.groupSize === groupSize + ) { + return; + } + + updateMountedGroup(group, { + defaultLayoutDeferred: false, + derivedPanelConstraints: nextDerivedPanelConstraints, + groupSize, + layout: nextLayout, + separatorToPanels: groupState.separatorToPanels + }); + } + // Add Panels with onResize callbacks to ResizeObserver // Add Group to ResizeObserver also in order to sync % based constraints const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { const { borderBoxSize, target } = entry; if (target === group.element) { - if (isMounted) { - const groupSize = calculateAvailableGroupSize({ group }); - if (groupSize === 0) { - // Can't calculate anything meaningful if the group has a width/height of 0 - // (This could indicate that it's within a hidden subtree) - return; - } - - const groupState = getMountedGroupState(group.id); - if (!groupState) { - // Not mounted yet - return; - } - - // Update non-percentage based constraints - const nextDerivedPanelConstraints = calculatePanelConstraints(group); - - // Revalidate layout in case constraints have changed or group size changed - const prevLayout = groupState.defaultLayoutDeferred - ? calculateDefaultLayout(nextDerivedPanelConstraints) - : groupState.layout; - const unsafeLayout = preserveFixedPanelSizes({ - group, - nextGroupSize: groupSize, - prevGroupSize: groupState.groupSize, - prevLayout - }); - const nextLayout = validatePanelGroupLayout({ - layout: unsafeLayout, - panelConstraints: nextDerivedPanelConstraints - }); - - if ( - !groupState.defaultLayoutDeferred && - layoutsEqual(groupState.layout, nextLayout) && - objectsEqual( - groupState.derivedPanelConstraints, - nextDerivedPanelConstraints - ) && - groupState.groupSize === groupSize - ) { - return; - } - - updateMountedGroup(group, { - defaultLayoutDeferred: false, - derivedPanelConstraints: nextDerivedPanelConstraints, - groupSize, - layout: nextLayout, - separatorToPanels: groupState.separatorToPanels - }); + // Debounce to skip transient resize bursts (issue #731). + // On iPadOS Safari, backgrounding the app can fire a rapid sequence + // of resize events with transiently wrong container widths. A short + // debounce ensures only the final settled size is applied. + if (resizeTimer !== null) { + clearTimeout(resizeTimer); } + resizeTimer = setTimeout(handleGroupResize, 50); } else { notifyPanelOnResize(group, target as HTMLElement, borderBoxSize); } @@ -226,6 +243,11 @@ export function mountGroup(group: RegisteredGroup) { ownerDocument.removeEventListener("pointerup", onDocumentPointerUp, true); } + if (resizeTimer !== null) { + clearTimeout(resizeTimer); + resizeTimer = null; + } + resizeObserver.disconnect(); }; }