Skip to content
Draft
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
4 changes: 2 additions & 2 deletions packages/vinext/src/server/app-browser-action-result.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ACTION_REVALIDATED_HEADER } from "./headers.js";
import { VINEXT_RSC_CONTENT_TYPE } from "./app-rsc-cache-busting.js";
import { ServerActionResultFactsV0 } from "./navigation-planner.js";
import { ServerActionResultFacts } from "./navigation-planner.js";

export type AppBrowserServerActionResult<TRoot> = {
root?: TRoot;
Expand Down Expand Up @@ -124,7 +124,7 @@ export type ServerActionResultResponseFactsInput = {
*/
export function createServerActionResultFacts(
input: ServerActionResultResponseFactsInput,
): ServerActionResultFactsV0 {
): ServerActionResultFacts {
return {
actionRedirectHref: input.actionRedirectHref,
actionRedirectType: input.actionRedirectType === "push" ? "push" : "replace",
Expand Down
12 changes: 6 additions & 6 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,8 @@ import {
import { removeStylesheetLinksCoveredByInlineCss } from "./app-inline-css-client.js";
import {
navigationPlanner,
type NavigationReuseFactsV0,
type VisitedResponseCacheCandidateFactsV0,
type NavigationReuseFacts,
type VisitedResponseCacheCandidateFacts,
} from "./navigation-planner.js";

type SearchParamInput = ConstructorParameters<typeof URLSearchParams>[0];
Expand Down Expand Up @@ -714,12 +714,12 @@ type VisitedResponseCacheCandidate =
| {
cacheKey: string;
entry: VisitedResponseCacheEntry;
facts: Extract<VisitedResponseCacheCandidateFactsV0, { candidate: "present" }>;
facts: Extract<VisitedResponseCacheCandidateFacts, { candidate: "present" }>;
}
| {
cacheKey: string;
entry: null;
facts: Extract<VisitedResponseCacheCandidateFactsV0, { candidate: "missing" }>;
facts: Extract<VisitedResponseCacheCandidateFacts, { candidate: "missing" }>;
};

function readVisitedResponseCacheCandidate(
Expand Down Expand Up @@ -1791,7 +1791,7 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void {
facts: {
candidate: "missing",
navigationKind,
} satisfies Extract<VisitedResponseCacheCandidateFactsV0, { candidate: "missing" }>,
} satisfies Extract<VisitedResponseCacheCandidateFacts, { candidate: "missing" }>,
}
: readVisitedResponseCacheCandidate(
rscUrl,
Expand All @@ -1806,7 +1806,7 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void {
visitedResponseCandidate,
visitedResponseDecision,
);
const visitedResponse: NavigationReuseFactsV0["visitedResponse"] =
const visitedResponse: NavigationReuseFacts["visitedResponse"] =
cachedRoute === null ? { status: "unavailable" } : { status: "available" };
const prefetchProbeDecision = navigationPlanner.classifyNavigationPrefetchProbe({
bypassNavigationCache: shouldBypassNavigationCache,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { ServerActionResultDecisionV0 } from "./navigation-planner.js";
import type { ServerActionResultDecision } from "./navigation-planner.js";

// Dispatches the executor effects implied by a ServerActionResultDecisionV0.
// Dispatches the executor effects implied by a ServerActionResultDecision.
// Returns true if a hard-navigation was triggered (the caller should return early);
// false if the decision is "proceed" and normal action processing should continue.
// Both callbacks are injected so this function is testable without browser globals.
export function applyServerActionResultDecision(
decision: ServerActionResultDecisionV0,
decision: ServerActionResultDecision,
clearCaches: () => void,
performHardNavigation: (url: string, historyMode?: "assign" | "replace") => void,
): boolean {
Expand Down
81 changes: 50 additions & 31 deletions packages/vinext/src/server/app-browser-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ import { createCacheEntryReuseProof, type CacheEntryReuseProof } from "./cache-p
import {
navigationPlanner,
resolveDefaultOrUnmatchedSlotPersistenceForLayouts,
type MountedParallelSlotSnapshotV0,
type NavigationDecisionV0,
type MountedParallelSlotSnapshot,
type NavigationDecision,
type OperationLane,
type OperationToken,
type RouteSnapshotV0,
type RouteSnapshot,
} from "./navigation-planner.js";
import { verifyOperationTokenForCommit, type VerifiedOperationToken } from "./operation-token.js";
import {
createSnapshotPathAndSearch,
type ClientNavigationRenderSnapshot,
Expand Down Expand Up @@ -511,18 +512,29 @@ export function resolvePendingNavigationCommitDispositionDecision(options: {
targetHref?: string;
}): PendingNavigationCommitDispositionDecision {
const traceFields = createPendingNavigationTraceFields(options);
const targetSnapshot = createPendingRouteSnapshot(options.pending);
const token = createPendingNavigationOperationToken({
pending: options.pending,
routeManifest: options.routeManifest ?? null,
startedNavigationId: options.startedNavigationId,
targetSnapshot,
});

if (
options.startedNavigationId !== options.activeNavigationId ||
options.pending.action.operation.startedVisibleCommitVersion !==
options.currentState.visibleCommitVersion
) {
// staleOperation — the navigation that created `pending` started from a
// different visibleCommitVersion than the current state. This happens when
// a synchronous history snapshot restore (restoreHistoryStateSnapshot, see
// OperationToken is the single eligibility authority for commit approval: a
// result may enter commit approval only if its token proves it belongs to the
// active navigation and the visible commit version it started from is still
// current. The token verifies; ApprovedVisibleCommit (downstream) mutates.
const verdict = verifyOperationTokenForCommit(token, {
activeNavigationId: options.activeNavigationId,
visibleCommitVersion: options.currentState.visibleCommitVersion,
});
if (!verdict.authorized) {
// staleOperation — the navigation that created `pending` was superseded, or
// visible state advanced after it started. The latter happens when a
// synchronous history snapshot restore (restoreHistoryStateSnapshot, see
// app-browser-entry.ts popstate handler) bumps visibleCommitVersion before
// an in-flight async RSC traverse resolves. The snapshot restore is the
// authoritative commit; the stale async payload is intentionally discarded.
// an in-flight async RSC traverse resolves. The authoritative commit wins;
// the stale async payload is intentionally discarded.
return {
disposition: "skip",
preserveElementIds: [],
Expand All @@ -536,6 +548,8 @@ export function resolvePendingNavigationCommitDispositionDecision(options: {
pending: options.pending,
routeManifest: options.routeManifest ?? null,
targetHref: options.targetHref,
targetSnapshot,
token: verdict.token,
traceFields,
}),
);
Expand Down Expand Up @@ -569,8 +583,8 @@ function createPendingNavigationTraceFields(options: {

function createMountedParallelSlotSnapshots(
elements: AppElements,
): readonly MountedParallelSlotSnapshotV0[] {
const snapshots: MountedParallelSlotSnapshotV0[] = [];
): readonly MountedParallelSlotSnapshot[] {
const snapshots: MountedParallelSlotSnapshot[] = [];
for (const slotId of getMountedSlotIds(elements)) {
const parsed = AppElementsWire.parseElementKey(slotId);
if (parsed?.kind !== "slot") continue;
Expand All @@ -582,7 +596,7 @@ function createMountedParallelSlotSnapshots(
return snapshots;
}

function createVisibleRouteSnapshot(state: AppRouterState): RouteSnapshotV0 {
function createVisibleRouteSnapshot(state: AppRouterState): RouteSnapshot {
const displayUrl = createSnapshotPathAndSearch(state.navigationSnapshot);
const matchedUrl = normalizeNavigationSnapshotMatchedUrl(state.navigationSnapshot.pathname);
return {
Expand All @@ -605,7 +619,7 @@ function createVisibleRouteSnapshot(state: AppRouterState): RouteSnapshotV0 {
};
}

function createPendingRouteSnapshot(pending: PendingNavigationCommit): RouteSnapshotV0 {
function createPendingRouteSnapshot(pending: PendingNavigationCommit): RouteSnapshot {
const displayUrl = createSnapshotPathAndSearch(pending.action.navigationSnapshot);
const matchedUrl = normalizeNavigationSnapshotMatchedUrl(
pending.action.navigationSnapshot.pathname,
Expand All @@ -631,19 +645,24 @@ function createPendingRouteSnapshot(pending: PendingNavigationCommit): RouteSnap
function createPendingNavigationOperationToken(options: {
pending: PendingNavigationCommit;
routeManifest: RouteManifest | null;
targetSnapshot: RouteSnapshotV0;
startedNavigationId: number;
targetSnapshot: RouteSnapshot;
}): OperationToken {
return {
baseVisibleCommitVersion: options.pending.action.operation.startedVisibleCommitVersion,
deploymentVersion: null,
graphVersion: options.routeManifest?.graphVersion ?? null,
lane: options.pending.action.operation.lane,
// The lifecycle navigation id the operation started under. operationId
// (renderId) cannot answer "belongs to the active navigation?" because it is
// a per-render counter; navigationId carries that lifecycle authority.
navigationId: options.startedNavigationId,
operationId: options.pending.action.operation.id,
targetSnapshotFingerprint: createRootBoundarySnapshotFingerprint(options.targetSnapshot),
};
}

function createRootBoundarySnapshotFingerprint(snapshot: RouteSnapshotV0): string {
function createRootBoundarySnapshotFingerprint(snapshot: RouteSnapshot): string {
return `${snapshot.routeId}|root:${snapshot.rootBoundaryId ?? "unknown"}`;
}

Expand All @@ -652,14 +671,14 @@ function planPendingRootBoundaryFlightResponse(options: {
pending: PendingNavigationCommit;
routeManifest: RouteManifest | null;
targetHref?: string;
// The token has already passed commit eligibility (verifyOperationTokenForCommit)
// in the disposition gate above. Requiring the verified brand here makes that
// ordering a compile-time guarantee: the planner cannot be reached with an
// unverified token.
token: VerifiedOperationToken;
targetSnapshot: RouteSnapshot;
traceFields: NavigationTraceFields;
}): NavigationDecisionV0 {
const targetSnapshot = createPendingRouteSnapshot(options.pending);
const token = createPendingNavigationOperationToken({
pending: options.pending,
routeManifest: options.routeManifest,
targetSnapshot,
});
}): NavigationDecision {
const cacheEntryReuseProof = options.pending.cacheEntryReuseProof;

// #726-CORE-07/08 keeps the browser state layer as the lifecycle gate and
Expand All @@ -669,7 +688,7 @@ function planPendingRootBoundaryFlightResponse(options: {
return navigationPlanner.plan({
routeManifest: options.routeManifest,
state: {
nextOperationToken: token,
nextOperationToken: options.token,
traceFields: options.traceFields,
visibleCommitVersion: options.currentState.visibleCommitVersion,
visibleSnapshot: createVisibleRouteSnapshot(options.currentState),
Expand All @@ -682,16 +701,16 @@ function planPendingRootBoundaryFlightResponse(options: {
// planner trace and future hard-nav executor agree with the browser
// URL. The fallback remains for lower-level tests and direct disposition
// callers that exercise only snapshot-derived planner semantics.
href: options.targetHref ?? targetSnapshot.displayUrl,
targetSnapshot,
href: options.targetHref ?? options.targetSnapshot.displayUrl,
targetSnapshot: options.targetSnapshot,
},
token,
token: options.token,
},
});
}

function mapNavigationDecisionToPendingDisposition(
decision: NavigationDecisionV0,
decision: NavigationDecision,
): PendingNavigationCommitDispositionDecision {
switch (decision.kind) {
case "proposeCommit":
Expand Down
Loading
Loading