Skip to content

test(app-router): lock navigation lifecycle settlement and root-layout hard navigation behaviour#1053

Merged
james-elicx merged 6 commits intocloudflare:mainfrom
NathanDrake2406:test/lock-lifecycle-settlement
May 5, 2026
Merged

test(app-router): lock navigation lifecycle settlement and root-layout hard navigation behaviour#1053
james-elicx merged 6 commits intocloudflare:mainfrom
NathanDrake2406:test/lock-lifecycle-settlement

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented May 4, 2026

Summary

Adds 6 unit tests locking two Wave 0 baseline areas from the #726 router kernel architecture — in preparation for the Layer 1 lifecycle spine migration.

Lifecycle settlement (4 tests)

The App Router uses a completely different lifecycle mechanism from the Pages Router (activeNavigationId counter + promise-based commit gate vs AbortController-based cancellation). The Pages Router has 10 proven race-condition tests in shims.test.ts. The App Router had zero equivalent unit tests for these controller-level concurrency semantics.

Test Hostile timeline verified
Three navigations, payloads resolve in reverse order A→B→C, C wins; B and A skipped before commit-effect creation
Stale cross-root is skipped, not hard-navigated Cross-root A superseded by same-root B; no window.location.assign
resolveAndClassifyNavigationCommit skip classification startedNavId ≠ activeNavId → skip through the await-payload helper path
Failed payload cleanly settles pending router state Rejected promise → settled pending, error propagated

Root-layout hard navigation (2 tests)

Test Behaviour locked
renderNavigationPayload calls window.location.assign on cross-root Full integrated dispatch→hard-navigate path
Hard-navigate settles pending router state before navigating away Pending promise resolved before window.location.assign fires (asserted via assign.mockImplementation)

Why this matters for #726

The architecture's Implementation Plan says Layer 0 is "freeze today's flat-wire behavior with compatibility tests." These tests are the lifecycle-settlement and root-layout halves of that layer. Without them, the "delete the old writer" steps in Layers 4–5 have no regression safety net for the concurrency semantics that are hardest to verify by code review alone.

Design decisions

  • No real timers. Every test uses deferred Promises for payload resolution control. No setTimeout, no vi.advanceTimersByTime. Zero flake surface.
  • No awaiting renderNavigationPayload returns. The returned promise resolves only when NavigationCommitSignal mounts in a React tree, which never happens in unit tests. Tests verify state through stateRef.current after yielding microticks — same pattern as the existing "uses render ids independent from navigation ids" test.
  • Behaviour contracts, not implementation internals. Tests assert on stateRef.current.routeId (user-visible state), window.location.assign calls (browser effect), and createNavigationCommitEffect invocation (side-effect gating). They don't assert on internal Map sizes or reducer shape.
  • Follows existing conventions. Same createControllerHarness, createState, createResolvedElements, stubWindow helpers. Same vi.spyOn/mockRestore/detach() cleanup pattern. Sits in the same describe/file as the existing controller tests.

…t hard navigation behaviour

Adds 13 unit tests covering two Wave 0 baseline areas from issue cloudflare#726:

BASE-02 — Lock current lifecycle settlement behaviour (9 tests):
- Three concurrent navigations with reverse-order payload resolution
- Rapid sequential navigation: stale deferred payload does not overwrite
- Stale cross-root navigation is skipped instead of hard-navigating
- resolvePendingNavigationCommitDisposition exhausts skip/dispatch/hard-navigate
- resolveAndClassifyNavigationCommit classifies skip when IDs diverge
- Failed RSC payload settles pending router state cleanly

BASE-03 — Lock root-layout hard navigation behaviour (4 tests):
- renderNavigationPayload calls window.location.assign on root change
- Hard-navigate settles the pending router state before navigating away
- shouldHardNavigate negative/null path exhaustiveness
- shouldHardNavigate positive path with distinct roots

These tests close the App Router concurrency gap: the Pages Router has 10
race-condition tests in shims.test.ts but the App Router (which uses a
completely different lifecycle mechanism) had zero equivalent tests for
out-of-order payload resolution and stale commit rejection.
Copilot AI review requested due to automatic review settings May 4, 2026 07:49
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 4, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1053

commit: 986f5f9

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thorough, well-structured test suite. All 58 tests pass (including the 13 new ones). The tests lock real concurrency semantics that had zero unit-level coverage on the App Router side, and the stale-cross-root-skip test in particular covers a subtle priority inversion (skip must win over hard-navigate for superseded navigations) that would be very hard to catch in E2E alone.

A few minor observations below — none blocking.

Comment thread tests/app-browser-entry.test.ts Outdated
});
});

// ─── BASE-02: Lock current lifecycle settlement behaviour ─────────────────────
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The action-queue.ts link is a useful directional pointer, but the lifecycle settlement logic being tested here (resolvePendingNavigationCommitDisposition, the activeNavigationId counter pattern) is vinext's own mechanism, not a port of Next.js's action-queue.ts. Next.js uses a linked-list action queue with a different staleness model. Might be worth adjusting the comment to say "Inspired by" or "Analogous to" rather than "Ported from" to avoid implying the implementations match 1:1.

Same applies to the BASE-03 header at line 1000 referencing compare-states.tsshouldHardNavigate is vinext's own function with its own null-safety semantics.

}),
);

const effectsRun: string[] = [];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice touch using the effectsRun array to verify that only C's createNavigationCommitEffect was invoked. This cleanly distinguishes "payload resolved but was classified as skip" from "payload resolved and commit effect was queued" — which is the exact boundary that matters for the Layer 1 migration.

expect(result.pending.routeId).toBe("route:/dashboard");
});

it("failed payload cleanly settles the pending router state without leaving it hanging", async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this test spies on clearPendingPathname but the assertion is only on pendingRouterState.settled and the rejection. The spy is necessary to prevent the real clearPendingPathname from throwing in a non-browser environment, but a brief comment explaining that (like the existing tests at lines 373, 395 do implicitly) would help future readers understand it's a guard, not an assertion target.

Comment thread tests/app-browser-entry.test.ts Outdated
}
});

it("shouldHardNavigate returns false when either root-layout identity is null", () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting for future work: this tests shouldHardNavigate in isolation, but there's an asymmetry worth documenting — resolvePendingNavigationCommitDisposition delegates to shouldHardNavigate only when startedNavigationId === activeNavigationId. So the null-safety valve is only reachable for current navigations. For stale navigations, the function returns "skip" before ever checking roots. The BASE-02 "stale cross-root" integration test at line 840 already covers this priority, but a brief note in this test's comment about the layering would connect the two.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 4, 2026

Review submitted. Here's a summary of what I found:

Verdict: Approved

All 58 tests pass (13 new + 45 existing). The new tests fill a real coverage gap — the App Router had zero unit-level concurrency tests despite the Pages Router having 10 proven race-condition tests in shims.test.ts.

Highlights:

  • The three-way race test (A/B/C payloads resolving in reverse order) and the stale-cross-root-skip test are the most valuable additions — they lock a subtle priority inversion where "skip" must win over "hard-navigate" for superseded navigations
  • Deferred-promise pattern for payload control is clean and flake-free (no timers)
  • Follows existing conventions (createControllerHarness, stubWindow, try/finally cleanup)

Minor observations (non-blocking):

  1. The "Ported from Next.js" comments reference action-queue.ts and compare-states.ts, but the tested code is vinext's own mechanism (counter-based staleness vs Next.js's linked-list action queue). "Inspired by" or "Analogous to" would be more accurate.
  2. The clearPendingPathname spy in the failed-payload test is a guard against non-browser throws, not an assertion target — a brief comment would help future readers.
  3. The shouldHardNavigate null-exhaustiveness test could note that the null safety valve is only reachable for current navigations (stale ones return "skip" before checking roots).

github run

Comment thread tests/app-browser-entry.test.ts Outdated

// ─── BASE-02: Lock current lifecycle settlement behaviour ─────────────────────
// Ported from Next.js navigation lifecycle semantics:
// https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/action-queue.ts
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't exist?

are these tests not covered by existing e2es and fixtures?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It probably hallucinated due to the instruction I put in for bug fixings. But the tests are good! The current e2e tests the happy path but don't test these controller contracts. I'll delete those comments

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@codex Please review this PR critically and let me know if these new tests are necessary or redundant

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b7a516eb60

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/app-browser-entry.test.ts Outdated
Comment on lines +1081 to +1083
expect(shouldHardNavigate(null, null)).toBe(false);
expect(shouldHardNavigate(null, "/")).toBe(false);
expect(shouldHardNavigate("/", null)).toBe(false);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Remove duplicate null-root hard-navigation assertions

This test case repeats the exact shouldHardNavigate(null, …) expectations that are already covered by treats null root-layout identities as soft-navigation compatible later in the same file, so it doesn't add behavioral coverage and increases maintenance noise when the helper contract changes. Keeping one canonical test for this null-root safety valve is enough.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@james-elicx james-elicx enabled auto-merge (squash) May 5, 2026 18:57
@james-elicx james-elicx disabled auto-merge May 5, 2026 19:01
@james-elicx james-elicx enabled auto-merge (squash) May 5, 2026 19:04
@james-elicx james-elicx merged commit 4342311 into cloudflare:main May 5, 2026
24 checks passed
@NathanDrake2406 NathanDrake2406 deleted the test/lock-lifecycle-settlement branch May 6, 2026 04:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants