From c53c65d56c544e8ac3785497b28e31856d48a8e3 Mon Sep 17 00:00:00 2001 From: Dash <125997726+dashhuang@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:47:04 +1300 Subject: [PATCH 1/3] =?UTF-8?q?fix(mobile):=20=E5=9C=A8=E5=8F=8C=E6=A0=8F?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=88=97=E8=A1=A8=E9=A1=B6=E9=83=A8=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E4=BC=99=E4=BC=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dash <125997726+dashhuang@users.noreply.github.com> --- apps/mobile/app/devices/index.tsx | 7 ++----- apps/mobile/src/session/HomeSurface.tsx | 13 ++++++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/mobile/app/devices/index.tsx b/apps/mobile/app/devices/index.tsx index 1884e12f905..b44f95bee3e 100644 --- a/apps/mobile/app/devices/index.tsx +++ b/apps/mobile/app/devices/index.tsx @@ -1,6 +1,4 @@ import { StyleSheet, View } from 'react-native'; -import { useAuth } from '@/auth/AuthContext'; -import { HomeRosterProvider } from '@/session/HomeRosterContext'; import { MobileHome } from '@/session/HomeSurface'; import { SessionHeaderNativeBlur } from '@/session/SessionHeaderNativeControls'; import { useHomeMode } from '@/session/useHomeMode'; @@ -8,15 +6,14 @@ import { useOptionalHeaderHeight } from '@/session/useOptionalHeaderHeight'; /** One home, as on desktop: teammates are the section at the top of the task list. */ export default function HomeScreen() { - const { accountGeneration } = useAuth(); const navigation = useHomeMode(); const headerHeight = useOptionalHeaderHeight(); // The teammate section's stored fold is part of the first frame. if (!navigation.hydrated) return null; - return + return - ; + ; } const styles = StyleSheet.create({ home: { flex: 1 } }); diff --git a/apps/mobile/src/session/HomeSurface.tsx b/apps/mobile/src/session/HomeSurface.tsx index 43f69a73947..c5ac971dcf2 100644 --- a/apps/mobile/src/session/HomeSurface.tsx +++ b/apps/mobile/src/session/HomeSurface.tsx @@ -69,6 +69,7 @@ import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { useAuth } from '@/auth/AuthContext'; import { HomeTeammatesSection, useHomeTeammatesSectionContext } from './HomeTeammatesSection'; +import { HomeRosterProvider } from './HomeRosterContext'; import { createDisclosureListCell, DisclosureGroupView, DisclosureItem, ListDisclosureScope, useDisclosurePrepare, useListDisclosureTransition } from './listDisclosureTransition'; import { useGuardedPush } from '@/utils/useGuardedPush'; import { useHomeMenuFadeTiming } from '@/session/homeMenuFadeTiming'; @@ -462,7 +463,9 @@ export function MobileHome(props: MobileHomeProps) { const { accountGeneration } = useAuth(); return - + + + ; } @@ -1315,11 +1318,11 @@ function HomeScreenContent({ active = true, width, newSessionInSystemBar = false const homeRoster = teammatesSectionContext.shared; const refreshHome = useCallback(() => { void loadHome({ visible: true }); - if (embedded || !homeRoster) return; + if (!homeRoster) return; void homeRoster.roster.refresh(); if (homeRoster.groups.supported) void homeRoster.groups.refresh(); setTeammatesRefreshKey(key => key + 1); - }, [embedded, homeRoster, loadHome]); + }, [homeRoster, loadHome]); loadHomeRef.current = loadHome; // 冷启动先画缓存:上次 loadHome 成功的设备+会话快照种入 store,先把列表画出来(消除首屏强制 @@ -2973,13 +2976,13 @@ function HomeScreenContent({ active = true, width, newSessionInSystemBar = false }} sections={sections} ListHeaderComponent={<> - {embedded ? null : } + runDisclosure={runDisclosure} prepareDisclosure={prepareListDisclosure} /> {sharedRows.length > 0 ? Date: Sun, 25 Oct 2026 10:00:21 +1300 Subject: [PATCH 2/3] =?UTF-8?q?fix(mobile):=20=E4=BC=99=E4=BC=B4=E8=B7=B3?= =?UTF-8?q?=E8=BD=AC=E7=AD=89=E5=BE=85=E4=BB=BB=E5=8A=A1=E6=8A=BD=E5=B1=89?= =?UTF-8?q?=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dash <125997726+dashhuang@users.noreply.github.com> --- .../__tests__/HomeTeammatesSection.test.tsx | 44 +++++++++++++++---- .../__tests__/useTeammateNavigation.test.tsx | 17 ++++++- apps/mobile/src/session/HomeSurface.tsx | 1 + .../src/session/HomeTeammatesSection.tsx | 14 +++--- .../src/session/useTeammateNavigation.ts | 7 ++- 5 files changed, 65 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/__tests__/HomeTeammatesSection.test.tsx b/apps/mobile/src/__tests__/HomeTeammatesSection.test.tsx index 2f8d8575522..8641a1fe22e 100644 --- a/apps/mobile/src/__tests__/HomeTeammatesSection.test.tsx +++ b/apps/mobile/src/__tests__/HomeTeammatesSection.test.tsx @@ -2,7 +2,8 @@ import { act, createContext, createElement as el } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; -const h = vi.hoisted(() => ({ cloudRefresh: vi.fn(async () => {}) })); +const h = vi.hoisted(() => ({ cloudRefresh: vi.fn(async () => {}), push: vi.fn(), + cloudActor: null as null | { id: string }, createOptions: null as any })); vi.mock('react-native', () => ({ View: ({ children, testID }: any) => el('div', { 'data-testid': testID }, children), ScrollView: ({ children, testID }: any) => el('div', { 'data-testid': testID }, children), @@ -19,10 +20,12 @@ vi.mock('lucide-react-native', () => ({ ChevronDown: () => null, ChevronRight: ( vi.mock('@/auth/AuthContext', () => ({ useAuth: () => ({ user: { id: 'owner' } }) })); vi.mock('@/components/AppText', () => ({ Text: ({ children, testID }: any) => el('span', { 'data-testid': testID }, children) })); vi.mock('@/components/RemoteCompanionAvatar', () => ({ RemoteCompanionAvatar: () => null })); -vi.mock('@/chat/cloudCompanion', () => ({ CLOUD_ROW_REVISION: 'cloud', cloudActorForRow: () => null, cloudCompanionRows: (items: unknown) => items })); +vi.mock('@/chat/cloudCompanion', () => ({ CLOUD_ROW_REVISION: 'cloud', cloudActorForRow: () => h.cloudActor, cloudCompanionRows: (items: unknown) => items })); +vi.mock('@/session/teammateNavigation', () => ({ cloudCompanionRoute: () => '/cloud-chat' })); +vi.mock('@/session/botGroupNavigation', () => ({ botGroupRoute: () => '/group-chat' })); vi.mock('@/chat/useCloudCompanion', () => ({ useCloudCompanionHome: () => ({ supported: true, actors: [], deviceNames: {}, refresh: h.cloudRefresh }) })); vi.mock('@/device-link/remoteResourceCache', () => ({ isRemoteResourceUnread: () => false, remoteResourceCacheRevision: () => 0, subscribeRemoteResourceCache: () => () => {} })); -vi.mock('@/utils/useGuardedPush', () => ({ useGuardedPush: () => () => {} })); +vi.mock('@/utils/useGuardedPush', () => ({ useGuardedPush: () => h.push })); vi.mock('@/theme', async () => { const tokens = await import('@/theme/tokens'); const colors = tokens.lightColors; @@ -35,11 +38,14 @@ vi.mock('@/session/CompanionPresenceRing', () => ({ CompanionPresenceRing: () => vi.mock('@/session/HomeRosterContext', () => ({ useHomeRoster: () => null })); vi.mock('@/session/HomeListVisuals', () => ({ homeListStyles: () => ({}) })); vi.mock('@/session/listDisclosureTransition', () => ({ DisclosureItem: ({ children, testID }: any) => el('div', { 'data-testid': testID }, children) })); -vi.mock('@/session/TeammateCreateController', () => ({ useTeammateCreateController: () => ({ - renderTrigger: (trigger: unknown) => trigger, press: () => {}, label: 'create', sheets: null }) })); +vi.mock('@/session/TeammateCreateController', () => ({ useTeammateCreateController: (options: any) => { + h.createOptions = options; + return { renderTrigger: (trigger: unknown) => trigger, press: () => {}, label: 'create', sheets: null }; +} })); vi.mock('@/session/TeammateList', () => ({ - useTeammateEntries: (items: any[]) => items.map(row => ({ kind: 'bot', row })), - useTeammateRowRenderer: () => (entry: any) => el('div', { key: entry.row.key }, entry.row.item.display.title), + useTeammateEntries: (items: any[], groups: any[] = []) => [...items.map(row => ({ kind: 'bot', row })), ...groups.map(row => ({ kind: 'group', row }))], + useTeammateRowRenderer: (options: any) => (entry: any) => el('button', { key: entry.row.key, + 'data-testid': `row.${entry.kind}`, onClick: () => entry.kind === 'bot' ? options.onSelect(entry.row) : options.groups.onSelect(entry.row) }, entry.row.item.display.title), })); vi.mock('@/session/useTeammateNavigation', () => ({ useTeammateNavigation: () => ({ owner: 'owner', teammatesCollapsed: false, lastTeammate: null, @@ -52,11 +58,12 @@ const roster = { items: [item], createTargets: [], groupTargets: [], loading: fa isOnline: () => true, connectionState: undefined, refresh: vi.fn(async () => {}) }; const groups = { items: [], supported: true, refreshing: false, error: null, isOnline: () => true, refresh: vi.fn(async () => {}) }; let root: Root; let node: HTMLDivElement; -beforeEach(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); node = document.createElement('div'); root = createRoot(node); vi.clearAllMocks(); }); +beforeEach(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); node = document.createElement('div'); root = createRoot(node); vi.clearAllMocks(); h.cloudActor = null; }); afterEach(async () => { await act(async () => root.unmount()); }); -const render = (refreshKey: number) => act(async () => root.render(el(HomeTeammatesSection, { +const render = (refreshKey: number, overrides: Partial[0]> = {}) => act(async () => root.render(el(HomeTeammatesSection, { context: { navigation: null, route: null, shared: { roster, groups } } as any, active: true, refreshKey, visible: true, query: '', showFooter: false, runDisclosure: (apply: () => void) => apply(), prepareDisclosure: () => {}, + ...overrides, }))); const byId = (id: string) => node.querySelector(`[data-testid="${id}"]`); @@ -79,3 +86,22 @@ it('re-reads cloud companions on each manual home refresh', async () => { await render(1); expect(h.cloudRefresh).toHaveBeenCalledTimes(1); }); + +// PR #7079: unit-tier component coverage of the cloud/group routes that bypass the teammate hook. +// Existing coverage only refreshed the roster. A deferred host push must prevent navigation until +// drawer close; the creation receipt is a separate entry after its own sheet has already closed. +it.each(['cloud', 'group', 'created-group'] as const)('defers the %s route until the drawer closes', async kind => { + let routeAfterClose: (() => void) | undefined; + const bot = { ...item, item: { ...item.item, revision: 'cloud' } }; + const group = { ...item, key: 'host:group', item: { ...item.item, ref: { ...item.item.ref, id: 'group' } } }; + h.cloudActor = { id: 'actor' }; + await render(0, { push: href => { routeAfterClose = () => h.push(href); }, + context: { navigation: null, route: null, shared: { roster: { ...roster, items: [bot] }, groups: { ...groups, items: [group] } } } as any }); + await act(async () => { + if (kind === 'created-group') h.createOptions.onGroupCreated(item.host, 'group'); + else byId(`row.${kind === 'cloud' ? 'bot' : 'group'}`)!.click(); + }); + expect(h.push).not.toHaveBeenCalled(); + routeAfterClose?.(); + expect(h.push).toHaveBeenCalledExactlyOnceWith(kind === 'cloud' ? '/cloud-chat' : '/group-chat'); +}); diff --git a/apps/mobile/src/__tests__/useTeammateNavigation.test.tsx b/apps/mobile/src/__tests__/useTeammateNavigation.test.tsx index c35334538a1..e6979da9dc1 100644 --- a/apps/mobile/src/__tests__/useTeammateNavigation.test.tsx +++ b/apps/mobile/src/__tests__/useTeammateNavigation.test.tsx @@ -26,8 +26,8 @@ const teammate = { key: 'mac:writer', host: { deviceId: 'mac', deviceName: 'My M Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); let root: Root | undefined; let result: ReturnType; -function Probe() { result = useTeammateNavigation(); return null; } -async function render() { root ??= createRoot(document.createElement('div')); await act(async () => root!.render(createElement(Probe))); } +function Probe({ push }: { push?: ReturnType }) { result = useTeammateNavigation(push); return null; } +async function render(push?: ReturnType) { root ??= createRoot(document.createElement('div')); await act(async () => root!.render(createElement(Probe, { push }))); } let serial = 0; beforeEach(() => { vi.clearAllMocks(); h.sessions = []; h.routes = []; h.disk.clear(); h.accountGeneration = ++serial; @@ -37,6 +37,19 @@ beforeEach(() => { }); afterEach(() => { act(() => root?.unmount()); root = undefined; vi.useRealTimers(); }); describe('header/home shared navigation', () => { + // PR #7079: the drawer must unmount before routing. Existing tests only covered immediate home + // pushes; one teammate or creation receipt with a deferred host push reproduces the regression. + // Unit tier, real navigation hook; the queued route must survive the source hook's unmount. + it.each(['existing', 'created'] as const)('waits for the drawer to close before opening an %s teammate', async (kind) => { + let routeAfterClose: (() => void) | undefined; + await render(href => { routeAfterClose = () => h.push(href); }); + await act(async () => kind === 'existing' ? result.openTeammate(teammate) : result.openCreatedTeammate(teammate.host, teammate.item.ref)); + expect(h.push).not.toHaveBeenCalled(); + expect(result.lastTeammate).toEqual(teammateIdentity(teammate)); + await act(async () => root!.unmount()); root = undefined; + routeAfterClose?.(); + expect(h.push).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ pathname: '/resources/[collectionId]/[resourceId]' })); + }); it('restores the remembered teammate and section fold, and returning home keeps them', async () => { const owner = homeNavigationOwner(h.user); await saveHomeNavigationPreferences(owner, { teammatesCollapsed: true, lastTeammate: teammateIdentity(teammate) }); diff --git a/apps/mobile/src/session/HomeSurface.tsx b/apps/mobile/src/session/HomeSurface.tsx index c5ac971dcf2..80f4b8b5db9 100644 --- a/apps/mobile/src/session/HomeSurface.tsx +++ b/apps/mobile/src/session/HomeSurface.tsx @@ -2977,6 +2977,7 @@ function HomeScreenContent({ active = true, width, newSessionInSystemBar = false sections={sections} ListHeaderComponent={<> ({ navigation, route, shared }), [navigation, route, shared]); } -export function HomeTeammatesSection({ context, active, refreshKey, visible, query, showFooter, runDisclosure, prepareDisclosure }: { +export function HomeTeammatesSection({ context, active, refreshKey, visible, query, showFooter, push, runDisclosure, prepareDisclosure }: { context: ReturnType; /** The home is the screen in front. */ active: boolean; @@ -69,6 +69,8 @@ export function HomeTeammatesSection({ context, active, refreshKey, visible, que query: string; /** Full-width hairline under the section, unless the next block draws its own top line. */ showFooter: boolean; + /** Embedded lists close their modal drawer before dispatching the route. */ + push?: ReturnType; runDisclosure(apply: () => void): void; prepareDisclosure(): void; }) { @@ -76,21 +78,23 @@ export function HomeTeammatesSection({ context, active, refreshKey, visible, que if (!shared) return null; return + push={push} runDisclosure={runDisclosure} prepareDisclosure={prepareDisclosure} /> ; } -function SectionContent({ shared, active, refreshKey, visible, query, showFooter, runDisclosure, prepareDisclosure }: { +function SectionContent({ shared, active, refreshKey, visible, query, showFooter, push: hostPush, runDisclosure, prepareDisclosure }: { shared: NonNullable>; active: boolean; refreshKey: number; visible: boolean; query: string; showFooter: boolean; + push?: ReturnType; runDisclosure(apply: () => void): void; prepareDisclosure(): void; }) { const { t, i18n } = useTranslation(); const { colors } = useTheme(); const styles = useThemedStyles(makeStyles); const list = useThemedStyles(homeListStyles); - const push = useGuardedPush(); - const navigation = useTeammateNavigation(); + const guardedPush = useGuardedPush(); + const push = hostPush ?? guardedPush; + const navigation = useTeammateNavigation(push); const focused = useIsFocused() && active; const { roster, groups } = shared; const cloud = useCloudCompanionHome(focused && visible); diff --git a/apps/mobile/src/session/useTeammateNavigation.ts b/apps/mobile/src/session/useTeammateNavigation.ts index 06ebd5238ce..85214de7dd0 100644 --- a/apps/mobile/src/session/useTeammateNavigation.ts +++ b/apps/mobile/src/session/useTeammateNavigation.ts @@ -12,11 +12,14 @@ import { remoteSessionStore } from './remoteSessionStore'; import { readRemoteCollectionCache, writeRemoteCollectionCache } from '@/device-link/remoteResourceAvailability'; /** Push keeps the current chat/composer mounted; known teammates validate in the destination. */ -export function useTeammateNavigation() { +export function useTeammateNavigation(hostPush?: ReturnType) { const preferences = useHomeMode(); const { user, accountGeneration } = useAuth(); const { i18n } = useTranslation(); - const push = useGuardedPush(); + const guardedPush = useGuardedPush(); + // Defer only the final dispatch: selection and its account/mount guards must finish while the + // drawer still exists. The host then closes/unmounts the drawer before executing the push. + const push = hostPush ?? guardedPush; const router = useRouter(); const navigation = useNavigation(); const current = useRef(accountGeneration); current.current = accountGeneration; From 2ae9c7c552c280bc56dc9d6814bca5dd376b2573 Mon Sep 17 00:00:00 2001 From: Dash <125997726+dashhuang@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:34:34 +1300 Subject: [PATCH 3/3] =?UTF-8?q?fix(mobile):=20=E5=85=B3=E9=97=AD=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=8A=BD=E5=B1=89=E6=97=B6=E5=81=9C=E7=94=A8=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dash <125997726+dashhuang@users.noreply.github.com> --- .../sessionListDrawerUpdates.test.tsx | 21 +++++++++++++++++++ apps/mobile/src/session/SessionListDrawer.tsx | 5 ++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/__tests__/sessionListDrawerUpdates.test.tsx b/apps/mobile/src/__tests__/sessionListDrawerUpdates.test.tsx index 68af2e8698e..0b697181d22 100644 --- a/apps/mobile/src/__tests__/sessionListDrawerUpdates.test.tsx +++ b/apps/mobile/src/__tests__/sessionListDrawerUpdates.test.tsx @@ -26,6 +26,9 @@ vi.mock("react-native", async () => { testID?: string; onPress?: () => void; accessibilityState?: { selected?: boolean }; + pointerEvents?: string; + accessibilityElementsHidden?: boolean; + importantForAccessibility?: string; ref?: import("react").Ref; }; const View = ({ @@ -33,6 +36,9 @@ vi.mock("react-native", async () => { testID, onPress, accessibilityState, + pointerEvents, + accessibilityElementsHidden, + importantForAccessibility, ref, }: Props) => createElement( @@ -41,6 +47,9 @@ vi.mock("react-native", async () => { "data-testid": testID, onClick: onPress, "aria-selected": accessibilityState?.selected, + "data-pointer-events": pointerEvents, + "data-accessibility-hidden": accessibilityElementsHidden, + "data-accessibility-importance": importantForAccessibility, ref, }, children, @@ -191,8 +200,16 @@ describe('shared Home drawer host', () => { it('unmounts the invisible blocker even when the animation never calls completion', async () => { native.reduceMotion = false; await act(async () => root.render()); + const content = () => container.querySelector('[data-testid="sessionDrawer.panel"]')!.firstElementChild!; + expect(content().getAttribute('data-pointer-events')).toBe('auto'); await act(async () => root.render()); expect(container.querySelector('[data-testid="sessionDrawer.overlay"]')).not.toBeNull(); + // PR #7079: RN's native input/accessibility contract must stop a second selection during + // exit; otherwise it updates lastTeammate while the coordinator retains the first route. + // Existing close-settlement coverage only checked eventual unmount. Unit tier, fake clock. + expect(content().getAttribute('data-pointer-events')).toBe('none'); + expect(content().getAttribute('data-accessibility-hidden')).toBe('true'); + expect(content().getAttribute('data-accessibility-importance')).toBe('no-hide-descendants'); expect(props.onClosed).not.toHaveBeenCalled(); // withTiming deliberately never delivers a completion in this test driver. await act(async () => vi.advanceTimersByTime(motionDuration.exit)); @@ -208,6 +225,10 @@ describe('shared Home drawer host', () => { await act(async () => root.render()); await act(async () => vi.advanceTimersByTime(motionDuration.exit)); expect(container.querySelector('[data-testid="sessionDrawer.overlay"]')).not.toBeNull(); + const content = container.querySelector('[data-testid="sessionDrawer.panel"]')!.firstElementChild!; + expect(content.getAttribute('data-pointer-events')).toBe('auto'); + expect(content.getAttribute('data-accessibility-hidden')).toBe('false'); + expect(content.getAttribute('data-accessibility-importance')).toBe('auto'); expect(props.onClosed).not.toHaveBeenCalled(); await act(async () => root.render()); await act(async () => vi.advanceTimersByTime(motionDuration.exit)); diff --git a/apps/mobile/src/session/SessionListDrawer.tsx b/apps/mobile/src/session/SessionListDrawer.tsx index 87152cb8c84..2bc8c8fb34b 100644 --- a/apps/mobile/src/session/SessionListDrawer.tsx +++ b/apps/mobile/src/session/SessionListDrawer.tsx @@ -203,7 +203,10 @@ export function SessionListDrawer({ currentSessionId, persistent = false, newSes - + {/* The host keeps the first navigation intent. Stop all list input during exit so a + second selection cannot change preferences while its route is discarded. */} +