diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 59daab8201..311401ef58 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -1219,7 +1219,10 @@ no inline-renderer or panel contract extension is needed. The host provides `ctx.navigation` to plugins declaring `navigation` in `inject`. `open(target)` returns a presentation result, not merely an accepted address: `opened`, `failed` (with a reason), `superseded`, or `cancelled`. The host owns one -history driver, toolbar/keyboard traversal, and a 15-second attempt deadline. +history driver, toolbar/keyboard traversal, and a 15-second attempt deadline +that runs only while a presenter is attached (the identity/key-setup gate, +`IdentitySetup`, hides the only one, so the startup visit's deadline starts when +that gate lifts). A visit has stable identity; retrying/reclicking preserves that visit and Forward, while a new destination truncates the forward branch. Leaving aborts the old attempt, and late completion cannot acknowledge a replacement attempt. diff --git a/src/app/navigation.ts b/src/app/navigation.ts index c281aa2ea0..30cd0becba 100644 --- a/src/app/navigation.ts +++ b/src/app/navigation.ts @@ -43,6 +43,12 @@ export function useAppNavigation( services.plugins.snapshot, ); + // This is the presenter the visit deadline measures. Behind the identity and + // key-setup gate (IdentitySetup) none exists, so the startup visit's deadline + // runs from this mount, not from createServices(); otherwise a new user's first + // frame was the timeout notice. + useLayoutEffect(() => services.navigationHost.attach(), [services]); + const startup = plugins.configuration.status; const target = state.entry.target; const settings = target.kind === "settings"; diff --git a/src/features/messages/ThreadPanel.test.tsx b/src/features/messages/ThreadPanel.test.tsx index a3741ef75e..bfdbbcb028 100644 --- a/src/features/messages/ThreadPanel.test.tsx +++ b/src/features/messages/ThreadPanel.test.tsx @@ -1210,6 +1210,8 @@ it.each(["ready", "unavailable", "error"] as const)( it("a presented ordinary thread survives the real navigation deadline while history is pending", async () => { vi.useFakeTimers(); const controller = createNavigationController(createMemoryHistory()); + // The visit deadline runs only while a presenter is attached, as the app's is. + controller.attach(); let release = () => {}; try { const navigation = ordinaryNavigation(); diff --git a/src/features/navigation/controller.test.ts b/src/features/navigation/controller.test.ts index addd67ce0e..022aea400d 100644 --- a/src/features/navigation/controller.test.ts +++ b/src/features/navigation/controller.test.ts @@ -14,10 +14,12 @@ const page: OpenTarget = { pageId: "board", }; const cleanups: (() => void)[] = []; -function setup(capacity = 100) { +function setup(capacity = 100, presenter = true) { const history = createMemoryHistory(homeTarget, capacity); const host = createNavigationController(history); cleanups.push(host.dispose); + // The deadline measures a presenter; the app's attaches on mount. + if (presenter) cleanups.push(host.attach()); return { host, navigation: host.navigation, history }; } afterEach(() => { @@ -216,6 +218,42 @@ describe("navigation attempts over one history driver", () => { }); }); +it("runs the attempt deadline only while a presenter is attached", async () => { + vi.useFakeTimers(); + const { host, navigation: nav } = setup(100, false); + // The startup visit exists before any presenter; its deadline is not running. + const { attempt } = nav.snapshot(); + expect(nav.snapshot()).toMatchObject({ status: "opening", attempt }); + await vi.advanceTimersByTimeAsync(60_000); + expect(nav.snapshot()).toMatchObject({ status: "opening", attempt }); + // The deadline starts when a presenter attaches and stops when the last leaves. + const first = host.attach(); + await vi.advanceTimersByTimeAsync(14_999); + const second = host.attach(); + first(); + await vi.advanceTimersByTimeAsync(1); + // One presenter remains, so the running deadline stands. + expect(nav.snapshot()).toMatchObject({ + status: "failed", + reason: "timeout", + attempt, + }); + const pending = nav.retry(); + await vi.advanceTimersByTimeAsync(14_999); + second(); + await vi.advanceTimersByTimeAsync(60_000); + expect(nav.snapshot().status).toBe("opening"); + // A presenter attaching later is measured from its own start. + cleanups.push(host.attach()); + await vi.advanceTimersByTimeAsync(14_999); + expect(nav.snapshot().status).toBe("opening"); + await vi.advanceTimersByTimeAsync(1); + await expect(pending).resolves.toEqual({ + status: "failed", + reason: "timeout", + }); +}); + it("default resolution preserves visit, promise, signal and deadline while revoking the old completion token", async () => { vi.useFakeTimers(); const { host, navigation: nav } = setup(); diff --git a/src/features/navigation/controller.ts b/src/features/navigation/controller.ts index 8b7ee78fbc..5492279bfb 100644 --- a/src/features/navigation/controller.ts +++ b/src/features/navigation/controller.ts @@ -78,11 +78,15 @@ export function createNavigationController( promise: Promise; resolve: (result: OpenResult) => void; pending: boolean; - timer?: ReturnType; + timer?: ReturnType | undefined; }; let disposed = false; let active: Operation; let snapshot: NavigationSnapshot; + // The deadline measures a presenter. None may exist yet (the identity and + // key-setup gate, IdentitySetup, hides the app for minutes), so it runs only + // while one is attached. + let presenters = 0; let ingressRetry: (() => Promise) | undefined; const listeners = new Set<() => void>(); // Commit state and capture each caller's result before invoking external code. @@ -123,6 +127,16 @@ export function createNavigationController( clearTimeout(operation.timer); operation.resolve(result); } + function arm(operation: Operation) { + if (!presenters || !operation.pending || operation.timer) return; + operation.timer = setTimeout( + () => + transaction(() => { + finish(operation, { status: "failed", reason: "timeout" }); + }), + timeoutMs, + ); + } function finish(operation: Operation, result: OpenResult) { if (operation !== active || !operation.pending) return; settle(operation, result); @@ -174,13 +188,7 @@ export function createNavigationController( finish(operation, { status: "failed", reason: "invalid-target" }); return promise; } - operation.timer = setTimeout( - () => - transaction(() => { - finish(operation, { status: "failed", reason: "timeout" }); - }), - timeoutMs, - ); + arm(operation); emit(); return promise; } @@ -295,6 +303,24 @@ export function createNavigationController( }); return { navigation, + /** + * Host-only: a presenter now exists. The pending visit's deadline starts (or + * restarts) here and stops when the last presenter detaches, so an attempt + * nobody could present never times out, while one a presenter stalls on does. + */ + attach() { + presenters++; + arm(active); + let attached = true; + return () => { + if (!attached) return; + attached = false; + presenters--; + if (presenters) return; + clearTimeout(active.timer); + active.timer = undefined; + }; + }, /** Normalize a pending presentation, not a new intent; never reset its deadline. */ resolve(attempt: OpenAttempt, input: OpenTarget) { return transaction(() => { diff --git a/src/features/navigation/service.test.ts b/src/features/navigation/service.test.ts index 68510fed87..86d383336b 100644 --- a/src/features/navigation/service.test.ts +++ b/src/features/navigation/service.test.ts @@ -24,6 +24,8 @@ function setup(retain = false) { const ctx = new Context(); contexts.push(ctx); const host = provideNavigation(ctx); + // The visit deadline runs only while a presenter is attached, as the app's is. + host.attach(); let live = true; const listeners = new Set<() => void>(); const owner: PresentationOwner = {