forked from akai/readest
fix(library): seed custom texture store at boot so saved texture renders on first paint (#4284)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof import('@/styles/textures')>();
|
||||
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> = {}): 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user