fix: lay out the reader's pages at the pane's width so a tablet pane is not left empty

This commit is contained in:
Linus Rath
2026-10-31 13:56:46 +01:00
parent e0184cfb46
commit e69bfc502b
2 changed files with 45 additions and 7 deletions
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// Tablet, landscape: the first message opened after a relaunch and a
// rotation showed an empty reading pane (toolbar and bottom bar only). The
// pager laid its pages out at the window's width before it knew the pane's,
// so initialScrollIndex put the open message at the wrong offset, and
// removeClippedSubviews could leave that page detached. The pages now wait
// for the measured width. No React Native renderer in the test env, so this
// checks the wiring.
const src = fs.readFileSync(path.resolve(__dirname, '..', '..', 'screens/EmailThreadScreen.tsx'), 'utf8');
describe('reader pager width', () => {
it('renders the pages only once the pager is measured', () => {
expect(src).toMatch(/<View style=\{styles\.pagerViewport\} onLayout=\{onPagerLayout\}>\s+\{measuredWidth > 0 && \(\s+<FlatList/);
expect(src).toMatch(/const pageWidth = measuredWidth \|\| windowWidth;/);
});
it('does not clip the pages away', () => {
const pager = src.slice(src.indexOf('<FlatList'), src.indexOf('renderItem={({ item, index })'));
expect(pager).not.toMatch(/^\s+removeClippedSubviews\s*$/m);
});
it('re-renders the pages when their width changes', () => {
expect(src).toMatch(/\(\) => \[cacheVersion, neighboursReady, pageWidth\]/);
});
});
+16 -7
View File
@@ -119,8 +119,13 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
const { width: windowWidth } = useWindowDimensions();
// Pages are as wide as the pager itself, which is the window on a phone
// but only the detail pane in a split view. Measured, so a rotation or a
// pane resize re-sizes them.
const [pageWidth, setPageWidth] = React.useState(windowWidth);
// pane resize re-sizes them. The pages render only once it is measured:
// laid out at the window's width first, a split view's pager scrolled the
// opened message to the wrong offset, and on a tablet the pane once stayed
// empty (just the toolbar and the bottom bar) until the message was
// reopened.
const [measuredWidth, setMeasuredWidth] = React.useState(0);
const pageWidth = measuredWidth || windowWidth;
const markRead = useEmailStore((s) => s.markRead);
const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails);
const deleteEmail = useEmailStore((s) => s.deleteEmail);
@@ -336,12 +341,12 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
}, [pageWidth]);
const onPagerLayout = React.useCallback((e: LayoutChangeEvent) => {
const width = Math.round(e.nativeEvent.layout.width);
if (width > 0) setPageWidth((prev) => (prev === width ? prev : width));
if (width > 0) setMeasuredWidth((prev) => (prev === width ? prev : width));
}, []);
const pagerExtraData = React.useMemo(
() => [cacheVersion, neighboursReady],
[cacheVersion, neighboursReady],
() => [cacheVersion, neighboursReady, pageWidth],
[cacheVersion, neighboursReady, pageWidth],
);
// While the body is pinch-zoomed the pager must not treat horizontal
@@ -815,10 +820,11 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
</View>
) : (
<>
<View style={styles.pagerViewport} onLayout={onPagerLayout}>
{measuredWidth > 0 && (
<FlatList
ref={listRef}
style={styles.pagerViewport}
onLayout={onPagerLayout}
onContentSizeChange={snapToActive}
data={emails}
extraData={pagerExtraData}
@@ -831,7 +837,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
windowSize={3}
initialNumToRender={1}
maxToRenderPerBatch={2}
removeClippedSubviews
// Not removeClippedSubviews: with three pages it saves nothing,
// and Android can leave the open page detached after a resize.
scrollEnabled={!pagerLocked && kbObstruction === 0}
onScrollBeginDrag={releaseNeighbours}
onMomentumScrollEnd={onMomentumEnd}
@@ -876,6 +883,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
</View>
)}
/>
)}
</View>
{/* Bottom action bar (hidden while keyboard is up so quick reply stays visible) */}
{kbObstruction === 0 && (