Files
readest/apps/readest-app/src/components/Providers.tsx
T
Huang Xin 336a719e08 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>
2026-05-23 18:34:10 +02:00

172 lines
6.9 KiB
TypeScript

'use client';
import '@/utils/polyfill';
import i18n from '@/i18n/i18n';
import { useEffect } from 'react';
import { IconContext } from 'react-icons';
import { AuthProvider } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
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';
import { useEinkMode } from '@/hooks/useEinkMode';
import { getLocale } from '@/utils/misc';
import { getDirFromUILanguage } from '@/utils/rtl';
import { getAndroidPatchedViewportContent } from '@/utils/viewport';
import { DropdownProvider } from '@/context/DropdownContext';
import { CommandPaletteProvider, CommandPalette } from '@/components/command-palette';
import AtmosphereOverlay from '@/components/AtmosphereOverlay';
import AppLockScreen from '@/components/AppLockScreen';
import AppLockDialog from '@/components/settings/AppLockDialog';
import PassphrasePrompt from '@/components/PassphrasePrompt';
import { upgradeToKeychainIfAvailable } from '@/libs/crypto/passphrase';
import { cryptoSession } from '@/libs/crypto/session';
import { useAppLockStore } from '@/store/appLockStore';
import { initSettingsSync } from '@/services/sync/replicaSettingsSync';
const Providers = ({ children }: { children: React.ReactNode }) => {
const { envConfig, appService } = useEnv();
const { applyUILanguage } = useSettingsStore();
const { applyBackgroundTexture } = useBackgroundTexture();
const { applyEinkMode } = useEinkMode();
const {
isInitialized: isLockInitialized,
isUnlocked,
initialize: initializeAppLock,
} = useAppLockStore();
const iconSize = useDefaultIconSize();
useSafeAreaInsets(); // Initialize safe area insets
useEffect(() => {
const handlerLanguageChanged = (lng: string) => {
document.documentElement.lang = lng;
// Set RTL class on document for targeted styling without affecting layout
const dir = getDirFromUILanguage();
if (dir === 'rtl') {
document.documentElement.classList.add('ui-rtl');
} else {
document.documentElement.classList.remove('ui-rtl');
}
};
const locale = getLocale();
handlerLanguageChanged(locale);
i18n.on('languageChanged', handlerLanguageChanged);
return () => {
i18n.off('languageChanged', handlerLanguageChanged);
};
}, []);
useEffect(() => {
loadDataTheme();
if (appService) {
initSystemThemeListener(appService);
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);
}
// Initialize the app-lock gate from on-disk settings. Until
// this runs, the gate renders nothing — guarantees the
// library can't flash on screen before the lock screen does.
initializeAppLock({
enabled: !!settings.pinCodeEnabled,
hash: settings.pinCodeHash,
salt: settings.pinCodeSalt,
});
// Subscribe the bundled-settings publisher to settingsStore
// changes, AFTER priming the publish snapshot from the just-
// loaded disk settings. Without this priming, the very first
// setSettings(disk_default) at boot (typically from library
// page's initLibrary) would diff every whitelisted field
// against `undefined`, treat them all as "new", and push the
// local defaults to the server with a fresh HLC — overwriting
// the cross-device authoritative values another device set.
// Idempotent — safe to call on remount.
initSettingsSync(settings);
});
}
}, [
envConfig,
appService,
applyUILanguage,
applyBackgroundTexture,
applyEinkMode,
initializeAppLock,
]);
// Sync-passphrase boot path: upgrade the passphrase store from
// ephemeral to OS keychain on Tauri (probe is async — must run after
// the platform check resolves), then attempt a silent unlock from
// the saved passphrase. Failures are silent — the gate prompts on
// first encrypted-field operation if we couldn't restore.
useEffect(() => {
void (async () => {
await upgradeToKeychainIfAvailable();
await cryptoSession.tryRestoreFromStore();
})();
}, []);
useEffect(() => {
const meta = document.querySelector<HTMLMetaElement>('meta[name="viewport"]');
if (!meta) return;
const updated = getAndroidPatchedViewportContent(navigator.userAgent, meta.content);
if (updated) meta.content = updated;
}, []);
// Make sure appService is available in all children components
if (!appService) return;
// App-lock gate. While the lock store is uninitialized we render
// nothing — without this guard the library would flash on screen
// for a few hundred ms before `loadSettings` resolved and let the
// lock store decide whether to lock.
const showAppLockScreen = isLockInitialized && !isUnlocked;
const appShellHidden = !isLockInitialized || !isUnlocked;
return (
<CSPostHogProvider>
<AuthProvider>
<IconContext.Provider value={{ size: `${iconSize}px` }}>
<SyncProvider>
<DropdownProvider>
<CommandPaletteProvider>
<div
aria-hidden={appShellHidden}
style={appShellHidden ? { display: 'none' } : undefined}
>
{children}
<CommandPalette />
<AtmosphereOverlay />
<PassphrasePrompt />
</div>
<AppLockDialog />
{showAppLockScreen && <AppLockScreen />}
</CommandPaletteProvider>
</DropdownProvider>
</SyncProvider>
</IconContext.Provider>
</AuthProvider>
</CSPostHogProvider>
);
};
export default Providers;