diff --git a/.agents/skills/verify-deepseekbot/features/phone-activity.md b/.agents/skills/verify-deepseekbot/features/phone-activity.md index 6694fe0631..d0a8eee9d5 100644 --- a/.agents/skills/verify-deepseekbot/features/phone-activity.md +++ b/.agents/skills/verify-deepseekbot/features/phone-activity.md @@ -77,4 +77,8 @@ Web preview evidence does not qualify native keyboard, safe-area or scrolling. ## Gotchas +For navigation responsiveness (#2111), time Chats → Activity separately from the data read. Cold-open, then repeat after idle prefetch and after returning from a visited page. The title/three tabs and a bounded skeleton must appear without awaiting a slow initial request. Returning to the same Host/query shows its retained data while refreshing; failure keeps that data plus Retry. Change Host and filters to confirm cache isolation. Switching away stops the scoped view subscription while the shared Host remains connected. Keep pending question choices and each visited page's scroll position, and confirm no loaded question re-enters its initial loading state. Profile the actual switches and long history/streaming with fixed input; record Debug versus production-JS versus Release, JS versus presented-frame measures, and leave 120 FPS unqualified without the corresponding native proof. + The earlier PR #1928 evidence remains specific to its own commit and limitations. Native iOS and Android acceptance are separate; this map does not requalify earlier evidence. + +Load a second Activity page, switch away and return, then wait for refresh: both loaded pages remain when Host data is unchanged. Repeat with a cached refresh error and delay the next read; Retry must queue a fresh read through the current query instance. Changed Host data may still move rows; this check does not establish scroll anchoring across arbitrary inserts or deletions. diff --git a/.github/pr-screenshots/2111-navigation-response/activity-loading-after.png b/.github/pr-screenshots/2111-navigation-response/activity-loading-after.png new file mode 100644 index 0000000000..8af9aa0bc7 Binary files /dev/null and b/.github/pr-screenshots/2111-navigation-response/activity-loading-after.png differ diff --git a/.github/pr-screenshots/2111-navigation-response/activity-loading-before.png b/.github/pr-screenshots/2111-navigation-response/activity-loading-before.png new file mode 100644 index 0000000000..e7603341c5 Binary files /dev/null and b/.github/pr-screenshots/2111-navigation-response/activity-loading-before.png differ diff --git a/.github/pr-screenshots/2111-navigation-response/measurements.json b/.github/pr-screenshots/2111-navigation-response/measurements.json new file mode 100644 index 0000000000..db8a41f93b --- /dev/null +++ b/.github/pr-screenshots/2111-navigation-response/measurements.json @@ -0,0 +1,399 @@ +{ + "baseline": "16c7c9af96803f2dde580328ecabb8aa0098dc10", + "candidate": "a481602191efde5da1a89dc5583ad1aa99e6c1a0", + "order": "ABBAAB", + "build": "Android SDK58 Debug, dev JavaScript, Hermes sampling + React hook", + "limits": [ + "ADB command overhead included in selection latency", + "Selection comparison uses warm Home returns only (9 samples per version); candidate Activity selection timestamps were not recorded", + "JS RAF gaps, not native UI FPS", + "First visit follows15 seconds idle; candidate may prefetch", + "120FPS and Release remain unqualified" + ], + "summary": [ + { + "mode": "a", + "runs": 3, + "switches": 18, + "medianFirstVisitMaxFrameMs": 678.1332309246063, + "medianWarmMaxFrameMs": 451.90807700157166, + "medianWarmMaxCommitMs": 309.24598383903503, + "medianWarmHomeSelectionMs": 476.37723088264465, + "warmHomeSelectionSamples": 9, + "warmFeedRenders": 15, + "warmHomeAndRowRenders": 45 + }, + { + "mode": "b", + "runs": 3, + "switches": 18, + "medianFirstVisitMaxFrameMs": 275.0536150932312, + "medianWarmMaxFrameMs": 197.74676990509033, + "medianWarmMaxCommitMs": 127.19309186935425, + "medianWarmHomeSelectionMs": 262.51415395736694, + "warmHomeSelectionSamples": 9, + "warmFeedRenders": 0, + "warmHomeAndRowRenders": 0 + } + ], + "runs": [ + { + "mode": "a", + "label": "matched-0-a", + "steps": [ + { + "to": "activity", + "firstVisit": true, + "maxFrameMs": 685.4691541194916, + "maxCommitMs": 434.18025612831116, + "selectionMs": 1327.9627690315247, + "feedRenders": 1, + "homeRenders": 3 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 451.90807700157166, + "maxCommitMs": 310.32535123825073, + "selectionMs": 508.71169209480286, + "feedRenders": 1, + "homeRenders": 3 + }, + { + "to": "activity", + "firstVisit": false, + "maxFrameMs": 409.763384103775, + "maxCommitMs": 278.0067183971405, + "selectionMs": 778.1131539344788, + "feedRenders": 1, + "homeRenders": 3 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 442.829461812973, + "maxCommitMs": 309.24598383903503, + "selectionMs": 464.5594620704651, + "feedRenders": 1, + "homeRenders": 3 + }, + { + "to": "activity", + "firstVisit": false, + "maxFrameMs": 424.44615387916565, + "maxCommitMs": 289.35460591316223, + "selectionMs": 563.2920000553131, + "feedRenders": 1, + "homeRenders": 3 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 501.2941539287567, + "maxCommitMs": 323.8882784843445, + "selectionMs": 476.37723088264465, + "feedRenders": 1, + "homeRenders": 3 + } + ] + }, + { + "mode": "b", + "label": "matched-1-b", + "steps": [ + { + "to": "activity", + "firstVisit": true, + "maxFrameMs": 293.8341541290283, + "maxCommitMs": 191.45837330818176, + "feedRenders": 0, + "homeRenders": 0 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 207.40807604789734, + "maxCommitMs": 132.9893925189972, + "selectionMs": 278.5125379562378, + "feedRenders": 0, + "homeRenders": 0 + }, + { + "to": "activity", + "firstVisit": false, + "maxFrameMs": 197.62769198417664, + "maxCommitMs": 126.93118524551392, + "feedRenders": 0, + "homeRenders": 0 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 197.74676990509033, + "maxCommitMs": 129.0450897216797, + "selectionMs": 247.72007703781128, + "feedRenders": 0, + "homeRenders": 0 + }, + { + "to": "activity", + "firstVisit": false, + "maxFrameMs": 213.4541540145874, + "maxCommitMs": 140.54837346076965, + "feedRenders": 0, + "homeRenders": 0 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 197.2550768852234, + "maxCommitMs": 128.27567219734192, + "selectionMs": 254.190691947937, + "feedRenders": 0, + "homeRenders": 0 + } + ] + }, + { + "mode": "b", + "label": "matched-2-b", + "steps": [ + { + "to": "activity", + "firstVisit": true, + "maxFrameMs": 275.0536150932312, + "maxCommitMs": 175.02423334121704, + "feedRenders": 0, + "homeRenders": 0 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 216.4338459968567, + "maxCommitMs": 127.57138299942017, + "selectionMs": 269.9384620189667, + "feedRenders": 0, + "homeRenders": 0 + }, + { + "to": "activity", + "firstVisit": false, + "maxFrameMs": 198.71053910255432, + "maxCommitMs": 127.19309186935425, + "feedRenders": 0, + "homeRenders": 0 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 193.01900005340576, + "maxCommitMs": 124.28251504898071, + "selectionMs": 272.537230014801, + "feedRenders": 0, + "homeRenders": 0 + }, + { + "to": "activity", + "firstVisit": false, + "maxFrameMs": 184.908616065979, + "maxCommitMs": 114.30722212791443, + "feedRenders": 0, + "homeRenders": 0 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 216.40030813217163, + "maxCommitMs": 135.23729038238525, + "selectionMs": 260.2646150588989, + "feedRenders": 0, + "homeRenders": 0 + } + ] + }, + { + "mode": "a", + "label": "matched-3-a", + "steps": [ + { + "to": "activity", + "firstVisit": true, + "maxFrameMs": 675.0052309036255, + "maxCommitMs": 417.7654678821564, + "selectionMs": 1249.050154209137, + "feedRenders": 1, + "homeRenders": 3 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 463.77500009536743, + "maxCommitMs": 320.1581082344055, + "selectionMs": 479.6962308883667, + "feedRenders": 1, + "homeRenders": 3 + }, + { + "to": "activity", + "firstVisit": false, + "maxFrameMs": 450.3655378818512, + "maxCommitMs": 299.6049757003784, + "feedRenders": 1, + "homeRenders": 3 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 468.1790769100189, + "maxCommitMs": 326.91067361831665, + "selectionMs": 468.70453906059265, + "feedRenders": 1, + "homeRenders": 3 + }, + { + "to": "activity", + "firstVisit": false, + "maxFrameMs": 450.60953879356384, + "maxCommitMs": 298.7192294597626, + "selectionMs": 574.552845954895, + "feedRenders": 1, + "homeRenders": 3 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 462.24807715415955, + "maxCommitMs": 330.5310380458832, + "selectionMs": 526.1350769996643, + "feedRenders": 1, + "homeRenders": 3 + } + ] + }, + { + "mode": "a", + "label": "matched-4-a", + "steps": [ + { + "to": "activity", + "firstVisit": true, + "maxFrameMs": 678.1332309246063, + "maxCommitMs": 448.92451095581055, + "selectionMs": 1145.8368458747864, + "feedRenders": 1, + "homeRenders": 3 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 491.7137689590454, + "maxCommitMs": 332.66699504852295, + "selectionMs": 472.21846199035645, + "feedRenders": 1, + "homeRenders": 3 + }, + { + "to": "activity", + "firstVisit": false, + "maxFrameMs": 453.07876896858215, + "maxCommitMs": 297.271954536438, + "selectionMs": 57.241539001464844, + "feedRenders": 1, + "homeRenders": 3 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 470.0652310848236, + "maxCommitMs": 329.57606625556946, + "selectionMs": 493.5122311115265, + "feedRenders": 1, + "homeRenders": 3 + }, + { + "to": "activity", + "firstVisit": false, + "maxFrameMs": 428.03484582901, + "maxCommitMs": 283.65630173683167, + "selectionMs": 1023.2424609661102, + "feedRenders": 1, + "homeRenders": 3 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 443.7483069896698, + "maxCommitMs": 305.62870621681213, + "selectionMs": 460.3036918640137, + "feedRenders": 1, + "homeRenders": 3 + } + ] + }, + { + "mode": "b", + "label": "matched-5-b", + "steps": [ + { + "to": "activity", + "firstVisit": true, + "maxFrameMs": 265.57815408706665, + "maxCommitMs": 174.912921667099, + "feedRenders": 0, + "homeRenders": 0 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 192.35661506652832, + "maxCommitMs": 122.22762322425842, + "selectionMs": 262.51415395736694, + "feedRenders": 0, + "homeRenders": 0 + }, + { + "to": "activity", + "firstVisit": false, + "maxFrameMs": 199.85415387153625, + "maxCommitMs": 127.58961367607117, + "feedRenders": 0, + "homeRenders": 0 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 190.6126148700714, + "maxCommitMs": 121.9365348815918, + "selectionMs": 251.77038383483887, + "feedRenders": 0, + "homeRenders": 0 + }, + { + "to": "activity", + "firstVisit": false, + "maxFrameMs": 189.556077003479, + "maxCommitMs": 120.15870594978333, + "feedRenders": 0, + "homeRenders": 0 + }, + { + "to": "home", + "firstVisit": false, + "maxFrameMs": 206.83261609077454, + "maxCommitMs": 124.52105355262756, + "selectionMs": 279.5753080844879, + "feedRenders": 0, + "homeRenders": 0 + } + ] + } + ], + "evidence": { + "viewport": [1220, 2712], + "crop": [0, 130, 1220, 1500], + "slowActivityResponseMs": 8000, + "pendingCaptureMsAfterSelect": 950, + "native": "Android16 SDK58 arm64 Debug client", + "nativeUiFpsQualified": false + } +} diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 0c24ca23cf..f3d7d1f589 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -19,6 +19,8 @@ Read docs for SDK 58 (`https://docs.expo.dev/versions/v58.0.0/...`), not `latest Screens follow [DESIGN.md](./DESIGN.md): the Host-as-workspace structure, the screen anatomy, and the code rules. No literal colours, type, spacing, radii or edge widths outside `src/ui/design/` (`test/style-guard.test.ts`); text through `src/ui/primitives/text`; new visual elements land in `src/ui/primitives`, `chat` or `shell` before a screen uses them. +**Immediate interaction feedback is required.** For navigation, drawers/sheets, search and async actions, follow [Responsive interactions](./DESIGN.md#responsive-interactions): commit the shell/selection before uncached data or heavy content, retain scoped cached content during refresh, and measure tap-to-feedback separately from data-ready time. Include long-list and streaming regressions when those paths are affected; 120 FPS requires native Release evidence, not a display refresh-rate setting. + **System text and large-text usability are required for every new or changed UI.** Read the [implementation and acceptance contract](./DESIGN.md#system-text-and-large-text-acceptance). Preserve iOS semantic Dynamic Type and Android native scaling; fix layout with wrapping, stacking, scrolling and reachable actions instead of disabling scaling or capping all text. Text/input adapter changes must preserve live drafts and editable-field identity. The PR records default/intermediate/maximum × light/dark native checks for both platforms, matched screenshots, actual interactions and explicit blockers. Do not infer Android or Human acceptance from iOS screenshots or source tests. ## Hard rules diff --git a/apps/mobile/DESIGN.md b/apps/mobile/DESIGN.md index b6808892c6..396d49558a 100644 --- a/apps/mobile/DESIGN.md +++ b/apps/mobile/DESIGN.md @@ -325,6 +325,18 @@ For shared primitives, cover representative screens from each row and record gap - Report each platform/case as passed, failed, blocked or unrun, with source/batch/binary identities, actions and observed outcomes. Missing Android tooling or a device is a blocker, not a pass inferred from iOS. Record a runnable next check for every capture blocker. - Source tests protect role mapping, nested text, input identity and behavior; they cannot establish native glyphs, keyboard geometry or usability. Screenshots alone cannot establish interaction, persistence, IME or screen-reader behavior. Keep source tests, native checks and Human acceptance distinct; documentation or green CI does not authorize merge. +## Responsive interactions + +Navigation, drawer/sheet opening, tab selection and editable search respond before waiting for data. Commit the destination's title, selected control and usable back/dismiss controls immediately. Defer expensive first content until the shell has had a frame to present, using `DeferredContent` for retained page content; never add an arbitrary animation-duration timeout or await a network call before opening. + +- Prefer the last successful content for the same Host and query while refreshing. An uncached result shows a bounded, noninteractive skeleton in its final layout; distinguish loading, empty, failure and content. A failed refresh keeps the content with a retry notice. Query identity includes every filter; changing Host must never reuse another Host's content. Bound inactive caches and release subscriptions when their page loses focus. Shared Host transport continues receiving. +- Prefetch a likely destination during idle time or intent when it helps the measured first visit. Deduplicate with the same cache the destination reads, cancel obsolete ownership and keep prefetch off the critical press path. Prefetch is an optimization, not a prerequisite for navigation. +- Separate focus/status-bar/chrome updates from expensive content. Preserve list, pager and editable-field identity across focus changes so scroll positions, choices and drafts survive. Memoization must address a measured invalidation and keep meaningful inputs stable; do not suppress updates with incomplete comparators. +- Virtualize long chat, roster and Activity lists. Streaming publishes update the active draft/tail; historical messages keep stable keys and avoid re-rendering for a pure tail append. Reading history owns its cursor until the user deliberately returns to the bottom. Exercise both history scrolling and scrolling during streaming. +- Measure tap-to-first-feedback and data-ready time separately, for cold, prefetched, warm/cached, slow and failed requests. Record the build, input size, query, device and instrumentation. Target 120 FPS means an 8.33 ms frame budget: report native presented-frame intervals/hitches and JS work separately, with the existing ABBA/three-run method. Development profiles identify work; production JavaScript on a Debug binary and a native Release build are different results. A 120 Hz setting, a skeleton screenshot or a smooth recording cannot qualify 120 FPS. + +The shared Expo/React rules are platform-independent; native iOS and Android acceptance remain separate. Reference: [Expo prefetch behavior](https://docs.expo.dev/router/basics/navigation/#prefetching) and [React deferred rendering](https://react.dev/reference/react/useDeferredValue). Use APIs from the installed SDK source when current documentation differs. + ## Keyboard and text entry Structured mention links follow the Human-confirmed inline presentation in #2160: the avatar and `@name` share the body text's line box, with the Bot primary color adapted against the actual bubble surface using the same name-color function as Web. They wrap as inline text; do not impose a 44/48-point block height. This is an explicit exception to the general control-size rule for these inline links, not a new default for buttons. Keep accessible link labels and ID-bound actions. diff --git a/apps/mobile/app/(tabs)/(activity)/activity.tsx b/apps/mobile/app/(tabs)/(activity)/activity.tsx index aec2be99f6..540335a77d 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,36 +16,62 @@ 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 unread = unreadHref( - item, - (slug) => bots.find((bot) => bot.slug === slug)?.dmChannelId, - (channelId) => channels.some((channel) => channel.id === channelId), - ); - 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), - ); - if (card !== undefined) return router.push(card); - router.push({ - pathname: '/inbox/[id]', - params: { - id: item.id, - kind: item.kind, - botSlug: item.botSlug, - summary: item.summary, - ...(item.channelId === undefined ? {} : { channelId: item.channelId }), - ...(item.messageId === undefined ? {} : { messageId: item.messageId }), - }, - }); - }; + const open = useCallback( + (item: HumanAttentionItem) => { + const unread = unreadHref( + item, + (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) => bot(slug)?.dmChannelId, + (channelId) => roster?.channels.some((channel) => channel.id === channelId) ?? false, + ); + if (card !== undefined) return router.push(card); + router.push({ + pathname: '/inbox/[id]', + params: { + id: item.id, + kind: item.kind, + botSlug: item.botSlug, + summary: item.summary, + ...(item.channelId === undefined ? {} : { channelId: item.channelId }), + ...(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 +84,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 8c88b476ae..ed2a08b269 100644 --- a/apps/mobile/app/(tabs)/(home)/index.tsx +++ b/apps/mobile/app/(tabs)/(home)/index.tsx @@ -1,10 +1,11 @@ import { renderKeyboardFormScroll } from '../../../src/ui/primitives/keyboard-form'; import { Redirect, Stack, useFocusEffect, useIsFocused, useRouter } from 'expo-router'; -import { useCallback, useMemo, useState } from 'react'; +import { memo, useCallback, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, RefreshControl, ScrollView, 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 { cloudClient, useCloudSnapshot } from '../../../src/cloud/native'; import { cloudConversations, useCloudConversations } from '../../../src/cloud/native-conversations'; @@ -33,14 +34,48 @@ type Row = | { key: string; type: 'computer'; conversation: Conversation; pinned: boolean; at?: string } | { key: string; type: 'cloud'; conversation: CloudConversation; pinned: false; at?: string }; 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(); + const prepared = useRef(undefined); + useFocusEffect( + useCallback(() => { + if (client === undefined || prepared.current === client) return; + let stop: (() => void) | undefined; + const idle = requestIdleCallback(() => { + prepared.current = client; + router.prefetch('/activity'); + 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 cloud = useCloudSnapshot(); const directory = useCloudConversations(); const [query, setQuery] = useState(''); @@ -268,4 +303,4 @@ export default function HomeScreen() { ); -} +}); diff --git a/apps/mobile/app/(tabs)/_layout.android.tsx b/apps/mobile/app/(tabs)/_layout.android.tsx index 0901873ae8..c04d56184c 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'; @@ -58,7 +57,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, @@ -137,7 +135,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..0e7425b121 --- /dev/null +++ b/apps/mobile/src/host/focused-source.ts @@ -0,0 +1,39 @@ +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..e8483c1fd1 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; @@ -80,6 +84,23 @@ export function createInbox(client: HostClient, options: InboxQuery = {}): Inbox const readOnce = async (): Promise => { revision += 1; loading.clear(); + const retained = + snapshot.status === 'ready' && options.activity ? snapshot.sections : undefined; + const readWindow = async (category: InboxSection): Promise => { + let page = await readPage(category); + const count = retained?.[category].length ?? 0; + if (count <= page.items.length || !page.nextCursor || stopped) return page; + const items = new Map(page.items.map((item) => [item.id, item])); + const cursors = new Set(); + while (!stopped && items.size < count && page.nextCursor && !cursors.has(page.nextCursor)) { + cursors.add(page.nextCursor); + const before = items.size; + page = await readPage(category, page.nextCursor); + for (const item of page.items) items.set(item.id, item); + if (items.size === before) break; + } + return { ...page, items: [...items.values()] }; + }; const [pages, counts] = await Promise.all([ Promise.all( INBOX_SECTIONS.map((category) => @@ -88,7 +109,7 @@ export function createInbox(client: HostClient, options: InboxQuery = {}): Inbox ? category !== options.section : category === 'handled') ? Promise.resolve({ items: [] } as HumanAttentionPage) - : readPage(category), + : readWindow(category), ), ), client.call('channelUnreadCounts').catch(() => ({ channels: [] })), @@ -214,24 +235,81 @@ 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..5df27d8319 --- /dev/null +++ b/apps/mobile/src/host/use-focused-snapshot.ts @@ -0,0 +1,21 @@ +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..17c25d8db2 --- /dev/null +++ b/apps/mobile/src/ui/primitives/deferred-content.tsx @@ -0,0 +1,25 @@ +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..9b1fbf4ac6 --- /dev/null +++ b/apps/mobile/src/ui/primitives/list-skeleton.tsx @@ -0,0 +1,52 @@ +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..8cf7d2b1aa 100644 --- a/apps/mobile/src/ui/shell/activity-feed.tsx +++ b/apps/mobile/src/ui/shell/activity-feed.tsx @@ -7,13 +7,18 @@ 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 +44,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 +52,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 +71,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; @@ -239,7 +248,6 @@ const Row = memo(function Row({ function Post({ client, - inbox, item, requestRef: ref, bot, @@ -248,7 +256,6 @@ function Post({ onSettled, }: { client: HostClient; - inbox: Inbox | undefined; item: HumanAttentionItem; requestRef: RequestRef; bot: RosterBot | undefined; @@ -280,7 +287,6 @@ function Post({ }, [client, channelId, messageId, botSlug]); const settled = (result: DecisionResult) => { onSettled(item, result); - void inbox?.refresh(); }; return ( (null); @@ -375,10 +381,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,35 +407,48 @@ export function ActivityFeed(props: ActivityFeedProps) { behavior="padding" > - } - renderTabBar={({ position, jumpTo }) => ( - - )} - renderScene={({ route }: { route: ActivityTab }) => ( - - )} - /> + + selectIndex(ACTIVITY_TABS.findIndex((tab) => tab.key === key))} + /> + + + } + > + } + renderTabBar={({ position, jumpTo }) => ( + + )} + renderScene={({ route }: { route: ActivityTab }) => ( + + )} + /> + ); -} +}); const ActivityPage = memo(function ActivityPage({ hostId, @@ -551,22 +570,20 @@ 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 currentInbox = () => (scoped === undefined ? inbox : scoped.getInbox()); const rows = feedRows(visible, gone, held, filter, search); const settled = (item: HumanAttentionItem, result: DecisionResult) => { if (activeSettlementScope.current !== settlementScope) return; if (result === 'accepted') setGone((current) => new Set(current).add(item.id)); else setHeld((current) => [...current.filter((kept) => kept.id !== item.id), item]); + void currentInbox()?.refresh(); }; const refresh = () => { setRefreshing(true); setHeld([]); - void (activeInbox?.refresh() ?? Promise.resolve()).finally(() => setRefreshing(false)); + void (currentInbox()?.refresh() ?? Promise.resolve()).finally(() => setRefreshing(false)); }; return ( ) : null} - {visible.status === 'loading' ? : null} + {visible.status === 'loading' ? : null} {visible.status === 'ready' && Object.values(visible.cursors ?? {}).some(Boolean) ? ( @@ -640,7 +657,7 @@ const ActivityPage = memo(function ActivityPage({ visible.status === 'ready' && visible.loadingMore?.includes(row.section) === true } - onPress={() => void activeInbox?.loadMore?.(row.section)} + onPress={() => void currentInbox()?.loadMore?.(row.section)} /> ); @@ -667,7 +684,6 @@ const ActivityPage = memo(function ActivityPage({ {client === undefined ? null : ( } > - {focused ? : null} + ); } + +function HeaderStatus({ lightInk }: { lightInk: boolean }) { + const focused = useIsFocused(); + return focused ? : null; +} diff --git a/apps/mobile/src/ui/shell/scroll-chrome.tsx b/apps/mobile/src/ui/shell/scroll-chrome.tsx index c7be6d43ec..a9edfb049d 100644 --- a/apps/mobile/src/ui/shell/scroll-chrome.tsx +++ b/apps/mobile/src/ui/shell/scroll-chrome.tsx @@ -1,11 +1,5 @@ -import { - createContext, - useContext, - useLayoutEffect, - useMemo, - useState, - type ReactNode, -} from 'react'; +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'; +import { useFocusEffect } from 'expo-router'; import { useAnimatedReaction, useAnimatedScrollHandler, @@ -87,14 +81,19 @@ export function CollapsingToolbar({ export function useChromeScroll(active = true, pin = false) { const { hidden, pinned } = useScrollChrome(); - useLayoutEffect(() => { - if (!active) return; - hidden.value = false; - pinned.value = pin; - return () => { - pinned.value = false; - }; - }, [active, pin, hidden, pinned]); + const scrolling = useSharedValue(false); + useFocusEffect( + useCallback(() => { + scrolling.value = active; + if (!active) return; + hidden.value = false; + pinned.value = pin; + return () => { + scrolling.value = false; + pinned.value = false; + }; + }, [active, pin, hidden, pinned, scrolling]), + ); return useAnimatedScrollHandler({ onBeginDrag(event, context) { context.offset = Math.max(0, event.contentOffset.y); @@ -107,7 +106,7 @@ export function useChromeScroll(active = true, pin = false) { context.travel = 0; }, onScroll(event, context) { - if (!active || pinned.value) return; + if (!scrolling.value || pinned.value) return; const next = scrollChromeStep( { offset: context.offset ?? 0, travel: context.travel ?? 0, hidden: hidden.value }, event.contentOffset.y, diff --git a/apps/mobile/test/activity-feed.test.ts b/apps/mobile/test/activity-feed.test.ts index 11d999d86c..a4df727bfe 100644 --- a/apps/mobile/test/activity-feed.test.ts +++ b/apps/mobile/test/activity-feed.test.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { act, createElement } from 'react'; +import { act, createElement, useLayoutEffect } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -22,6 +22,7 @@ vi.mock('expo-haptics', () => import('./expo-haptics-shim')); vi.mock('expo-router', () => ({ useRouter: () => ({ push: () => undefined }), useIsFocused: () => true, + useFocusEffect: (work: () => void) => useLayoutEffect(work, [work]), Stack: { Screen: () => null }, })); vi.mock('react-native-worklets', () => ({ scheduleOnRN: (callback: () => void) => callback() })); @@ -31,7 +32,7 @@ vi.mock('../src/ui/bot-avatar', () => ({ })); import { createHostClient, type HostClient } from '../src/host/client'; -import type { InboxSnapshot } from '../src/host/inbox'; +import { observeInbox, type InboxSnapshot } from '../src/host/inbox'; import type { HumanAttentionItem } from '../src/host/wire'; import { ActivityFeed, feedRows } from '../src/ui/shell/activity-feed'; import { t } from '../src/ui/strings'; @@ -216,6 +217,84 @@ describe('Activity feed rows', () => { }); describe('Activity feed', () => { + it('retries through the current Inbox while a cached return is still refreshing', async () => { + const fake = host(); + const view = await feed(fake, ready([]), 'cached-host', 5000); + const observed = observeInbox(client!, { activity: true, section: 'action' }); + fake.answers.set('humanAttention', () => ({ + ok: false, + error: { code: 'unavailable', message: 'temporary failure', details: {} }, + })); + await act(async () => observed.getInbox()!.refresh()); + expect(view.textContent).toContain('temporary failure'); + act(() => root!.unmount()); + + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + let reads = 0; + const request = fake.carrier.request; + fake.carrier.request = async (url, init) => { + if (url.includes('humanAttention')) { + reads++; + await pending; + } + return request(url, init); + }; + fake.answers.set('humanAttention', () => ({ ok: true, value: { items: [], queryVersion: 1 } })); + root = createRoot(view); + try { + await act(async () => + root!.render( + createElement(ActivityFeed, { + hostId: 'cached-host', + client, + inbox: undefined, + snapshot: ready([]), + botSlugs: [], + bot: () => undefined, + onOpen: () => undefined, + }), + ), + ); + await settle(); + expect(reads).toBe(1); + await press(button(view, t.tryAgain)); + await act(async () => release()); + await settle(); + expect(reads).toBe(2); + expect(view.textContent).not.toContain('temporary failure'); + } finally { + release(); + } + }); + + it('shows navigable tabs and a skeleton while the initial Host read remains pending', async () => { + const fake = host(); + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const request = fake.carrier.request; + fake.carrier.request = async (url, init) => { + if (url.includes('humanAttention')) await pending; + return request(url, init); + }; + const view = await feed(fake, ready([]), 'slow-host', 5000); + expect(view.querySelector('[data-testid="list-skeleton"]')).not.toBeNull(); + expect(button(view, t.inboxSections.action).getAttribute('aria-selected')).toBe('true'); + await press(button(view, t.inboxSections.unread)); + expect(button(view, t.inboxSections.unread).getAttribute('aria-selected')).toBe('true'); + expect(view.textContent).not.toContain(t.inboxEmpty); + await act(async () => release()); + await settle(); + expect( + [...view.querySelectorAll('[data-testid="list-skeleton"]')].filter( + (node) => !node.closest('[hidden]'), + ), + ).toHaveLength(0); + }); it('bounds the native unread preview while opening the original full item', async () => { const unread: HumanAttentionItem = { ...reply, @@ -560,7 +639,7 @@ describe('Activity feed', () => { }); const fetched = queries.length; await press(view.querySelector('[data-testid="pager-previous"]')!); - expect(queries.length).toBe(fetched); + expect(queries.length).toBe(fetched + 1); expect(queries.findLast((query) => query.category === 'replies')).toMatchObject({ category: 'replies', sort: 'oldest', diff --git a/apps/mobile/test/create-button.test.tsx b/apps/mobile/test/create-button.test.tsx index f737ca7394..572da157a5 100644 --- a/apps/mobile/test/create-button.test.tsx +++ b/apps/mobile/test/create-button.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { act, type ReactNode } from 'react'; +import { act, useLayoutEffect, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { push, platform, showActionSheetWithOptions, alert } = vi.hoisted(() => ({ push: vi.fn(), @@ -41,8 +41,8 @@ vi.mock('expo-router', () => { Stack, router: { push }, useIsFocused: () => true, - useFocusEffect: () => {}, - useRouter: () => ({ push }), + useFocusEffect: (work: () => void) => useLayoutEffect(work, [work]), + useRouter: () => ({ push, prefetch: () => undefined }), }; }); vi.mock('../src/host/host-session', () => ({ @@ -85,9 +85,17 @@ import { appDialogs } from '../src/ui/primitives/dialog-state'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); let root: Root | undefined; +beforeEach(() => { + vi.stubGlobal( + 'requestIdleCallback', + vi.fn(() => 1), + ); + vi.stubGlobal('cancelIdleCallback', vi.fn()); +}); let host: HTMLElement | undefined; afterEach(() => { act(() => root?.unmount()); + vi.unstubAllGlobals(); host?.remove(); root = undefined; push.mockReset(); diff --git a/apps/mobile/test/deferred-content.test.tsx b/apps/mobile/test/deferred-content.test.tsx new file mode 100644 index 0000000000..aa8586a1e2 --- /dev/null +++ b/apps/mobile/test/deferred-content.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('react-native', () => import('./react-native-shim')); +import { DeferredContent } from '../src/ui/primitives/deferred-content'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +afterEach(() => vi.unstubAllGlobals()); + +describe('deferred page content', () => { + it('commits the shell before mounting content and cancels pending work on dismissal', () => { + const frames = new Map(); + let id = 0; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.set(++id, callback); + return id; + }); + vi.stubGlobal('cancelAnimationFrame', (frame: number) => frames.delete(frame)); + const frame = () => { + const work = [...frames.values()]; + frames.clear(); + act(() => { + for (const callback of work) callback(0); + }); + }; + const view = document.createElement('div'); + const root = createRoot(view); + let mounts = 0; + function Content() { + mounts++; + return Ready; + } + act(() => + root.render( + Skeleton}> + + , + ), + ); + expect(view.textContent).toBe('Skeleton'); + expect(mounts).toBe(0); + frame(); + expect(mounts).toBe(0); + frame(); + expect(view.textContent).toBe('Ready'); + expect(mounts).toBe(1); + act(() => + root.render( + Skeleton}> + + , + ), + ); + expect(view.textContent).toBe('Skeleton'); + act(() => root.unmount()); + expect(frames.size).toBe(0); + expect(mounts).toBe(1); + }); +}); diff --git a/apps/mobile/test/focused-source.test.ts b/apps/mobile/test/focused-source.test.ts new file mode 100644 index 0000000000..b579c6de34 --- /dev/null +++ b/apps/mobile/test/focused-source.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { focusedSource } from '../src/host/focused-source'; + +describe('focused snapshot source', () => { + it('holds hidden data without notifications, catches up once and disposes subscriptions', () => { + let value = 'cached'; + const live = new Set<() => void>(); + const source = { + getSnapshot: () => value, + subscribe: (listener: () => void) => { + live.add(listener); + return () => { + live.delete(listener); + }; + }, + }; + const focused = focusedSource(source); + let updates = 0; + const off = focused.subscribe(() => { + updates++; + }); + expect(live.size).toBe(0); + focused.focus(true); + expect(live.size).toBe(1); + value = 'live'; + for (const listener of live) listener(); + expect(focused.getSnapshot()).toBe('live'); + expect(updates).toBe(1); + focused.focus(false); + value = 'background'; + expect(live.size).toBe(0); + expect(focused.getSnapshot()).toBe('live'); + expect(updates).toBe(1); + focused.focus(true); + expect(focused.getSnapshot()).toBe('background'); + expect(updates).toBe(2); + focused.focus(true); + expect(updates).toBe(2); + off(); + expect(live.size).toBe(0); + }); +}); diff --git a/apps/mobile/test/home-search.test.tsx b/apps/mobile/test/home-search.test.tsx index cd39d59396..a21d9c30ad 100644 --- a/apps/mobile/test/home-search.test.tsx +++ b/apps/mobile/test/home-search.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { act, type ReactNode } from 'react'; +import { act, useLayoutEffect, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CloudRow } from '../src/cloud/client'; import type { CloudConversation } from '../src/cloud/conversations'; @@ -70,8 +70,8 @@ vi.mock('expo-router', () => { Stack, router: { push }, useIsFocused: () => true, - useFocusEffect: () => {}, - useRouter: () => ({ push }), + useFocusEffect: (work: () => void) => useLayoutEffect(work, [work]), + useRouter: () => ({ push, prefetch: () => undefined }), }; }); vi.mock('../src/host/host-session', () => ({ @@ -108,11 +108,19 @@ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); let root: Root | undefined; let host: HTMLElement | undefined; +beforeEach(() => { + vi.stubGlobal( + 'requestIdleCallback', + vi.fn(() => 1), + ); + vi.stubGlobal('cancelIdleCallback', vi.fn()); +}); afterEach(() => { act(() => root?.unmount()); host?.remove(); root = undefined; push.mockReset(); + vi.unstubAllGlobals(); cloudState.rows = []; cloudState.ready = true; cloudState.error = undefined; diff --git a/apps/mobile/test/inbox-observer.test.ts b/apps/mobile/test/inbox-observer.test.ts new file mode 100644 index 0000000000..b3e827df0b --- /dev/null +++ b/apps/mobile/test/inbox-observer.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createHostClient, type HostClient } from '../src/host/client'; +import { observeInbox, prefetchInbox } from '../src/host/inbox'; +import { FakeHost } from './fake-host'; + +const clients: HostClient[] = []; +afterEach(() => { + for (const client of clients.splice(0)) client.stop(); +}); + +function host() { + const fake = new FakeHost(); + let calls = 0; + let refuse = false; + fake.answers.set('humanAttention', () => { + calls++; + return refuse + ? { ok: false, error: { code: 'unavailable', message: 'offline', details: {} } } + : { ok: true, value: { items: [], queryVersion: 1 } }; + }); + const client = createHostClient({ + routes: ['https://studio.example'], + credential: fake.credential, + carrier: fake.carrier, + }); + clients.push(client); + client.start(); + return { + client, + calls: () => calls, + refuse: () => { + refuse = true; + }, + }; +} + +async function settle() { + for (let i = 0; i < 15; i++) await new Promise((r) => setTimeout(r, 5)); +} + +describe('Activity query observation', () => { + it('prefetches without navigation, releases live observation and tolerates repeated cleanup', async () => { + const target = host(); + const query = { activity: true, section: 'action' } as const; + const cancel = prefetchInbox(target.client, query); + await settle(); + const observed = observeInbox(target.client, query); + expect(observed.getSnapshot().status).toBe('ready'); + expect(observed.getInbox()).toBeUndefined(); + cancel(); + cancel(); + const stop = observed.subscribe(() => {}); + expect(observed.getInbox()).toBeDefined(); + stop(); + expect(observed.getInbox()).toBeUndefined(); + }); + it('shows cached data immediately on return and refreshes without clearing it', async () => { + const target = host(); + const query = { activity: true, section: 'action' } as const; + const first = observeInbox(target.client, query); + const off = first.subscribe(() => {}); + await settle(); + expect(first.getSnapshot().status).toBe('ready'); + const cached = first.getSnapshot(); + const calls = target.calls(); + off(); + const returned = observeInbox(target.client, { ...query }); + expect(returned.getSnapshot()).toBe(cached); + const stop = returned.subscribe(() => {}); + expect(returned.getSnapshot()).toBe(cached); + await settle(); + expect(target.calls()).toBeGreaterThan(calls); + stop(); + }); + + it('retains a successful snapshot on refresh failure and isolates Host and query identity', async () => { + const first = host(); + const query = { activity: true, section: 'action' } as const; + const observed = observeInbox(first.client, query); + const off = observed.subscribe(() => {}); + await settle(); + off(); + first.refuse(); + const returned = observeInbox(first.client, query); + const stop = returned.subscribe(() => {}); + await settle(); + expect(returned.getSnapshot()).toMatchObject({ status: 'ready', stale: 'offline' }); + expect(observeInbox(first.client, { ...query, search: 'different' }).getSnapshot().status).toBe( + 'loading', + ); + expect(observeInbox(host().client, query).getSnapshot().status).toBe('loading'); + stop(); + }); + + it('refreshes the loaded page window on return without discarding its tail', async () => { + const fake = new FakeHost(); + const calls: Record[] = []; + let updated = false; + fake.answers.set('humanAttention', (args) => { + calls.push(args); + const second = args.cursor === 'page-two'; + return { + ok: true, + value: { + items: [{ id: second ? 'second' : 'first', summary: updated ? 'updated' : 'original' }], + ...(second ? {} : { nextCursor: 'page-two' }), + queryVersion: 1, + }, + }; + }); + const client = createHostClient({ + routes: ['https://studio.example'], + credential: fake.credential, + carrier: fake.carrier, + }); + clients.push(client); + client.start(); + const observed = observeInbox(client, { activity: true, section: 'replies' }); + const off = observed.subscribe(() => {}); + await settle(); + await observed.getInbox()!.loadMore!('replies'); + const loaded = observed.getSnapshot(); + expect(loaded).toMatchObject({ sections: { replies: [{ id: 'first' }, { id: 'second' }] } }); + off(); + updated = true; + calls.length = 0; + const stop = observed.subscribe(() => {}); + expect(observed.getSnapshot()).toBe(loaded); + await settle(); + expect(observed.getSnapshot()).toMatchObject({ + sections: { + replies: [ + { id: 'first', summary: 'updated' }, + { id: 'second', summary: 'updated' }, + ], + }, + }); + expect(calls.map((args) => args.cursor)).toEqual([undefined, 'page-two']); + stop(); + }); + + it('shares a query until its last observer leaves and bounds inactive search caches', async () => { + const target = host(); + const query = { activity: true, section: 'action' } as const; + const first = observeInbox(target.client, query); + const second = observeInbox(target.client, query); + const off = first.subscribe(() => {}); + const stop = second.subscribe(() => {}); + await settle(); + expect(second.getInbox()).toBe(first.getInbox()); + off(); + expect(second.getInbox()).toBeDefined(); + stop(); + expect(second.getInbox()).toBeUndefined(); + for (let i = 0; i < 40; i++) observeInbox(target.client, { ...query, search: `search-${i}` }); + expect(observeInbox(target.client, query).getSnapshot().status).toBe('loading'); + }); +}); diff --git a/apps/mobile/test/react-native-shim.ts b/apps/mobile/test/react-native-shim.ts index a358acec37..29b648e3c5 100644 --- a/apps/mobile/test/react-native-shim.ts +++ b/apps/mobile/test/react-native-shim.ts @@ -19,7 +19,15 @@ export const View = (props: Props) => createElement('div', { ...a11y(props), ref: props['ref'] }, props.children); export const Text = (props: Props) => createElement('span', a11y(props), props.children); export const ScrollView = View; -export const Animated = { View }; +export const Animated = { + View, + Value: class { + constructor(readonly value: number) {} + interpolate({ outputRange }: { outputRange: unknown[] }) { + return outputRange[0]; + } + }, +}; export const I18nManager = { isRTL: false }; export const KeyboardAvoidingView = View; export const Modal = (props: Props) => diff --git a/changes/2111-mobile-navigation-feedback.md b/changes/2111-mobile-navigation-feedback.md new file mode 100644 index 0000000000..5cc525924f --- /dev/null +++ b/changes/2111-mobile-navigation-feedback.md @@ -0,0 +1,13 @@ +--- +section: Fixed +--- + +## en + +- Chats and Activity avoid repainting their loaded lists on every tab switch. Activity presents its shell before mounting heavy content, reuses Host-scoped query snapshots during refresh, and pauses hidden query subscriptions. Chats prepares Activity during idle time ([#2111](https://github.com/BotHarness/DeepSeekBot/issues/2111)). +- Returning to Activity refreshes the loaded page window, and cached refresh controls use the current query owner ([#2111](https://github.com/BotHarness/DeepSeekBot/issues/2111)). + +## zh + +- 聊天与动态切换时避免重复重绘已加载的列表;动态先显示页面外壳,再挂载较重的内容,刷新时复用按 Host 隔离的查询缓存,并暂停隐藏查询的视图订阅。聊天页会在空闲时预加载动态([#2111](https://github.com/BotHarness/DeepSeekBot/issues/2111))。 +- 返回动态时刷新已加载的分页范围,缓存页面的刷新操作使用当前查询实例([#2111](https://github.com/BotHarness/DeepSeekBot/issues/2111))。