Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/app/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 2 additions & 0 deletions src/features/messages/ThreadPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
40 changes: 39 additions & 1 deletion src/features/navigation/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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();
Expand Down
42 changes: 34 additions & 8 deletions src/features/navigation/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,15 @@ export function createNavigationController(
promise: Promise<OpenResult>;
resolve: (result: OpenResult) => void;
pending: boolean;
timer?: ReturnType<typeof setTimeout>;
timer?: ReturnType<typeof setTimeout> | 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<OpenResult>) | undefined;
const listeners = new Set<() => void>();
// Commit state and capture each caller's result before invoking external code.
Expand Down Expand Up @@ -123,6 +127,16 @@ export function createNavigationController(
clearTimeout(operation.timer);
operation.resolve(result);
}
function arm(operation: Operation) {
if (!presenters || !operation.pending || operation.timer) return;
Comment thread
matt2e marked this conversation as resolved.
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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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(() => {
Expand Down
2 changes: 2 additions & 0 deletions src/features/navigation/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading