Skip to content

Commit e2b77b5

Browse files
authored
fix(mobile): keep a header Back press inside its own stack (#9327)
* fix(mobile): keep a header Back press inside its own stack Rapid presses on a shared header's Back control each dispatched their own GO_BACK, so one burst popped screens the reader had not asked to leave, and a Back with no history left was spent on the tab navigator (the tab switched) or was unhandled at the root container. The header now claims one dispatch per 500 ms burst and pops only when the container can go back, using the declared fallback destination otherwise. The Security Agent [scope] layout also publishes the scope it validated, so a route beneath it that a navigation seeds without params of its own — the anchor sibling of a restored screen — renders its screen instead of the terminal "Page not found" state that a Back press used to reveal. * fix(mobile): space the toggle row with a direction-agnostic gap The title column reserved its gap with a physical right padding. Under RTL layout the title text painted into that reserved space: the design gate's rtl cell measured the title paragraph 10.9 dp beyond its parent's content box on the Security Agent settings screen. A row gap keeps the same 12 dp between the copy and the switch in both directions and leaves the text inside its column. * fix(mobile): harden the header back burst window Claim the burst window only for a press that runs something, so a press with no destination does not hold the next one out, and read a backwards wall-clock step as a new burst instead of a long suppression. Cover the hook itself: one pop per window, no pop without history, the declared fallback, and the handler taking precedence. * fix(mobile): finish the security agent dashboard states Before the first sync the dashboard printed zero counts that read as a clean result; it now offers the first sync instead of the grid and the posture sections. An unmeasured SLA compliance tile shows the shared no-value dash rather than a sentence in the figure slot, the action links use the link colour in dark mode, and the repositories row keeps one sync control. The empty "Act first" row no longer promises a destination, the personal notification screen drops headings that restated each row title, and the toggle row's switch draws both states from theme tokens so the off state stays visible on either card surface. * fix(mobile): only prompt a first sync over an empty grid A missing owner-level sync stamp can accompany findings left by a partial run, so the prompt now stands in for an empty result rather than for the stamp, and it waits for the stats read. The repository-setup link also renders before that prompt, because a sync without a configured repository is refused. * fix(mobile): settle the security agent design review states The dashboard states its sync status once before the first sync, the SLA posture dots carry their severity, the dark off switch keeps a light thumb, and the new-finding subtitle no longer leaves an orphan. * test(mobile): keep a header back burst to one dispatch * fix(mobile): state an unmeasured sync once on the dashboard * test(mobile): assert the SLA posture card is absent, not its old keys * fix(mobile): keep this pull request to the back-navigation fix The dashboard first-sync prompt, the notification-heading and empty-row changes, and the shortened new-finding subtitle answered design findings on screens this work item does not change, so they are dropped here and stay with the design work that owns them. The shared header's Back guard, the scope the [scope] layout publishes for a route without params of its own, and their tests remain. * fix(mobile): rely on the router's anchor params instead of a scope context The [scope] layout published its validated scope through a context so a route seeded without its own params could still render, which is a parallel copy of the rule the router now applies: a freshly built destination hands the target's dynamic params to the anchor it mints, so the sibling route mounts with the segment it is nested under. The route keeps reading its own param and the layout keeps rejecting a segment that names no scope.
1 parent cd84699 commit e2b77b5

6 files changed

Lines changed: 380 additions & 13 deletions

File tree

‎apps/mobile/src/components/screen-header.mounted.test.tsx‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { act, TestRenderer } from '@/test/renderer';
99
import { beforeEach, describe, expect, it, vi } from 'vitest';
1010

1111
import { OFFLINE_BANNER_HEIGHT } from '@/lib/offline-banner-state';
12+
import { HEADER_BACK_GUARD_MS } from '@/lib/hooks/header-back';
1213
import { ICON_TARGET_BOX_CLASS } from '@/lib/a11y/tap-target';
1314
import { AGENTS_SESSIONS_HREF } from '@/lib/agents-sessions-route';
1415
import { ScreenHeader, shouldStackHeaderActions } from './screen-header';
@@ -311,6 +312,37 @@ describe('ScreenHeader mounted', () => {
311312
expect(back.props.accessibilityRole).toBe('button');
312313
});
313314

315+
it('dispatches one back for a burst of presses on the same header', () => {
316+
// One gesture owns the header's Back: four presses delivered inside the
317+
// window have to pop one screen, not four. The window is per header, so the
318+
// screen a dispatched back lands on starts a fresh one.
319+
vi.useFakeTimers();
320+
try {
321+
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
322+
routerState.routes = ['tabs', 'security-agent', 'security-agent-settings'];
323+
const renderer = renderHeader({ title: 'Settings' });
324+
325+
const back = findBackPressable(renderer.root);
326+
for (let press = 0; press < 4; press += 1) {
327+
act(() => {
328+
(back.props.onPress as () => void)();
329+
});
330+
}
331+
expect(routerState.back).toHaveBeenCalledTimes(1);
332+
expect(routerState.routes).toEqual(['tabs', 'security-agent']);
333+
334+
// A deliberate press after the window still walks the stack.
335+
vi.setSystemTime(new Date(Date.now() + HEADER_BACK_GUARD_MS));
336+
act(() => {
337+
(back.props.onPress as () => void)();
338+
});
339+
expect(routerState.back).toHaveBeenCalledTimes(2);
340+
expect(routerState.routes).toEqual(['tabs']);
341+
} finally {
342+
vi.useRealTimers();
343+
}
344+
});
345+
314346
it('pulls the leading controls with a logical start margin the RTL swap cannot mirror', async () => {
315347
const renderer = renderHeader({ title: 'Sessions', headerRight: 'RIGHT' });
316348

‎apps/mobile/src/components/screen-header.tsx‎

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
99
import { Eyebrow } from '@/components/ui/eyebrow';
1010
import { Text } from '@/components/ui/text';
1111
import { useOfflineBannerSpace } from '@/components/offline-banner-space';
12+
import { useHeaderBack } from '@/lib/hooks/use-header-back';
1213
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
1314
import { offlineHeaderReservation } from '@/lib/offline-banner-state';
1415
import { useScreenInsets, useSideInsetStyle } from '@/lib/screen-insets';
@@ -269,13 +270,13 @@ export function ScreenHeader({
269270
className,
270271
}: Readonly<ScreenHeaderProps>) {
271272
const insets = useScreenInsets();
272-
const router = useRouter();
273273
const colors = useThemeColors();
274274
const { t } = useTranslation();
275275
const { width: windowWidth } = useWindowDimensions();
276276
const fontScale = useLiveFontScale();
277277
const historyCanGoBack = useCanGoBack();
278278
const canGoBack = showBackButton ?? (historyCanGoBack || backFallback !== undefined);
279+
const handleBack = useHeaderBack({ onBack, backFallback, canGoBack: historyCanGoBack });
279280
const isOfflineBannerVisible = useOfflineBannerSpace();
280281
// A window too narrow to hold the title beside its actions drops the actions
281282
// to their own row instead of squeezing the title into a single-letter
@@ -450,18 +451,7 @@ export function ScreenHeader({
450451
const backControl = canGoBack ? (
451452
<View className={cn('shrink-0 overflow-hidden rounded-full', !separateHeading && '-ms-4')}>
452453
<Pressable
453-
onPress={() => {
454-
if (onBack) {
455-
onBack();
456-
} else if (backFallback !== undefined && !router.canGoBack()) {
457-
// POP_TO keeps the fallback's contract (land on this destination)
458-
// while truncating any routes the app left above the destination;
459-
// with no history it still creates the destination, as replace did.
460-
router.dismissTo(backFallback);
461-
} else {
462-
router.back();
463-
}
464-
}}
454+
onPress={handleBack}
465455
accessibilityRole="button"
466456
accessibilityLabel={resolvedBackIcon === 'close' ? t('common.close') : t('common.goBack')}
467457
className={cn(ICON_TARGET_BOX_CLASS, 'active:opacity-70')}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import {
4+
claimHeaderBack,
5+
HEADER_BACK_GUARD_MS,
6+
type HeaderBackGuardState,
7+
resolveHeaderBack,
8+
} from './header-back';
9+
10+
const T0 = 1_700_000_000_000;
11+
12+
function freshState(): HeaderBackGuardState {
13+
return { lastBackAt: 0 };
14+
}
15+
16+
describe('claimHeaderBack', () => {
17+
it('claims the first press and records when it was accepted', () => {
18+
const state = freshState();
19+
20+
expect(claimHeaderBack(state, T0)).toBe(true);
21+
expect(state.lastBackAt).toBe(T0);
22+
});
23+
24+
it('drops a repeat inside the window so one burst dispatches one back', () => {
25+
const state = freshState();
26+
claimHeaderBack(state, T0);
27+
28+
expect(claimHeaderBack(state, T0 + 4)).toBe(false);
29+
expect(claimHeaderBack(state, T0 + 40)).toBe(false);
30+
expect(state.lastBackAt).toBe(T0);
31+
});
32+
33+
it('accepts the press at the end of the window', () => {
34+
const state = freshState();
35+
claimHeaderBack(state, T0);
36+
37+
expect(claimHeaderBack(state, T0 + HEADER_BACK_GUARD_MS - 1)).toBe(false);
38+
expect(claimHeaderBack(state, T0 + HEADER_BACK_GUARD_MS)).toBe(true);
39+
});
40+
41+
it('opens a new window when the clock steps backwards', () => {
42+
const state = freshState();
43+
claimHeaderBack(state, T0);
44+
45+
// A wall clock that stepped back must not hold every press out until the
46+
// clock catches up with the recorded time.
47+
expect(claimHeaderBack(state, T0 - 3_600_000)).toBe(true);
48+
expect(state.lastBackAt).toBe(T0 - 3_600_000);
49+
});
50+
});
51+
52+
describe('resolveHeaderBack', () => {
53+
it('runs the screen handler before anything else', () => {
54+
expect(resolveHeaderBack({ hasHandler: true, hasFallback: false, canGoBack: false })).toBe(
55+
'handler'
56+
);
57+
expect(resolveHeaderBack({ hasHandler: true, hasFallback: true, canGoBack: true })).toBe(
58+
'handler'
59+
);
60+
});
61+
62+
it('pops the navigator when it has history', () => {
63+
expect(resolveHeaderBack({ hasHandler: false, hasFallback: false, canGoBack: true })).toBe(
64+
'history'
65+
);
66+
expect(resolveHeaderBack({ hasHandler: false, hasFallback: true, canGoBack: true })).toBe(
67+
'history'
68+
);
69+
});
70+
71+
it('uses the declared fallback when there is no history', () => {
72+
expect(resolveHeaderBack({ hasHandler: false, hasFallback: true, canGoBack: false })).toBe(
73+
'fallback'
74+
);
75+
});
76+
77+
it('does nothing when there is neither history nor a fallback', () => {
78+
expect(resolveHeaderBack({ hasHandler: false, hasFallback: false, canGoBack: false })).toBe(
79+
'none'
80+
);
81+
});
82+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Burst window for one header Back press.
3+
*
4+
* Presses that reach a header inside one burst belong to the same gesture, and
5+
* each one dispatches its own `router.back()` — expo-router enqueues one
6+
* `GO_BACK` per call and the queue drains in order. On the Android emulator a
7+
* four-tap burst on the Security Agent settings header delivered two presses to
8+
* the same header 4 ms apart, so one gesture popped two screens. The window can
9+
* be generous because the screen a dispatched back pops takes its guard with it,
10+
* so the next screen's press starts a fresh window.
11+
*/
12+
export const HEADER_BACK_GUARD_MS = 500;
13+
14+
/** Mutable guard state. Each header keeps one instance in a ref. */
15+
export type HeaderBackGuardState = {
16+
/** `Date.now()` of the last dispatched back. `0` means none yet. */
17+
lastBackAt: number;
18+
};
19+
20+
/**
21+
* Whether `now` is inside the window opened at `lastBackAt`.
22+
*
23+
* A wall clock can step backwards (NTP correction, a manual change), which
24+
* makes the delta negative and would keep every press out for as long as the
25+
* step lasts. A negative delta is read as "the clock moved, not a repeat" and
26+
* opens a fresh window instead.
27+
*/
28+
function insideWindow(lastBackAt: number, now: number): boolean {
29+
const elapsed = now - lastBackAt;
30+
return elapsed >= 0 && elapsed < HEADER_BACK_GUARD_MS;
31+
}
32+
33+
/** What a header Back press should run. */
34+
type HeaderBackDecision = 'handler' | 'history' | 'fallback' | 'none';
35+
36+
/**
37+
* Claim the burst window: `true` when this press may dispatch, `false` when it
38+
* repeats a dispatch inside the window. The accepted press is recorded before
39+
* returning, so a re-entrant call cannot claim the same window twice.
40+
*/
41+
export function claimHeaderBack(state: HeaderBackGuardState, now: number): boolean {
42+
if (insideWindow(state.lastBackAt, now)) {
43+
return false;
44+
}
45+
state.lastBackAt = now;
46+
return true;
47+
}
48+
49+
/**
50+
* Decide what a Back press runs, from the controls the screen declared.
51+
*
52+
* A screen's own `onBack` owns the press, because it may open a confirmation
53+
* instead of popping. Otherwise the navigator pops only when it has somewhere
54+
* to pop to: a `router.back()` with no history is handled by
55+
* whatever navigator is left (the tab navigator switches to its first tab) or
56+
* is unhandled at the root, which is the unwinding past the stack root this
57+
* guard exists to stop. A declared `backFallback` is the destination for a
58+
* screen reached without history; with neither, the press does nothing.
59+
*/
60+
export function resolveHeaderBack(declared: {
61+
hasHandler: boolean;
62+
hasFallback: boolean;
63+
canGoBack: boolean;
64+
}): HeaderBackDecision {
65+
if (declared.hasHandler) {
66+
return 'handler';
67+
}
68+
if (declared.canGoBack) {
69+
return 'history';
70+
}
71+
return declared.hasFallback ? 'fallback' : 'none';
72+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { createElement } from 'react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { act, TestRenderer } from '@/test/renderer';
5+
6+
import { HEADER_BACK_GUARD_MS } from './header-back';
7+
import { type HeaderBackOptions, useHeaderBack } from './use-header-back';
8+
9+
const router = vi.hoisted(() => ({ back: vi.fn(), dismissTo: vi.fn() }));
10+
11+
vi.mock('expo-router', () => ({ useRouter: () => router }));
12+
13+
const T0 = 1_700_000_000_000;
14+
15+
function Probe({
16+
props,
17+
press,
18+
}: Readonly<{ props: HeaderBackOptions; press: { current: () => void } }>) {
19+
press.current = useHeaderBack(props);
20+
return null;
21+
}
22+
23+
function mountHook(initial: HeaderBackOptions) {
24+
const press: { current: () => void } = { current: () => undefined };
25+
const created: { renderer: TestRenderer.ReactTestRenderer | undefined } = { renderer: undefined };
26+
act(() => {
27+
created.renderer = TestRenderer.create(createElement(Probe, { props: initial, press }));
28+
});
29+
const renderer = created.renderer;
30+
if (!renderer) {
31+
throw new Error('renderer was not created');
32+
}
33+
return {
34+
press: () => {
35+
act(() => {
36+
press.current();
37+
});
38+
},
39+
rerender: (next: HeaderBackOptions) => {
40+
act(() => {
41+
renderer.update(createElement(Probe, { props: next, press }));
42+
});
43+
},
44+
};
45+
}
46+
47+
// The hook reads `Date.now()`, so the burst window is driven from the fake clock.
48+
function advance(ms: number): void {
49+
vi.setSystemTime(Date.now() + ms);
50+
}
51+
52+
beforeEach(() => {
53+
router.back.mockClear();
54+
router.dismissTo.mockClear();
55+
vi.useFakeTimers({ now: T0 });
56+
});
57+
58+
afterEach(() => {
59+
vi.useRealTimers();
60+
});
61+
62+
describe('useHeaderBack', () => {
63+
it('pops once for a burst on the same header', () => {
64+
const header = mountHook({ canGoBack: true });
65+
66+
header.press();
67+
advance(4);
68+
header.press();
69+
advance(40);
70+
header.press();
71+
72+
expect(router.back).toHaveBeenCalledTimes(1);
73+
});
74+
75+
it('pops again once the window has passed', () => {
76+
const header = mountHook({ canGoBack: true });
77+
78+
header.press();
79+
advance(HEADER_BACK_GUARD_MS);
80+
header.press();
81+
82+
expect(router.back).toHaveBeenCalledTimes(2);
83+
});
84+
85+
it('never pops without history', () => {
86+
const header = mountHook({ canGoBack: false });
87+
88+
header.press();
89+
advance(HEADER_BACK_GUARD_MS);
90+
header.press();
91+
92+
expect(router.back).not.toHaveBeenCalled();
93+
expect(router.dismissTo).not.toHaveBeenCalled();
94+
});
95+
96+
it('dismisses to the declared fallback when the container has no history', () => {
97+
const header = mountHook({ canGoBack: false, backFallback: '/profile' });
98+
99+
header.press();
100+
101+
expect(router.back).not.toHaveBeenCalled();
102+
expect(router.dismissTo).toHaveBeenCalledWith('/profile');
103+
});
104+
105+
it('runs the screen handler instead of a pop, once per burst', () => {
106+
const onBack = vi.fn<() => void>();
107+
const header = mountHook({ canGoBack: true, onBack });
108+
109+
header.press();
110+
advance(4);
111+
header.press();
112+
113+
expect(onBack).toHaveBeenCalledTimes(1);
114+
expect(router.back).not.toHaveBeenCalled();
115+
});
116+
117+
it('leaves the window unclaimed when the press has nothing to run', () => {
118+
const header = mountHook({ canGoBack: false });
119+
120+
header.press();
121+
// The navigator gained history a moment later; the dropped press must not
122+
// hold the new one out of the same window.
123+
advance(10);
124+
header.rerender({ canGoBack: true });
125+
header.press();
126+
127+
expect(router.back).toHaveBeenCalledTimes(1);
128+
});
129+
});

0 commit comments

Comments
 (0)