/**
* Intercept Resolution Middleware
*
* Resolves intercept (modal slot) segments for soft navigation.
* Yields intercept segments after main route segments.
*
* FLOW DIAGRAM
* ============
*
* source (from segment-resolution)
* |
* v
* +---------------------------+
* | Collect + yield source | Pass through main segments
* | segments[] |
* +---------------------------+
* |
* v
* +---------------------+
* | isFullMatch? |──yes──> return (no intercepts on doc requests)
* +---------------------+
* | no
* v
* +---------------------+
* | Has interceptResult |──no───> return
* | AND not cached? |
* +---------------------+
* | yes
* v
* +----------------------+ +----------------------------+
* | Fresh intercept? |yes>| resolveInterceptEntry() |
* | (!cacheHit or | | - middleware, loaders, UI |
* | no intercept segs) | +----------------------------+
* +----------------------+ |
* | no v
* v yield intercept segments
* +----------------------------+ |
* | Cache hit with intercept | |
* | handleCacheHitIntercept() | |
* | - Extract from cache | |
* | - Re-resolve loaders only | |
* +----------------------------+ |
* | |
* +-------------------------------+
* |
* v
* +---------------------------+
* | Update state: |
* | - interceptSegments |
* | - slots[slotName] |
* +---------------------------+
* |
* v
* next middleware
*
*
* INTERCEPT SCENARIOS
* ===================
*
* 1. Fresh intercept (no cache):
* - Full resolution of intercept entry
* - Resolves middleware, loaders, and component
* - Yields all intercept segments
*
* 2. Cache hit with intercept:
* - Extracts intercept segments from cached data
* - Re-resolves ONLY loaders for fresh data
* - Keeps cached component/layout
*
* 3. No intercept:
* - Passes through unchanged
* - No intercept segments yielded
*
*
* WHAT ARE INTERCEPTS?
* ====================
*
* Intercepts enable "soft navigation" patterns like modals:
*
* 1. User clicks a link (e.g., /photos/123)
* 2. Instead of full navigation, content renders in a modal slot
* 3. Background page remains visible and interactive
* 4. Hard navigation (direct URL) shows full page
*
* Configuration:
* intercept("@modal", "photos", , () => [...])
*
* The intercept resolves to segments that render in the named slot
* instead of replacing the main content.
*
*
* SLOT STRUCTURE
* ==============
*
* state.slots[slotName] = {
* active: true,
* segments: [...intercept segments]
* }
*
* The client uses this to:
* 1. Keep current page segments
* 2. Render intercept segments in named
*/
import type { ResolvedSegment } from "../../types.js";
import type { MatchContext, MatchPipelineState } from "../match-context.js";
import { getRouterContext } from "../router-context.js";
import type { GeneratorMiddleware } from "./cache-lookup.js";
import { debugLog } from "../logging.js";
/**
* Creates intercept resolution middleware
*
* If ctx.interceptResult exists and we're not in a cache-hit-with-intercept scenario:
* - Resolves intercept segments
* - Updates state.interceptSegments
* - Updates state.slots with the intercept slot
* - Yields intercept segments after main segments
*/
export function withInterceptResolution(
ctx: MatchContext,
state: MatchPipelineState,
): GeneratorMiddleware {
return async function* (
source: AsyncGenerator,
): AsyncGenerator {
const ms = ctx.metricsStore;
// First, yield all segments from the source (main segment resolution or cache)
const segments: ResolvedSegment[] = [];
for await (const segment of source) {
segments.push(segment);
yield segment;
}
// Measure own work only (after source iteration completes)
const ownStart = performance.now();
// Skip intercept resolution for full match (document requests don't have intercepts)
if (ctx.isFullMatch) {
if (ms) {
ms.metrics.push({
label: "pipeline:intercept",
duration: performance.now() - ownStart,
startTime: ownStart - ms.requestStart,
});
}
return;
}
// Skip intercept resolution if:
// 1. No intercept result
// 2. Already have intercept segments (from cache hit with intercept key)
// 3. Cache hit with intercept key
const skipInterceptResolution =
!ctx.interceptResult ||
state.interceptSegments.length > 0 ||
(state.cacheHit && ctx.isIntercept);
if (skipInterceptResolution) {
// For cache hit with intercept, extract intercept segments from cached data for slots
// and re-resolve loaders for fresh data
if (ctx.interceptResult && state.cacheHit && ctx.isIntercept) {
await handleCacheHitIntercept(ctx, state, segments);
}
if (ms) {
ms.metrics.push({
label: "pipeline:intercept",
duration: performance.now() - ownStart,
startTime: ownStart - ms.requestStart,
});
}
return;
}
// Resolve intercept segments
const { resolveInterceptEntry } = getRouterContext();
const slotName = ctx.interceptResult!.intercept.slotName;
debugLog("matchPartial.intercept", "intercept resolved", {
routeName: ctx.localRouteName,
slotName,
});
// Resolve intercept entry (middleware, loaders, handler)
const Store = ctx.Store;
const interceptSegments = await Store.run(() =>
resolveInterceptEntry(
ctx.interceptResult!.intercept,
ctx.interceptResult!.entry,
ctx.matched.params,
ctx.handlerContext,
true, // belongsToRoute
{
clientSegmentIds: ctx.clientSegmentSet,
prevParams: ctx.prevParams,
request: ctx.request,
prevUrl: ctx.prevUrl,
nextUrl: ctx.url,
routeKey: ctx.routeKey,
actionContext: ctx.actionContext,
stale: ctx.stale,
},
),
);
// Update state
state.interceptSegments = interceptSegments;
state.slots[slotName] = {
active: true,
segments: interceptSegments,
};
// Yield intercept segments
for (const segment of interceptSegments) {
yield segment;
}
if (ms) {
ms.metrics.push({
label: "pipeline:intercept",
duration: performance.now() - ownStart,
startTime: ownStart - ms.requestStart,
});
}
};
}
/**
* Handle cache hit with intercept scenario
*
* Extract intercept segments from cached data and re-resolve loaders for fresh data.
*/
async function handleCacheHitIntercept(
ctx: MatchContext,
state: MatchPipelineState,
segments: ResolvedSegment[],
): Promise {
if (!ctx.interceptResult) return;
const { resolveInterceptLoadersOnly } = getRouterContext();
const slotName = ctx.interceptResult.intercept.slotName;
// Find intercept segments from cached segments (namespace starts with "intercept:")
const interceptSegments = segments.filter((s) =>
s.namespace?.startsWith("intercept:"),
);
state.interceptSegments = interceptSegments;
// Re-resolve intercept loaders for fresh data on cache hit
// This keeps cached component/layout but fetches fresh loader data
if (resolveInterceptLoadersOnly) {
const Store = ctx.Store;
const freshLoaderResult = await Store.run(() =>
resolveInterceptLoadersOnly(
ctx.interceptResult!.intercept,
ctx.interceptResult!.entry,
ctx.matched.params,
ctx.handlerContext,
true, // belongsToRoute
{
clientSegmentIds: ctx.clientSegmentSet,
prevParams: ctx.prevParams,
request: ctx.request,
prevUrl: ctx.prevUrl,
nextUrl: ctx.url,
routeKey: ctx.routeKey,
actionContext: ctx.actionContext,
stale: ctx.stale,
},
),
);
// Update intercept segment's loaderDataPromise with fresh data
if (freshLoaderResult) {
const interceptMainSegment = interceptSegments.find(
(s) => s.type === "parallel" && s.slot,
);
if (interceptMainSegment) {
interceptMainSegment.loaderDataPromise =
freshLoaderResult.loaderDataPromise;
interceptMainSegment.loaderIds = freshLoaderResult.loaderIds;
debugLog(
"matchPartial.intercept",
"cache hit with fresh intercept loaders",
{
routeName: ctx.localRouteName,
slotName,
},
);
}
} else {
debugLog(
"matchPartial.intercept",
"cache hit without intercept loader revalidation",
{
routeName: ctx.localRouteName,
slotName,
},
);
}
}
state.slots[slotName] = {
active: true,
segments: interceptSegments,
};
}