fix(reader): apply page margin changes live on all platforms (#4898) (#4900)
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (rust) (push) Waiting to run
Publish Docker image / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Publish Docker image / merge (push) Blocked by required conditions
PR checks / rust_lint (push) Waiting to run
PR checks / build_web_app (push) Waiting to run
PR checks / test_web_app (1) (push) Waiting to run
PR checks / test_web_app (2) (push) Waiting to run
PR checks / test_extensions (push) Waiting to run
PR checks / build_tauri_app (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Deploy to vercel on merge / build_and_deploy (push) Waiting to run

Adjusting the top, bottom, left, or right page margin had no visible
effect until an unrelated setting (e.g. Show Header) was toggled.

The BooksGrid perf refactor (#4562) memoized the derived view/content
insets on the ViewSettings object identity. saveViewSettings mutates
ViewSettings in place (same reference), so the memo never recomputed on
a margin edit and the new margin never reached the paginator. Left and
right margins were always stale; top and bottom only refreshed when the
header/footer visibility (an effect dependency) changed, which is why
toggling the header appeared to apply a pending change.

Extract the inset derivation into useContentInsets and memoize by the
resolved numeric values instead of the object reference: identical
numbers across a page turn keep a stable reference (no re-render storm),
while a changed margin yields a new one that propagates to the renderer.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin 2026-07-03 03:24:34 +09:00 committed by GitHub
parent 77ea87c344
commit fd8fbb178c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 143 additions and 28 deletions

View file

@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useContentInsets } from '@/app/reader/hooks/useContentInsets';
import { ViewSettings } from '@/types/book';
const ZERO: { top: number; right: number; bottom: number; left: number } = {
top: 0,
right: 0,
bottom: 0,
left: 0,
};
const makeViewSettings = (overrides: Partial<ViewSettings> = {}): ViewSettings =>
({
showHeader: true,
showFooter: true,
vertical: false,
writingMode: 'horizontal-tb',
marginTopPx: 44,
marginBottomPx: 44,
marginLeftPx: 16,
marginRightPx: 16,
compactMarginTopPx: 16,
compactMarginBottomPx: 16,
compactMarginLeftPx: 16,
compactMarginRightPx: 16,
...overrides,
}) as ViewSettings;
describe('useContentInsets', () => {
// Regression for #4898: saveViewSettings mutates the ViewSettings object in
// place (same reference), so left/right margin edits never reached the
// paginator because the derived insets were memoized on the object identity.
it('reflects an in-place left/right margin edit on the same viewSettings object', () => {
const viewSettings = makeViewSettings();
const { result, rerender } = renderHook(({ vs, gi }) => useContentInsets(vs, gi), {
initialProps: { vs: viewSettings, gi: ZERO },
});
expect(result.current.contentInsets.left).toBe(16);
expect(result.current.contentInsets.right).toBe(16);
viewSettings.compactMarginLeftPx = 40;
viewSettings.compactMarginRightPx = 32;
rerender({ vs: viewSettings, gi: ZERO });
expect(result.current.contentInsets.left).toBe(40);
expect(result.current.contentInsets.right).toBe(32);
});
it('reflects an in-place top/bottom margin edit when header/footer are shown', () => {
const viewSettings = makeViewSettings();
const { result, rerender } = renderHook(({ vs, gi }) => useContentInsets(vs, gi), {
initialProps: { vs: viewSettings, gi: ZERO },
});
expect(result.current.contentInsets.top).toBe(44);
expect(result.current.contentInsets.bottom).toBe(44);
viewSettings.marginTopPx = 60;
viewSettings.marginBottomPx = 52;
rerender({ vs: viewSettings, gi: ZERO });
expect(result.current.contentInsets.top).toBe(60);
expect(result.current.contentInsets.bottom).toBe(52);
});
it('adds the grid insets to the page margins', () => {
const viewSettings = makeViewSettings();
const { result } = renderHook(() =>
useContentInsets(viewSettings, { top: 10, right: 4, bottom: 8, left: 6 }),
);
expect(result.current.contentInsets.top).toBe(54);
expect(result.current.contentInsets.left).toBe(22);
});
it('keeps a stable contentInsets reference across a page turn (unchanged margins)', () => {
const viewSettings = makeViewSettings();
const { result, rerender } = renderHook(({ vs, gi }) => useContentInsets(vs, gi), {
initialProps: { vs: viewSettings, gi: ZERO },
});
const first = result.current.contentInsets;
// A page turn re-renders the cell without touching any margin setting.
rerender({ vs: viewSettings, gi: ZERO });
expect(result.current.contentInsets).toBe(first);
});
});

View file

@ -10,7 +10,7 @@ import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { getGridTemplate, getInsetEdges } from '@/utils/grid';
import { getViewInsets } from '@/utils/insets';
import { useContentInsets } from '../hooks/useContentInsets';
import SearchResultsNav from './sidebar/SearchResultsNav';
import BooknotesNav from './sidebar/BooknotesNav';
import FoliateViewer from './FoliateViewer';
@ -118,33 +118,10 @@ const BookCellInner: React.FC<BookCellProps> = ({
const config = getConfig(bookKey);
const { book, bookDoc } = bookData || {};
// viewSettings drives both viewInsets and the inset-derived geometry.
// Memoize off its identity so contentInsets stays stable while the
// user is just turning pages — viewSettings only changes when the
// user toggles settings, not on every relocate. Same logic for
// gridInsets which is keyed off the resolved Insets numbers.
const viewInsets = useMemo(
() => (viewSettings ? getViewInsets(viewSettings) : { top: 0, right: 0, bottom: 0, left: 0 }),
[viewSettings],
);
const contentInsets = useMemo(
() => ({
top: gridInsets.top + viewInsets.top,
right: gridInsets.right + viewInsets.right,
bottom: gridInsets.bottom + viewInsets.bottom,
left: gridInsets.left + viewInsets.left,
}),
[
gridInsets.top,
gridInsets.right,
gridInsets.bottom,
gridInsets.left,
viewInsets.top,
viewInsets.right,
viewInsets.bottom,
viewInsets.left,
],
);
// viewInsets/contentInsets stay stable while the user is just turning pages
// (margins are unchanged) but update when a margin setting changes — even
// though saveViewSettings mutates viewSettings in place (#4898).
const { viewInsets, contentInsets } = useContentInsets(viewSettings, gridInsets);
// Stable callback so HeaderBar doesn't see a new prop reference per
// BooksGrid render.

View file

@ -0,0 +1,48 @@
import { useMemo } from 'react';
import { Insets } from '@/types/misc';
import { ViewSettings } from '@/types/book';
import { getViewInsets } from '@/utils/insets';
const ZERO_INSETS: Insets = { top: 0, right: 0, bottom: 0, left: 0 };
/**
* Resolve the reader's view insets (page margins) and content insets
* (grid insets + page margins) for a book cell.
*
* `saveViewSettings` mutates the `ViewSettings` object in place (keeping the
* same reference), so memoizing on the object identity would freeze the page
* margins: a margin edit changes a field but not the reference, so the memo
* never recomputes and the new margin never reaches the paginator (#4898).
*
* `getViewInsets` is cheap, so resolve it every render and memoize by the
* resolved numeric values instead: identical numbers across a page turn keep
* the same object reference (children bail out of re-rendering), while a
* changed margin yields a new reference that propagates to the paginator.
*/
export const useContentInsets = (viewSettings: ViewSettings | null, gridInsets: Insets) => {
const resolved = viewSettings ? getViewInsets(viewSettings) : ZERO_INSETS;
const viewInsets = useMemo(
() => resolved,
// eslint-disable-next-line react-hooks/exhaustive-deps
[resolved.top, resolved.right, resolved.bottom, resolved.left],
);
const contentInsets = useMemo(
() => ({
top: gridInsets.top + viewInsets.top,
right: gridInsets.right + viewInsets.right,
bottom: gridInsets.bottom + viewInsets.bottom,
left: gridInsets.left + viewInsets.left,
}),
[
gridInsets.top,
gridInsets.right,
gridInsets.bottom,
gridInsets.left,
viewInsets.top,
viewInsets.right,
viewInsets.bottom,
viewInsets.left,
],
);
return { viewInsets, contentInsets };
};