From 336a719e08ceff1d330079cc58a82637e86e10db Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sun, 24 May 2026 00:34:10 +0800 Subject: [PATCH] fix(library): seed custom texture store at boot so saved texture renders on first paint (#4284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #4254. On app boot, Providers called applyBackgroundTexture immediately after loadSettings() resolved, but the customTextureStore was still empty — loadCustomTextures only ran later when ColorPanel mounted or useReplicaPull seeded it during library render. The hook's addTexture fallback re-derives the texture id from name and creates a new entry with a different id whenever the saved id wasn't computed from the current name (legacy imports, cross-device sync), so applyTexture silently bailed out and no texture was mounted. Seed customTextureStore.setTextures(settings.customTextures) in Providers right after loadSettings() resolves — preserving the saved ids — so applyTexture can resolve them at boot. Only custom textures were affected; predefined textures (concrete, paper, etc.) worked already because the lookup falls back to PREDEFINED_TEXTURES. Co-authored-by: Claude Opus 4.7 (1M context) --- .../hooks/useBackgroundTexture.test.tsx | 110 ++++++++++++++++++ apps/readest-app/src/components/Providers.tsx | 11 ++ 2 files changed, 121 insertions(+) create mode 100644 apps/readest-app/src/__tests__/hooks/useBackgroundTexture.test.tsx diff --git a/apps/readest-app/src/__tests__/hooks/useBackgroundTexture.test.tsx b/apps/readest-app/src/__tests__/hooks/useBackgroundTexture.test.tsx new file mode 100644 index 00000000..d0833590 --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useBackgroundTexture.test.tsx @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { cleanup, renderHook } from '@testing-library/react'; + +import { useBackgroundTexture } from '@/hooks/useBackgroundTexture'; +import { useCustomTextureStore } from '@/store/customTextureStore'; +import { useSettingsStore } from '@/store/settingsStore'; +import { CustomTexture } from '@/styles/textures'; +import { SystemSettings } from '@/types/settings'; +import { ViewSettings } from '@/types/book'; +import { EnvConfigType } from '@/services/environment'; + +vi.mock('@/styles/textures', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + mountBackgroundTexture: vi.fn(), + unmountBackgroundTexture: vi.fn(), + }; +}); + +const createMockEnvConfig = (): EnvConfigType => + ({ + getAppService: vi.fn().mockResolvedValue({ + openFile: vi.fn().mockResolvedValue({ + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)), + }), + }), + }) as unknown as EnvConfigType; + +// Use a saved id that does NOT equal getTextureId(name). This mirrors the +// real-world bug: legacy/synced textures persist an id that wasn't derived +// from the current name. addTexture would re-derive the id from name and +// create a SECOND store entry with the wrong id, leaving the real id +// unfindable. The fix path therefore must seed the store with the saved id. +const SAVED_TEXTURE: CustomTexture = { + id: 'legacy-saved-id', + name: 'my-cool-texture', + path: 'legacy-saved-id/my-cool-texture.png', +}; + +const makeViewSettings = (overrides: Partial = {}): ViewSettings => + ({ + backgroundTextureId: SAVED_TEXTURE.id, + backgroundOpacity: 0.6, + backgroundSize: 'cover', + ...overrides, + }) as unknown as ViewSettings; + +beforeEach(() => { + useCustomTextureStore.setState({ textures: [], loading: false }); + useSettingsStore.setState({ settings: {} as SystemSettings }); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('useBackgroundTexture', () => { + test('mounts a custom texture when the store is pre-seeded with its saved id', async () => { + // Simulates the fixed boot path: Providers seeds the customTextureStore + // via setTextures(settings.customTextures) before calling the hook. The + // hook's applyTexture then finds the texture by its saved id. + const { mountBackgroundTexture } = await import('@/styles/textures'); + vi.mocked(mountBackgroundTexture).mockClear(); + + useCustomTextureStore.getState().setTextures([SAVED_TEXTURE]); + + const { result } = renderHook(() => useBackgroundTexture()); + result.current.applyBackgroundTexture(createMockEnvConfig(), makeViewSettings()); + + await vi.waitFor(() => { + expect(mountBackgroundTexture).toHaveBeenCalledTimes(1); + }); + + const mountedTexture = vi.mocked(mountBackgroundTexture).mock.calls[0]?.[1]; + expect(mountedTexture?.id).toBe(SAVED_TEXTURE.id); + }); + + test('does NOT mount the texture when the store is empty (documents the boot bug without the seeding fix)', async () => { + // Without setTextures seeding, the in-hook addTexture re-derives an id + // from name and adds a different entry, so applyTexture never finds the + // saved id and unmounts instead of mounting. + const { mountBackgroundTexture, unmountBackgroundTexture } = await import('@/styles/textures'); + vi.mocked(mountBackgroundTexture).mockClear(); + vi.mocked(unmountBackgroundTexture).mockClear(); + + useSettingsStore.setState({ + settings: { customTextures: [SAVED_TEXTURE] } as unknown as SystemSettings, + }); + + const { result } = renderHook(() => useBackgroundTexture()); + result.current.applyBackgroundTexture(createMockEnvConfig(), makeViewSettings()); + + await vi.waitFor(() => { + expect(unmountBackgroundTexture).toHaveBeenCalled(); + }); + expect(mountBackgroundTexture).not.toHaveBeenCalled(); + }); + + test('does nothing when textureId is "none"', () => { + const { result } = renderHook(() => useBackgroundTexture()); + result.current.applyBackgroundTexture( + createMockEnvConfig(), + makeViewSettings({ backgroundTextureId: 'none' }), + ); + + expect(useCustomTextureStore.getState().getTexture(SAVED_TEXTURE.id)).toBeUndefined(); + }); +}); diff --git a/apps/readest-app/src/components/Providers.tsx b/apps/readest-app/src/components/Providers.tsx index b91ce1a4..d958b19b 100644 --- a/apps/readest-app/src/components/Providers.tsx +++ b/apps/readest-app/src/components/Providers.tsx @@ -10,6 +10,7 @@ import { CSPostHogProvider } from '@/context/PHContext'; import { SyncProvider } from '@/context/SyncContext'; import { initSystemThemeListener, loadDataTheme } from '@/store/themeStore'; import { useSettingsStore } from '@/store/settingsStore'; +import { useCustomTextureStore } from '@/store/customTextureStore'; import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets'; import { useDefaultIconSize } from '@/hooks/useResponsiveSize'; import { useBackgroundTexture } from '@/hooks/useBackgroundTexture'; @@ -68,6 +69,16 @@ const Providers = ({ children }: { children: React.ReactNode }) => { appService.loadSettings().then((settings) => { const globalViewSettings = settings.globalViewSettings; applyUILanguage(globalViewSettings.uiLanguage); + // Seed the customTextureStore with the disk-loaded textures (preserving + // their saved ids) so the boot-time applyBackgroundTexture below can + // resolve a custom textureId. Without this, the store is empty until + // ColorPanel or the replica-pull seed runs — and the in-hook addTexture + // fallback re-derives the id from name, which mismatches whenever the + // saved id wasn't computed from the current name (legacy imports, + // cross-device sync, name-based id collisions). + if (settings.customTextures?.length) { + useCustomTextureStore.getState().setTextures(settings.customTextures); + } applyBackgroundTexture(envConfig, globalViewSettings); if (globalViewSettings.isEink) { applyEinkMode(true);