From 0d0e422d2a35854163a8d2fc8275a98a6ad78abe Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Sun, 18 Oct 2026 04:03:54 +0900 Subject: [PATCH 1/5] wip(mobile): retain tab data and isolate navigation content Issue: #2111 Agent-Task: codex/local/01a145e7-8579-7ec3-b958-d3bef00fc347 --- .../mobile/app/(tabs)/(activity)/activity.tsx | 57 +++++++------ apps/mobile/app/(tabs)/(home)/index.tsx | 33 ++++++-- apps/mobile/app/(tabs)/_layout.android.tsx | 4 +- apps/mobile/src/host/focused-source.ts | 33 ++++++++ apps/mobile/src/host/inbox.ts | 68 ++++++++++++++-- apps/mobile/src/host/use-focused-snapshot.ts | 16 ++++ .../src/ui/primitives/deferred-content.tsx | 15 ++++ .../src/ui/primitives/list-skeleton.tsx | 24 ++++++ apps/mobile/src/ui/shell/activity-feed.tsx | 33 ++++---- apps/mobile/src/ui/shell/home-header.tsx | 8 +- apps/mobile/src/ui/shell/scroll-chrome.tsx | 12 ++- apps/mobile/test/activity-feed.test.ts | 5 +- apps/mobile/test/create-button.test.tsx | 4 +- apps/mobile/test/focused-source.test.ts | 34 ++++++++ apps/mobile/test/home-search.test.tsx | 4 +- apps/mobile/test/inbox-observer.test.ts | 81 +++++++++++++++++++ apps/mobile/test/react-native-shim.ts | 5 +- 17 files changed, 368 insertions(+), 68 deletions(-) create mode 100644 apps/mobile/src/host/focused-source.ts create mode 100644 apps/mobile/src/host/use-focused-snapshot.ts create mode 100644 apps/mobile/src/ui/primitives/deferred-content.tsx create mode 100644 apps/mobile/src/ui/primitives/list-skeleton.tsx create mode 100644 apps/mobile/test/focused-source.test.ts create mode 100644 apps/mobile/test/inbox-observer.test.ts diff --git a/apps/mobile/app/(tabs)/(activity)/activity.tsx b/apps/mobile/app/(tabs)/(activity)/activity.tsx index aec2be99f6..507549f2a1 100644 --- a/apps/mobile/app/(tabs)/(activity)/activity.tsx +++ b/apps/mobile/app/(tabs)/(activity)/activity.tsx @@ -1,5 +1,6 @@ import { Stack, useIsFocused, useRouter } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; +import { useCallback, useMemo } from 'react'; import { useHostSession, useHostSnapshot, useInbox } from '../../../src/host/host-session'; import { cardHref, unreadHref } from '../../../src/host/push'; @@ -15,22 +16,32 @@ export default function ActivityScreen() { const focused = useIsFocused(); const theme = useDesignTheme(); const { saved, client, inbox } = useHostSession(); - const snapshot = useHostSnapshot(client); - const list = useInbox(inbox); - const bots = snapshot.roster?.bots ?? []; - const channels = snapshot.roster?.channels ?? []; + const snapshot = useHostSnapshot(client, focused); + const list = useInbox(inbox, focused); + const roster = snapshot.roster; + const botSlugs = useMemo(() => roster?.bots.map((bot) => bot.slug) ?? [], [roster]); + const channels = useMemo( + () => [ + ...(roster?.bots.flatMap((bot) => + bot.dmChannelId ? [{ id: bot.dmChannelId, name: bot.displayName }] : [], + ) ?? []), + ...(roster?.channels.map((channel) => ({ id: channel.id, name: channel.name })) ?? []), + ], + [roster], + ); + const bot = useCallback((slug: string) => roster?.bots.find((entry) => entry.slug === slug), [roster]); - const open = (item: HumanAttentionItem) => { + const open = useCallback((item: HumanAttentionItem) => { const unread = unreadHref( item, - (slug) => bots.find((bot) => bot.slug === slug)?.dmChannelId, - (channelId) => channels.some((channel) => channel.id === channelId), + (slug) => bot(slug)?.dmChannelId, + (channelId) => roster?.channels.some((channel) => channel.id === channelId) ?? false, ); if (unread !== undefined) return router.push(unread); const card = cardHref( item, - (slug) => bots.find((bot) => bot.slug === slug)?.dmChannelId, - (channelId) => channels.some((channel) => channel.id === channelId), + (slug) => bot(slug)?.dmChannelId, + (channelId) => roster?.channels.some((channel) => channel.id === channelId) ?? false, ); if (card !== undefined) return router.push(card); router.push({ @@ -44,7 +55,14 @@ export default function ActivityScreen() { ...(item.messageId === undefined ? {} : { messageId: item.messageId }), }, }); - }; + }, [router, bot, roster]); + const header = useMemo(() => ( + client?.retryNow()} + onPairAgain={() => router.push('/pair')} + /> + ), [snapshot.connection, client, router]); return ( <> @@ -57,22 +75,11 @@ export default function ActivityScreen() { client={client} inbox={inbox} snapshot={list} - botSlugs={bots.map((bot) => bot.slug)} - channels={[ - ...bots.flatMap((bot) => - bot.dmChannelId ? [{ id: bot.dmChannelId, name: bot.displayName }] : [], - ), - ...channels.map((channel) => ({ id: channel.id, name: channel.name })), - ]} - bot={(slug) => bots.find((bot) => bot.slug === slug)} + botSlugs={botSlugs} + channels={channels} + bot={bot} onOpen={open} - header={ - client?.retryNow()} - onPairAgain={() => router.push('/pair')} - /> - } + header={header} /> diff --git a/apps/mobile/app/(tabs)/(home)/index.tsx b/apps/mobile/app/(tabs)/(home)/index.tsx index 703518d3ad..5e9780ee57 100644 --- a/apps/mobile/app/(tabs)/(home)/index.tsx +++ b/apps/mobile/app/(tabs)/(home)/index.tsx @@ -1,11 +1,12 @@ import { renderKeyboardFormScroll } from '../../../src/ui/primitives/keyboard-form'; -import { Redirect, Stack, useIsFocused, useRouter } from 'expo-router'; -import { useMemo, useState } from 'react'; +import { Redirect, Stack, useFocusEffect, useIsFocused, useRouter } from 'expo-router'; +import { memo, useCallback, useMemo, useState } from 'react'; import { ActivityIndicator, RefreshControl, View } from 'react-native'; import Animated from 'react-native-reanimated'; import { fullClientParams } from '../../../src/host/full-client'; import { useHostSession, useHostSnapshot, useInbox } from '../../../src/host/host-session'; +import { prefetchInbox } from '../../../src/host/inbox'; import { useHostLook } from '../../../src/host/native-host-look'; import { shownHostName } from '../../../src/ui/design/host-look'; import { sectionCollapse, useCollapsedSections } from '../../../src/host/native-section-collapse'; @@ -34,14 +35,32 @@ import type { HumanAttentionItem } from '../../../src/host/wire'; const NONE: HumanAttentionItem[] = []; export default function HomeScreen() { - const router = useRouter(); const focused = useIsFocused(); - const onScroll = useChromeScroll(focused); - const bottom = useFloatingTabInset(); const { saved, client, inbox } = useHostSession(); - const look = useHostLook(saved?.hostId); const snapshot = useHostSnapshot(client, focused); const list = useInbox(inbox, focused); + return ; +} + +const HomeContent = memo(function HomeContent({ saved, client, inbox, snapshot, list }: { + saved: ReturnType['saved']; + client: ReturnType['client']; + inbox: ReturnType['inbox']; + snapshot: ReturnType; + list: ReturnType; +}) { + const router = useRouter(); + useFocusEffect(useCallback(() => { + let stop: (() => void) | undefined; + const idle = requestIdleCallback(() => { + router.prefetch('/activity'); + if (client !== undefined) stop = prefetchInbox(client, { activity: true, section: 'action' }); + }); + return () => { cancelIdleCallback(idle); stop?.(); }; + }, [router, client])); + const onScroll = useChromeScroll(); + const bottom = useFloatingTabInset(); + const look = useHostLook(saved?.hostId); const [query, setQuery] = useState(''); const [refreshing, setRefreshing] = useState(false); const action = list.status === 'ready' ? list.sections.action : NONE; @@ -150,4 +169,4 @@ export default function HomeScreen() { ); -} +}); diff --git a/apps/mobile/app/(tabs)/_layout.android.tsx b/apps/mobile/app/(tabs)/_layout.android.tsx index b08088284f..0a0a069cf1 100644 --- a/apps/mobile/app/(tabs)/_layout.android.tsx +++ b/apps/mobile/app/(tabs)/_layout.android.tsx @@ -1,5 +1,4 @@ import { BottomTabBar, Tabs } from 'expo-router/js-tabs'; -import { useSegments } from 'expo-router'; import type { ComponentProps } from 'react'; import { View, useWindowDimensions } from 'react-native'; import Animated, { useAnimatedStyle } from 'react-native-reanimated'; @@ -59,7 +58,6 @@ function AndroidTabLayout() { const insets = useSafeAreaInsets(); const { width, fontScale } = useWindowDimensions(); const pending = useActivityCount().count ?? 0; - const activitySelected = useSegments().includes('(activity)'); const scale = Math.max(1, fontScale); const barWidth = Math.min( width - insets.left - insets.right - space.x6 * 2, @@ -138,7 +136,7 @@ function AndroidTabLayout() { focused={focused} color={color} count={pending} - countVisible={!activitySelected} + countVisible={!focused} > diff --git a/apps/mobile/src/host/focused-source.ts b/apps/mobile/src/host/focused-source.ts new file mode 100644 index 0000000000..5036318e33 --- /dev/null +++ b/apps/mobile/src/host/focused-source.ts @@ -0,0 +1,33 @@ +export function focusedSource(source: { + getSnapshot(): Snapshot; + subscribe(listener: () => void): () => void; +}) { + let value = source.getSnapshot(); + let focused = false; + let off: (() => void) | undefined; + const listeners = new Set<() => void>(); + const update = () => { + const next = source.getSnapshot(); + if (Object.is(next, value)) return; + value = next; + for (const listener of listeners) listener(); + }; + const sync = () => { + if (focused && listeners.size > 0) { + off ??= source.subscribe(update); + update(); + } else { + off?.(); + off = undefined; + } + }; + return { + getSnapshot: () => value, + subscribe(listener: () => void) { + listeners.add(listener); + sync(); + return () => { listeners.delete(listener); sync(); }; + }, + focus(next: boolean) { focused = next; sync(); }, + }; +} diff --git a/apps/mobile/src/host/inbox.ts b/apps/mobile/src/host/inbox.ts index 4209f1236c..bb83dcbc63 100644 --- a/apps/mobile/src/host/inbox.ts +++ b/apps/mobile/src/host/inbox.ts @@ -44,9 +44,13 @@ export interface Inbox { stop(): void; } -export function createInbox(client: HostClient, options: InboxQuery = {}): Inbox { +export function createInbox( + client: HostClient, + options: InboxQuery = {}, + retained?: InboxSnapshot, +): Inbox { const listeners = new Set<() => void>(); - let snapshot: InboxSnapshot = { status: 'idle' }; + let snapshot: InboxSnapshot = retained?.status === 'ready' ? retained : { status: 'idle' }; let reading: Promise | undefined; let readAgain = false; let stopped = false; @@ -214,24 +218,76 @@ export function actionCount(snapshot: InboxSnapshot): number { return snapshot.status === 'ready' ? snapshot.sections.action.length : 0; } -export function observeInbox(client: HostClient, query: InboxQuery) { +type InboxObservation = { + getSnapshot(): InboxSnapshot; + getInbox(): Inbox | undefined; + subscribe(listener: () => void): () => void; + watching(): boolean; +}; +const observations = new WeakMap>(); +const INACTIVE_QUERY_LIMIT = 24; + +export function observeInbox(client: HostClient, query: InboxQuery): InboxObservation { + let cache = observations.get(client); + if (cache === undefined) { + cache = new Map(); + observations.set(client, cache); + } + const key = JSON.stringify([ + query.activity ?? false, query.section ?? 'all', query.botSlug ?? '', + query.channelId ?? '', query.search ?? '', query.unreadOnly ?? false, query.sort ?? 'default', + ]); + const existing = cache.get(key); + if (existing !== undefined) { + cache.delete(key); + cache.set(key, existing); + return existing; + } const loading: InboxSnapshot = { status: 'loading' }; + let retained: InboxSnapshot = loading; let inbox: Inbox | undefined; let watchers = 0; - return { - getSnapshot: () => inbox?.getSnapshot() ?? loading, + const observed: InboxObservation = { + getSnapshot: () => inbox?.getSnapshot() ?? retained, getInbox: () => inbox, + watching: () => watchers > 0, subscribe(listener: () => void) { - inbox ??= createInbox(client, query); + inbox ??= createInbox(client, query, retained); watchers += 1; const off = inbox.subscribe(listener); + let closed = false; return () => { + if (closed) return; + closed = true; off(); watchers -= 1; if (watchers !== 0) return; + retained = inbox?.getSnapshot() ?? retained; inbox?.stop(); inbox = undefined; }; }, }; + cache.set(key, observed); + let inactive = [...cache.values()].filter((entry) => !entry.watching()).length; + for (const [entryKey, entry] of cache) { + if (inactive <= INACTIVE_QUERY_LIMIT) break; + if (entry.watching()) continue; + cache.delete(entryKey); + inactive--; + } + return observed; +} + +export function prefetchInbox(client: HostClient, query: InboxQuery): () => void { + const observed = observeInbox(client, query); + if (observed.getSnapshot().status === 'ready') return () => {}; + let off = () => {}; + const settled = () => { + const { status } = observed.getSnapshot(); + if (status === 'ready' || status === 'error') off(); + }; + off = observed.subscribe(settled); + settled(); + return off; } diff --git a/apps/mobile/src/host/use-focused-snapshot.ts b/apps/mobile/src/host/use-focused-snapshot.ts new file mode 100644 index 0000000000..0ef555732e --- /dev/null +++ b/apps/mobile/src/host/use-focused-snapshot.ts @@ -0,0 +1,16 @@ +import { useFocusEffect } from 'expo-router'; +import { useCallback, useMemo, useSyncExternalStore } from 'react'; + +import { focusedSource } from './focused-source'; + +export function useFocusedSnapshot(source: { + getSnapshot(): Snapshot; + subscribe(listener: () => void): () => void; +}, enabled = true): Snapshot { + const focused = useMemo(() => focusedSource(source), [source]); + useFocusEffect(useCallback(() => { + focused.focus(enabled); + return () => focused.focus(false); + }, [focused, enabled])); + return useSyncExternalStore(focused.subscribe, focused.getSnapshot); +} diff --git a/apps/mobile/src/ui/primitives/deferred-content.tsx b/apps/mobile/src/ui/primitives/deferred-content.tsx new file mode 100644 index 0000000000..cc59a35e15 --- /dev/null +++ b/apps/mobile/src/ui/primitives/deferred-content.tsx @@ -0,0 +1,15 @@ +import { startTransition, useState, type ReactNode } from 'react'; +import { View } from 'react-native'; + +import { useMounted } from '../mounted'; + +export function DeferredContent({ children, fallback }: { children: ReactNode; fallback: ReactNode }) { + const [ready, setReady] = useState(false); + const mounted = useMounted(() => { + let frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => startTransition(() => setReady(true))); + }); + return () => cancelAnimationFrame(frame); + }, []); + return {ready ? children : fallback}; +} diff --git a/apps/mobile/src/ui/primitives/list-skeleton.tsx b/apps/mobile/src/ui/primitives/list-skeleton.tsx new file mode 100644 index 0000000000..ddb9b15fa1 --- /dev/null +++ b/apps/mobile/src/ui/primitives/list-skeleton.tsx @@ -0,0 +1,24 @@ +import { View } from 'react-native'; + +import { useDesignTheme } from '../design/theme'; +import { radius, space } from '../design/tokens'; +import { t } from '../strings'; + +export function ListSkeleton() { + const theme = useDesignTheme(); + return ( + + {[0, 1, 2].map((key) => ( + + + + + + + + ))} + + ); +} diff --git a/apps/mobile/src/ui/shell/activity-feed.tsx b/apps/mobile/src/ui/shell/activity-feed.tsx index 432186a044..88b689931b 100644 --- a/apps/mobile/src/ui/shell/activity-feed.tsx +++ b/apps/mobile/src/ui/shell/activity-feed.tsx @@ -7,13 +7,11 @@ import { useRef, useMemo, useState, - useSyncExternalStore, memo, type ReactElement, } from 'react'; -import { Platform, Pressable, RefreshControl, View, useWindowDimensions } from 'react-native'; +import { Animated as NativeAnimated, Platform, Pressable, RefreshControl, View, useWindowDimensions } from 'react-native'; import { TabView } from 'react-native-tab-view'; -import { useIsFocused } from 'expo-router'; import Animated, { FadeOut, LinearTransition, @@ -39,6 +37,7 @@ import { type RequestRef, } from '../../host/requests'; import type { HumanAttentionItem } from '../../host/wire'; +import { useFocusedSnapshot } from '../../host/use-focused-snapshot'; import { BotAvatar } from '../bot-avatar'; import { ApprovalCard } from '../chat/approval-card'; import { QuestionCard } from '../chat/question-card'; @@ -46,9 +45,11 @@ import { useDesignTheme } from '../design/theme'; import { space } from '../design/tokens'; import { Text } from '../primitives/text'; import { CountBadge } from '../primitives/count-badge'; -import { Loading, Notice } from '../request-parts'; +import { Notice } from '../request-parts'; import { useChatHeaderHeight } from '../chat/header-chrome'; import { Button } from '../primitives/button'; +import { DeferredContent } from '../primitives/deferred-content'; +import { ListSkeleton } from '../primitives/list-skeleton'; import { ActivityToolbar, type ActivityToolbarHandle } from './activity-toolbar'; import { ActivityTabs, ACTIVITY_TABS, type ActivityTab } from './activity-tabs'; import { useFloatingTabInset } from './floating-tab-inset'; @@ -63,6 +64,7 @@ import { const AVATAR = 32; const LOADING: InboxSnapshot = { status: 'loading' }; +const EMPTY_SOURCE = { getSnapshot: () => LOADING, subscribe: () => () => {} }; const ACTIVITY_ROUTES = [...ACTIVITY_TABS]; const SUMMARY_PREVIEW_LIMIT = 512; @@ -359,7 +361,7 @@ type ActivityFeedProps = { now?: number; }; -export function ActivityFeed(props: ActivityFeedProps) { +export const ActivityFeed = memo(function ActivityFeed(props: ActivityFeedProps) { const { botSlugs, channels = [], bot } = props; const theme = useDesignTheme(); const toolbar = useRef(null); @@ -375,10 +377,10 @@ export function ActivityFeed(props: ActivityFeedProps) { const [headerHeight, setHeaderHeight] = useState(Platform.OS === 'ios' ? 0 : initialHeight); const width = useWindowDimensions().width; const reducedMotion = useReducedMotion(); - const focused = useIsFocused(); const progress = useChromeProgress(); const selectedIndex = ACTIVITY_TABS.findIndex((tab) => tab.key === filter.section); const index = selectedIndex < 0 ? lastTab : selectedIndex; + const placeholderPosition = useMemo(() => new NativeAnimated.Value(index), [index]); const slugsWithSelection = filter.bot !== undefined && !botSlugs.includes(filter.bot) ? [...botSlugs, filter.bot] @@ -401,6 +403,11 @@ export function ActivityFeed(props: ActivityFeedProps) { behavior="padding" > + + selectIndex(ACTIVITY_TABS.findIndex((tab) => tab.key === key))} /> + + }> } + renderLazyPlaceholder={() => } renderTabBar={({ position, jumpTo }) => ( )} @@ -423,13 +430,14 @@ export function ActivityFeed(props: ActivityFeedProps) { sort={sort} active={route.key === ACTIVITY_TABS[index]?.key} searchFocused={searchFocused} - scrollActive={focused && route.key === ACTIVITY_TABS[index]?.key} + scrollActive={route.key === ACTIVITY_TABS[index]?.key} pinChrome={searchFocused || filtering} headerTravel={travel} dismissSearch={dismissSearch} /> )} /> + ); -} +}); const ActivityPage = memo(function ActivityPage({ hostId, @@ -551,10 +559,7 @@ const ActivityPage = memo(function ActivityPage({ activeSettlementScope.current = undefined; }; }, [settlementScope, active]); - const scopedSnapshot = useSyncExternalStore( - useCallback((listener: () => void) => scoped?.subscribe(listener) ?? (() => {}), [scoped]), - useCallback(() => scoped?.getSnapshot() ?? LOADING, [scoped]), - ); + const scopedSnapshot = useFocusedSnapshot(scoped ?? EMPTY_SOURCE, scrollActive); const visible = scoped === undefined ? snapshot : scopedSnapshot; const activeInbox = scoped === undefined ? inbox : scoped.getInbox(); const rows = feedRows(visible, gone, held, filter, search); @@ -616,7 +621,7 @@ const ActivityPage = memo(function ActivityPage({