diff --git a/src/lib/hooks/useWebScrollRestoration.ts b/src/lib/hooks/useWebScrollRestoration.ts index d5281f114e..5f60d5a592 100644 --- a/src/lib/hooks/useWebScrollRestoration.ts +++ b/src/lib/hooks/useWebScrollRestoration.ts @@ -42,7 +42,11 @@ export function useWebScrollRestoration() { () => ({ focus(e: EventArg<'focus', boolean | undefined, unknown>) { const scrollY = state.scrollYs.get(e.target) ?? 0 - window.scrollTo(0, scrollY) + // Deferred so that screens re-mounted by LRU eviction have + // time to render their content before we scroll. + requestAnimationFrame(() => { + window.scrollTo(0, scrollY) + }) state.focusedKey = e.target ?? null }, }), diff --git a/src/view/shell/createNativeStackNavigatorWithAuth.tsx b/src/view/shell/createNativeStackNavigatorWithAuth.tsx index 76f4f19ac0..9d8f9fa16d 100644 --- a/src/view/shell/createNativeStackNavigatorWithAuth.tsx +++ b/src/view/shell/createNativeStackNavigatorWithAuth.tsx @@ -42,6 +42,10 @@ import {BottomBarWeb} from './bottom-bar/BottomBarWeb' import {DesktopLeftNav} from './desktop/LeftNav' import {DesktopRightNav} from './desktop/RightNav' +// On web, only this many screens (beyond Home + focused) stay mounted. +// Older screens are unmounted to prevent memory growth during long sessions. +const WEB_MAX_CACHED_SCREENS = 5 + type NativeStackNavigationOptionsWithAuth = NativeStackNavigationOptions & { requireAuth?: boolean } @@ -105,6 +109,8 @@ function NativeStackNavigator({ ) // --- our custom logic starts here --- + // Web LRU: tracks route keys in most-recently-focused order + const lruKeysRef = React.useRef([]) const {hasSession, currentAccount} = useSession() const activeRoute = state.routes[state.index] const activeDescriptor = descriptors[activeRoute.key] @@ -126,19 +132,54 @@ function NativeStackNavigator({ if (onboardingState.isActive) { return } - const newDescriptors: typeof descriptors = {} - for (let key in descriptors) { - const descriptor = descriptors[key] - const requireAuth = descriptor.options.requireAuth ?? false - newDescriptors[key] = { - ...descriptor, - render() { - if (requireAuth && !hasSession) { - return - } else { - return descriptor.render() + // On web, limit how many screens stay mounted to prevent memory growth. + // Home is always pinned, the focused screen is always mounted, and the + // most recently visited screens are kept up to WEB_MAX_CACHED_SCREENS. + // Evicted screens render a lightweight placeholder — the route stays in + // state so browser back/forward still works; the component just re-mounts. + let finalDescriptors = descriptors + if (IS_WEB) { + const focusedKey = activeRoute.key + + // Update LRU: move focused key to front + const lru = lruKeysRef.current + const idx = lru.indexOf(focusedKey) + if (idx > 0) { + lru.splice(idx, 1) + lru.unshift(focusedKey) + } else if (idx === -1) { + lru.unshift(focusedKey) + } + + // Remove keys for routes no longer in the stack + const routeKeySet = new Set(state.routes.map(r => r.key)) + lruKeysRef.current = lruKeysRef.current.filter(k => routeKeySet.has(k)) + + // Build mount set: Home (pinned) + focused + N most recent + const mountSet = new Set() + mountSet.add(focusedKey) + const homeKey = state.routes.find(r => r.name === 'Home')?.key + if (homeKey) mountSet.add(homeKey) + let cached = 0 + for (const key of lruKeysRef.current) { + if (cached >= WEB_MAX_CACHED_SCREENS) break + if (!mountSet.has(key)) { + mountSet.add(key) + cached++ + } + } + + // Evicted screens get a lightweight placeholder instead of their full tree + finalDescriptors = {} as typeof descriptors + for (const key in descriptors) { + if (mountSet.has(key)) { + finalDescriptors[key] = descriptors[key] + } else { + finalDescriptors[key] = { + ...descriptors[key], + render: () => , } - }, + } } } @@ -153,7 +194,7 @@ function NativeStackNavigator({ {...rest} state={state} navigation={navigation} - descriptors={descriptors} + descriptors={finalDescriptors} describe={describe} />