From cafdcb3496e7f80580ce54620c4f3d4f946f91ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Tue, 7 Jul 2026 17:01:01 +0200 Subject: [PATCH 1/2] Re-establish sortable layout when the app returns to the foreground --- .../providers/shared/MeasurementsProvider.ts | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/react-native-sortables/src/providers/shared/MeasurementsProvider.ts b/packages/react-native-sortables/src/providers/shared/MeasurementsProvider.ts index 522502c0..743aa689 100644 --- a/packages/react-native-sortables/src/providers/shared/MeasurementsProvider.ts +++ b/packages/react-native-sortables/src/providers/shared/MeasurementsProvider.ts @@ -1,4 +1,5 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; +import { AppState } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; import { runOnUI } from 'react-native-reanimated'; @@ -41,7 +42,9 @@ const { MeasurementsProvider, useMeasurementsContext } = createProvider( containerWidth, controlledContainerDimensions, controlledItemDimensions, + itemCurrentPositions, itemHeights, + itemLayoutPositions, itemWidths, usesAbsoluteLayout } = useCommonValuesContext(); @@ -235,6 +238,40 @@ const { MeasurementsProvider, useMeasurementsContext } = createProvider( })(); }, [itemHeights, itemWidths, context]); + // Re-establish the layout when the app returns to the foreground. + // After an extended background, the platform may recreate the sortable's + // native views (and Reanimated may not re-run the mappers that position them), + // which leaves the absolutely positioned items overlapping until the next + // drag re-runs the layout. Item positions live entirely in Reanimated shared + // values, so on resume we drop the JS-side dimension cache (so the onLayout + // events fired by recreated views are treated as fresh measurements instead + // of being diffed away) and re-emit each inactive item's position from the + // authoritative layout so the freshly committed views pick it up. + // https://github.com/MatiPl01/react-native-sortables/issues/592 + useEffect(() => { + const subscription = AppState.addEventListener('change', state => { + if (state !== 'active') { + return; + } + previousItemDimensionsRef.current = {}; + runOnUI(() => { + const activeKey = activeItemKey.value; + const layoutPositions = itemLayoutPositions.value; + for (const key in itemCurrentPositions.value) { + if (key === activeKey) { + continue; + } + const target = layoutPositions[key]; + const position = itemCurrentPositions.value[key]; + if (target && position) { + position.value = { x: target.x, y: target.y }; + } + } + })(); + }); + return () => subscription.remove(); + }, [activeItemKey, itemCurrentPositions, itemLayoutPositions]); + return { value: { applyControlledContainerDimensions, From ce0b78681f634b65520ef42523de524ba5c396f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Wed, 8 Jul 2026 11:00:32 +0200 Subject: [PATCH 2/2] Skip the resume re-seed while still in relative layout The AppState re-seed only matters once the grid uses absolute positioning; in relative flow there are no absolute positions to restore, so gate it on usesAbsoluteLayout and make it a no-op otherwise. Also correct the explanatory comment to describe the real mechanism: a recreated native view never receives the last emitted style because the shared value did not change, so re-assigning each inactive item's position (a fresh object) forces Reanimated to re-push the style, reproducing what the first drag would otherwise do. --- .../providers/shared/MeasurementsProvider.ts | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/react-native-sortables/src/providers/shared/MeasurementsProvider.ts b/packages/react-native-sortables/src/providers/shared/MeasurementsProvider.ts index 743aa689..c93a7983 100644 --- a/packages/react-native-sortables/src/providers/shared/MeasurementsProvider.ts +++ b/packages/react-native-sortables/src/providers/shared/MeasurementsProvider.ts @@ -239,14 +239,25 @@ const { MeasurementsProvider, useMeasurementsContext } = createProvider( }, [itemHeights, itemWidths, context]); // Re-establish the layout when the app returns to the foreground. - // After an extended background, the platform may recreate the sortable's - // native views (and Reanimated may not re-run the mappers that position them), - // which leaves the absolutely positioned items overlapping until the next - // drag re-runs the layout. Item positions live entirely in Reanimated shared - // values, so on resume we drop the JS-side dimension cache (so the onLayout - // events fired by recreated views are treated as fresh measurements instead - // of being diffed away) and re-emit each inactive item's position from the - // authoritative layout so the freshly committed views pick it up. + // + // Each item's absolute position lives only in a Reanimated shared value whose + // derived style is pushed to the native view exactly once, when the value + // changes. After a long background some platforms recreate the sortable's + // native views; the shared values survive but the freshly committed views + // never receive the last emitted style (the value did not change, so nothing + // re-pushes it), so absolutely positioned items collapse onto each other. The + // first drag is the only thing that mutates a position and thus re-pushes the + // style, which is why "a drag fixes it". + // + // On resume we reproduce that nudge without a drag: re-assign every inactive + // item's position to its authoritative layout target. The value is unchanged + // but the write is a fresh object, so Reanimated re-emits the style to the + // (possibly recreated) view. We also drop the JS dimension cache so a + // recreated view's onLayout is treated as a fresh measurement rather than + // diffed away, covering items whose size changed while backgrounded. The + // active item is skipped so a drag interrupted by backgrounding is not fought. + // Skipped entirely while still in relative layout, where there are no absolute + // positions to restore. // https://github.com/MatiPl01/react-native-sortables/issues/592 useEffect(() => { const subscription = AppState.addEventListener('change', state => { @@ -255,6 +266,9 @@ const { MeasurementsProvider, useMeasurementsContext } = createProvider( } previousItemDimensionsRef.current = {}; runOnUI(() => { + if (!usesAbsoluteLayout.value) { + return; + } const activeKey = activeItemKey.value; const layoutPositions = itemLayoutPositions.value; for (const key in itemCurrentPositions.value) { @@ -270,7 +284,12 @@ const { MeasurementsProvider, useMeasurementsContext } = createProvider( })(); }); return () => subscription.remove(); - }, [activeItemKey, itemCurrentPositions, itemLayoutPositions]); + }, [ + activeItemKey, + itemCurrentPositions, + itemLayoutPositions, + usesAbsoluteLayout + ]); return { value: {