Skip to content

refactor(app-router): gate browser state writes with approved visible commits#1090

Merged
james-elicx merged 5 commits intocloudflare:mainfrom
NathanDrake2406:nathan/726-core-02-approved-visible-commit
May 6, 2026
Merged

refactor(app-router): gate browser state writes with approved visible commits#1090
james-elicx merged 5 commits intocloudflare:mainfrom
NathanDrake2406:nathan/726-core-02-approved-visible-commit

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented May 6, 2026

What this changes

Implements #726-CORE-02/03 from #726.

This introduces CommitDecision and ApprovedVisibleCommit as the explicit proof object for visible App Router browser commits. Navigation and same-URL server-action state writes now flow through lifecycle approval before visible browser state can be reduced, and HMR uses a named recovery approval path instead of generic proof construction.

Why

Issue #726 is moving the browser navigation lifecycle toward explicit commit authority. The current code already had operation records and visibleCommitVersion, but each browser writer still held its own implicit approval check before calling the reducer. That made future stale-result gates and planner work easier to bypass accidentally.

This PR is the intended thin spine step: no proof, no visible commit transaction.

Approach

  • Add app-browser-visible-commit.ts as the visible browser mutation authority module.
  • Keep app-browser-state.ts focused on state shape, reducer mechanics, pending commit construction, history helpers, server-action request headers, and legacy disposition tracing.
  • Add a commit decision layer that maps the existing dispatch, skip, and hard-navigate semantics to commit, no-commit, and hard-navigate decisions.
  • Add ApprovedVisibleCommit as a nominal commit proof and route visible state reduction through applyApprovedVisibleCommit.
  • Keep the generic proof constructors private inside app-browser-visible-commit.ts.
  • Expose approvePendingNavigationCommit() as the navigation lifecycle gate.
  • Expose approveHmrVisibleCommit() as the named trusted HMR recovery path, with a lane guard so non-HMR pending commits cannot use it as a generic approval escape hatch.
  • Replace the controller's raw dispatchBrowserTree path with dispatchApprovedVisibleCommit for navigation, same-URL action payloads, and HMR.
  • Have same-URL action payload handling read the authoritative CommitDecision directly, with the legacy disposition shape kept private to the older state-level classifier.
  • Preserve current behavior.

Non-goals for this PR, matching #726-CORE-02/03:

  • stale same-URL server-action rejection
  • NavigationPlanner
  • cache semantics

Validation

  • vp check packages/vinext/src/server/app-browser-state.ts packages/vinext/src/server/app-browser-visible-commit.ts packages/vinext/src/server/app-browser-navigation-controller.ts tests/app-browser-entry.test.ts
  • vp test run tests/app-browser-entry.test.ts tests/app-route-graph.test.ts
  • vp run vinext#build
  • Commit hook ran vp check --fix and knip --no-progress

Bonk

Bonk, please read issue #726 before reviewing so the ApprovedVisibleCommit boundary is evaluated in the larger lifecycle and planner roadmap context, not as an isolated refactor.

… commits

Browser router commits currently mutate visible state from raw pending navigation actions after separate disposition checks. That leaves lifecycle approval implicit at each writer, which makes later stale-operation and planner work easier to bypass by accident.

Introduce CommitDecision and ApprovedVisibleCommit as the proof object for visible browser commits. Route navigation, same-URL action, and HMR state writes through the approved commit path while preserving the legacy dispatch, skip, and hard-navigate dispositions for current callers.

Tests cover approved navigate, replace, traverse, stale no-commit, and hard-navigation decisions, alongside the existing browser lifecycle regression coverage.
Copilot AI review requested due to automatic review settings May 6, 2026 04:14
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 6, 2026

Open in StackBlitz

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

commit: 8e2fb7a

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.

Review: #726-CORE-02/03 — Approved Visible Commit Gate

Read #726 before reviewing. This is a well-scoped step toward the lifecycle authority spine described there. The extraction is clean: the controller gets simpler, the new module owns the commit proof, and the brand prevents external construction. Tests cover the happy path, lane guard, non-commit decisions, and all three action types through the approval path.

Overall this is ready to land. A few observations follow.

What's good

  • dispatchBrowserTree's 12-parameter signature collapsing into dispatchApprovedVisibleCommit(commit, pending, transition) is a real readability win. The controller lost ~50 lines and the intent at each call site is now obvious.
  • The brand symbol keeping createApprovedVisibleCommit private is the right call. External callers can't forge proof, which is the whole point of the boundary.
  • approveHmrVisibleCommit with the lane guard is a good pattern — named trusted paths instead of generic escape hatches.
  • The CommitApproval discriminated union (VisibleCommitApproval | NonVisibleCommitApproval) makes it impossible to forget the null check at the type level.
  • Tests cover the key sequences: commit, no-commit, hard-navigate, HMR lane guard rejection, replace-without-merge, traverse-with-slot-cleanup.

Observations

See inline comments for specifics. Summary:

  1. ClassifiedPendingNavigationCommit carries both disposition (legacy) and decision (new). Fine for the transitional step, but worth a comment noting which is authoritative.
  2. The approvedCommit === null throw in renderNavigationPayload is structurally unreachable after the two preceding guards. Current code is correct; a type predicate could eliminate the runtime check later.
  3. commitSameUrlNavigatePayload destructures disposition — the only use is === "hard-navigate". Could switch to decision.disposition and let a follow-up drop commitDecisionToPendingNavigationCommitDisposition entirely.
  4. Test coverage suggestion: the stale/hard-navigate non-approval test (line 659) could also assert trace reason codes (NC_STALE and NC_ROOT) to verify the trace carries through the new approval layer.

decision: CommitDecision;
disposition: PendingNavigationCommitDisposition;
pending: PendingNavigationCommit;
trace: NavigationTrace;
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.

The ClassifiedPendingNavigationCommit type now carries both disposition (legacy PendingNavigationCommitDisposition) and decision (new CommitDecision), which are semantically redundant — one is always a mechanical translation of the other via commitDecisionToPendingNavigationCommitDisposition.

This is fine for the transitional step (the PR description is clear this is intentional), but it would help future readers to have a doc comment here noting that decision is the authoritative field and disposition exists only for legacy caller compatibility. Otherwise someone maintaining commitSameUrlNavigatePayload might not know which one to trust.

};
type VisibleCommitApproval = {
approvedCommit: ApprovedVisibleCommit;
decision: VisibleCommitDecision;
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: VisibleCommitApproval.decision is always the same reference as approvedCommit.decision (set at line 137). Having both means callers can read the decision from two places. For the approval result, decision is useful because callers need it before (or without) unwrapping the commit — the non-commit paths need decision.disposition without an approvedCommit. Current shape is fine, just noting the duplication for awareness.


const approvedCommit = approval.approvedCommit;
if (approvedCommit === null) {
throw new Error("[vinext] Commit decision did not approve a visible commit");
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.

This throw is structurally unreachable: the two preceding guards ("no-commit" at line 425 and "hard-navigate" at line 432) return early for every non-commit decision, so approval.decision.disposition must be "commit" here, which means approval is VisibleCommitApproval and approvedCommit is non-null.

TypeScript can't narrow through the prior returns across the discriminated union, so the runtime check is defensible as a safety net. But if you want to eliminate it, a type assertion function on CommitApproval would work:

function assertVisibleCommitApproval(
  approval: CommitApproval,
): asserts approval is VisibleCommitApproval {
  if (approval.approvedCommit === null) {
    throw new Error("...");
  }
}

Not blocking — the current code is correct.

@@ -522,11 +486,11 @@ export function createAppBrowserNavigationController(
// need a stronger commit-version gate than activeNavigationId alone.
const {
disposition,
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.

This is the last consumer of the legacy disposition field from resolveAndClassifyNavigationCommit. The only use is disposition === "hard-navigate" at line 506. Since decision is also available through the destructured result, you could check decision.disposition === "hard-navigate" instead, which would let a follow-up PR remove commitDecisionToPendingNavigationCommitDisposition and the legacy disposition field from ClassifiedPendingNavigationCommit entirely. Not blocking since preserving the legacy contract is a stated non-goal.

};
}

export function approveHmrVisibleCommit(pending: PendingNavigationCommit): ApprovedVisibleCommit {
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.

The lane guard here is good — enforces that the HMR approval path can only be used by HMR operations.

PendingNavigationCommit.action.operation is always PendingOperationRecord (state "pending") by construction from createPendingNavigationCommit, so the type system already prevents CommittedOperationRecord from appearing here. Just noting this assumption is correct today and would need re-evaluation if PendingNavigationCommit objects were ever reused after commit.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review posted on PR #1090 with 5 inline comments and a summary. The review covers:

  1. ClassifiedPendingNavigationCommit dual-field redundancydisposition (legacy) and decision (new) are mechanically equivalent; suggested adding a doc comment noting which is authoritative.

  2. Unreachable null throw in renderNavigationPayload — the approvedCommit === null check at line 441 is structurally unreachable after the two preceding early returns, but TypeScript can't narrow it. Suggested a type assertion function pattern for when more call sites appear.

  3. Legacy disposition cleanup opportunitycommitSameUrlNavigatePayload is the last consumer; could switch to decision.disposition and drop commitDecisionToPendingNavigationCommitDisposition in a follow-up.

  4. VisibleCommitApproval.decision duplication — same reference as approvedCommit.decision; noted for awareness.

  5. HMR lane guard assumption — confirmed the type system prevents committed operations from reaching approveHmrVisibleCommit, noted the assumption for future changes.

Overall: the PR is a clean, well-scoped step toward the #726 lifecycle authority spine. The controller simplification from 12-param dispatchBrowserTree to 3-param dispatchApprovedVisibleCommit is a real win, and the brand-based proof boundary is correctly enforced.

github run

Visible commit classification still exposed a translated legacy disposition beside the authoritative decision. That left same-URL server action handling able to read two equivalent fields and made the approval boundary less clear than it needs to be.

Use the commit decision directly, remove the translation helper, keep the old state-level disposition private, and assert the non-visible trace reason codes in the approval tests.
@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.

Re-review: #726-CORE-02/03 — Approved Visible Commit Gate

Previous review observations have been addressed:

  1. ClassifiedPendingNavigationCommit dual-field redundancy — Fixed. The legacy disposition field was removed from ClassifiedPendingNavigationCommit. The type now carries only decision: CommitDecision as the authoritative field.

  2. Legacy disposition in commitSameUrlNavigatePayload — Fixed. Now destructures decision and checks decision.disposition === "hard-navigate" directly. The commitDecisionToPendingNavigationCommitDisposition function (which never existed in this PR but was a potential follow-up concern) is no longer relevant since the legacy shape was cleaned up in the same PR.

  3. Trace reason code assertions — Added. The stale/hard-navigate non-approval test (line 659) now asserts NavigationTraceReasonCodes.staleOperation and NavigationTraceReasonCodes.rootBoundaryChanged, verifying the trace carries through the approval layer.

  4. Unreachable null throw (line 439-441 of controller) — Retained as a defensive safety net, which was the recommendation. Fine.

  5. VisibleCommitApproval.decision duplication and HMR lane guard — Both were acknowledged observations, not blocking items. No change needed.

Verification

  • The dispatchBrowserTree 12-param function is fully removed. No references remain.
  • LayoutFlags and PendingOperationRecord imports were correctly pruned from the controller.
  • PendingNavigationCommit is correctly re-exported for the visible-commit module to consume.
  • The commitSameUrlNavigatePayload path's if (approvedCommit) truthiness check is semantically equivalent to the old if (disposition === "dispatch")approvedCommit is non-null only for commit decisions.
  • navigationSnapshot propagation through the approval boundary is correct: it lives inside commit.action for all three paths (navigation, HMR, same-URL action).
  • Brand symbol is properly confined to app-browser-visible-commit.ts.

Clean, well-scoped step. Ready to land.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review posted on PR #1090 with an approval. All 5 observations from the previous review were addressed or acknowledged:

  1. Legacy disposition dual-field removed from ClassifiedPendingNavigationCommit
  2. commitSameUrlNavigatePayload now uses decision.disposition directly
  3. Trace reason code assertions added to the non-approval test
  4. Defensive null throw retained as safety net (as recommended)
  5. HMR lane guard and decision duplication observations were non-blocking, unchanged

The PR is a clean, well-scoped extraction that replaces the 12-parameter dispatchBrowserTree with a 3-parameter dispatchApprovedVisibleCommit, enforces commit proof via branded types, and preserves all existing behavior.

github run

@james-elicx james-elicx merged commit 41d62e9 into cloudflare:main May 6, 2026
24 checks passed
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