Skip to content

fix(app-router): schedule segment-cache prefetch phases#2465

Open
james-elicx wants to merge 18 commits into
mainfrom
codex/fix-segment-cache-prefetch-scheduling-28478866791
Open

fix(app-router): schedule segment-cache prefetch phases#2465
james-elicx wants to merge 18 commits into
mainfrom
codex/fix-segment-cache-prefetch-scheduling-28478866791

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • schedule cache-components route-tree and segment prefetch phases in Next-compatible order
  • mark segment-prefetch requests with the App Router prefetch header
  • keep rescheduled hover/visible prefetches blocked on an existing route-tree response before starting the segment phase

Next.js parity

Targets the Segment Cache scheduling behavior covered by:

  • test/e2e/app-dir/segment-cache/prefetch-scheduling/prefetch-scheduling.test.ts

Validation

  • vp test run tests/link-navigation.test.ts -t "Link prefetch scheduling"
  • vp check packages/vinext/src/shims/link.tsx tests/link-navigation.test.ts
  • REPO="$PWD" NEXTJS_DIR="/Users/jamesanderson/Developer/vinext/.nextjs-ref" NEXT_TEST_CONCURRENCY=1 ./scripts/run-targeted-nextjs-e2e.sh test/e2e/app-dir/segment-cache/prefetch-scheduling/prefetch-scheduling.test.ts (4 passed, 1 skipped)

Review

Independent review found and fixed two issues before PR: missing Next-Router-Prefetch: 1 on segment-phase requests, and early segment-phase starts when a visible link was rescheduled while the route-tree response was still pending.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jul 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2465
npm i https://pkg.pr.new/vinext@2465

commit: 32da0ea

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@ask-bonk ask-bonk Bot left a comment

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.

Review: segment-cache prefetch scheduling

Reviewed the client-side scheduler in link.tsx and the two new tests. Overall the change is well-structured and the phase/priority model is coherent. Tests pass and vp check is clean. A few observations, one of which is a real (latent) correctness concern.

1. Scheduler is not idempotent for in-flight tasks (latent double-scheduling)

processLinkPrefetchQueue removes a task from the queue before running it (removeLinkPrefetchTask sets task.queued = false), but the task object is still referenced by instance.scheduledTask. If scheduleSegmentCacheLinkPrefetch is called again while that task is in-flight (e.g. hover fires during a pending route-tree fetch — exactly the scenario in the second new test), pushLinkPrefetchTask re-queues the same task object (since queued === false), and the queue can invoke prefetchUrl for it a second time concurrently. scheduledLinkPrefetchInProgress then gets incremented twice for a single logical task, and two finally handlers run.

Today this is masked by dedup inside prefetchUrl (if (prefetched.has(cacheKey)) awaits the existing pending and returns), so no duplicate network request is issued and the tests pass. But the scheduler correctness relies on that downstream dedup rather than being idempotent itself. This is fragile:

  • scheduledLinkPrefetchInProgress can temporarily overcount, affecting bandwidth gating for unrelated tasks.
  • Any future change to the dedup behavior in prefetchUrl would surface a real double-fetch.

Suggestion: add an explicit "in-flight" guard on the task (e.g. a running flag set before prefetchUrl and cleared in finally) and skip pushLinkPrefetchTask/re-run while running, deferring the reschedule to the existing finally re-queue.

2. Hover re-schedule resets a completed task back to route-tree phase

In scheduleSegmentCacheLinkPrefetch, the reuse branch unconditionally sets task.phase = "route-tree" (line 858). After a link has fully completed both phases, instance.scheduledTask is never nulled and its phase stays "segment". A subsequent hover (scheduleSegmentCacheLinkPrefetch(instance, "high")) therefore resets the finished task to "route-tree" and re-queues it, re-running the route-tree phase before the segment phase.

As with (1), prefetchUrl dedup makes this a no-op on the wire, so it's not a functional bug — just a wasted scheduler cycle. But it diverges from the intent (a hover on an already-prefetched link should bump priority, not restart from the tree). Consider only resetting to "route-tree" when the prior task hadn't completed the tree phase yet. There's no test covering hover-after-full-completion, so this path is currently unverified.

3. /_page segment-prefetch value is a vinext invention (documented divergence — confirm intent)

Next.js uses /_tree for the route-tree request (matches this PR) but uses actual encoded segment paths for segment requests — there is no /_page key in Next.js. Since vinext keeps a unified route payload (per the comment at the fetch-promise gate) rather than true per-segment fetching, /_page is only used as a distinct cache-busting marker and the vinext server doesn't branch on it. That's an acceptable deliberate divergence, but per AGENTS.md it should be a documented decision. Recommend a short code comment noting /_page is a vinext-specific marker (not a Next.js segment key) so a future reader doesn't assume parity.

Minor / non-blocking

  • The new await entry?.pending at the end of the app-prefetch path now applies to all app modes, not just the scheduler. All non-scheduler callers use void prefetchUrl(...), so the later resolution is harmless — noting for the record.
  • hasLinkPrefetchBandwidth only batch-gates route-tree low-priority tasks against queued earlier batches; an in-flight earlier-batch task (removed from queue) does not gate later batches. Likely fine, but worth a sanity check against the Next.js scheduling contract if strict inter-batch ordering matters.

Verification

  • tests/link-navigation.test.ts — 56 passed
  • vp check packages/vinext/src/shims/link.tsx — clean (format, lint, types)

None of these are blocking; (1) and (2) are the ones worth addressing to make the scheduler self-consistent rather than relying on downstream dedup.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2465. Summary of findings:

Review complete

I reviewed the segment-cache prefetch scheduler in link.tsx and the two new tests. Tests pass (56/56) and vp check is clean. The change is well-structured, but I flagged three items:

1. Scheduler not idempotent for in-flight tasks (latent correctness issue) — When scheduleSegmentCacheLinkPrefetch fires while a task is already running (e.g. hover during a pending route-tree fetch — the exact second-test scenario), the same task object gets re-queued and can invoke prefetchUrl a second time concurrently, overcounting scheduledLinkPrefetchInProgress. It only works today because prefetchUrl dedups downstream. Suggested an explicit in-flight guard.

2. Hover resets a completed task back to route-tree phaseinstance.scheduledTask is never nulled after completion, so a later hover restarts from the tree phase. Masked by dedup, but diverges from intent and is untested (no hover-after-completion test).

3. /_page is a vinext-invented segment key — Next.js uses /_tree (matches) but real encoded segment paths, not /_page. It's an acceptable deliberate divergence given vinext's unified payload, but should carry a code comment per AGENTS.md.

Plus two minor non-blocking notes on the broadened await entry?.pending and inter-batch gating.

None are blocking; (1) and (2) are worth addressing so the scheduler is self-consistent rather than depending on prefetchUrl dedup.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx james-elicx force-pushed the codex/fix-segment-cache-prefetch-scheduling-28478866791 branch from 0333a84 to 09d0a64 Compare July 1, 2026 06:13
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: segment-cache prefetch scheduling

Re-reviewed the scheduler in link.tsx and the two new tests. Tests pass (56/56) and the phase/priority model is coherent. I confirmed the two prior findings and found one additional behavioral regression that affects all App Router apps, not just cache-components apps. Details below.

1. Concurrent re-run of an in-flight task is a real double-fetch, not just an overcount

The prior review flagged this as "latent, masked by prefetchUrl dedup." After tracing the await ordering, the dedup does not reliably mask it, so this is a genuine duplicate network request under the exact scenario the second new test exercises (hover during a pending route-tree fetch).

Sequence:

  1. processLinkPrefetchQueue dequeues a task (removeLinkPrefetchTaskqueued = false), sets scheduledLinkPrefetchInProgress++, and calls prefetchUrl (route-tree phase, still in-flight). instance.scheduledTask still points at this task.
  2. A hover fires → scheduleSegmentCacheLinkPrefetch(instance, "high"). Since instance.scheduledTask !== null, the reuse branch runs task.phase = "route-tree" and pushLinkPrefetchTask(task) — re-queuing the same task object because queued is false.
  3. The queue runs it a second time concurrently → scheduledLinkPrefetchInProgress++ again and a second prefetchUrl call.

Why dedup does not save us: inside runPrefetch, the guard prefetched.add(cacheKey) (link.tsx:548) is reached only after await Promise.all([...dynamic imports]) and await createRscRequestUrl(...) (link.tsx:525, which is async). Both concurrent invocations can pass if (prefetched.has(cacheKey)) (link.tsx:528) before either reaches line 548, so both issue a real fetch. The prefetched set is not a synchronous mutex across the await boundary.

Consequences:

  • Duplicate route-tree network request for the same link.
  • scheduledLinkPrefetchInProgress is incremented twice and decremented twice; if the two finally handlers interleave with a cancel/visibility change it can drift (including negative), loosening the bandwidth gate for unrelated tasks.

Fix: add an explicit in-flight guard on the task (e.g. a running flag set before prefetchUrl and cleared in the finally), and in both scheduleSegmentCacheLinkPrefetch and pushLinkPrefetchTask refuse to re-queue a running task — defer the reschedule to the existing finally re-queue. Relying on downstream dedup that sits behind two awaits is not safe.

2. App Router low-priority viewport prefetches lost requestIdleCallback deferral (regression for non-cache-components apps)

This is the item I want to flag most strongly because it changes behavior for every App Router app, not just cache-components apps.

On main, all low-priority prefetches (App and Pages) were deferred via requestIdleCallback (or a setTimeout(fn, 100) fallback) "to avoid blocking the main thread during initial page load" — that is still what the docstring at link.tsx:404-407 describes.

This PR changes the gate to:

if (priority === "high" || hasAppNavigationRuntime()) {
  return runPrefetch();
}

hasAppNavigationRuntime() is true for any App Router app. So a regular App Router app with cache components off now runs its low-priority viewport prefetches immediately instead of during idle time. The segment-cache scheduler (usesSegmentCachePrefetchScheduler) only kicks in when __NEXT_CACHE_COMPONENTS === "true"; for everyone else, setVisibleLinkPrefetch still calls void prefetchUrl(instance.href, instance.mode, "low", ...) directly (link.tsx:927), which now bypasses idle scheduling entirely.

If the intent is to only start immediately when the scheduler is orchestrating timing, this gate is too broad. Consider narrowing it so non-scheduler App Router low-priority prefetches keep the idle deferral (e.g. gate on the scheduler/priority === "high" rather than on hasAppNavigationRuntime()), or confirm and document that removing idle deferral for all App Router viewport prefetches is intentional and matches Next.js. This should be a deliberate, documented decision per AGENTS.md rather than a side effect of the scheduler change.

3. Hover resets a completed task back to route-tree phase (untested path)

scheduleSegmentCacheLinkPrefetch's reuse branch unconditionally sets task.phase = "route-tree" (link.tsx:858). After a link fully completes both phases, instance.scheduledTask is never nulled and its phase stays "segment". A later hover therefore restarts the finished task from the tree phase and re-queues it. Combined with finding #1's await-window, this is not merely a wasted cycle — it can also re-issue the route-tree fetch. Only reset to "route-tree" when the prior task had not yet completed the tree phase, and null instance.scheduledTask (or mark it done) on completion. No test covers hover-after-full-completion.

4. /_page is a vinext-specific marker — needs a code comment

Next.js uses /_tree for the route-tree request (this PR matches) but real encoded segment paths for segment requests; there is no /_page key in Next.js. Since vinext keeps a unified route payload, /_page (link.tsx:521) is only a cache-busting marker and the server doesn't branch on it. That's an acceptable deliberate divergence, but per AGENTS.md it should carry a short code comment noting it is a vinext-specific marker, not a Next.js segment key.

Minor / non-blocking

  • Bot handling: bots are correctly skipped for network fetches (link.tsx:449, and prefetchOnIntent at 1298), but the scheduler still enqueues tasks and increments scheduledLinkPrefetchInProgress for bot user agents before prefetchUrl no-ops. Harmless, but the scheduler could short-circuit bot instances up front.
  • hasLinkPrefetchBandwidth only batch-gates low-priority route-tree tasks against queued earlier batches; an in-flight earlier-batch task (already removed from the queue) does not gate later batches. Likely fine, but worth a sanity check against the Next.js scheduling contract if strict inter-batch ordering matters.

Verification

  • tests/link-navigation.test.ts — 56 passed
  • Reviewed against main diff to confirm the idle-scheduling change in #2 is a real behavioral delta.

Findings #1 and #2 are the ones I'd resolve before merge: #1 is a reproducible double-fetch under the PR's own second test scenario, and #2 changes main-thread scheduling for all App Router apps. #3 and #4 are cheap follow-ups worth doing in the same PR.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2465.

Review summary

I re-reviewed the segment-cache prefetch scheduler (link.tsx) and the two new tests. Tests pass (56/56). I confirmed the prior findings and found one additional regression. Key findings:

1. Concurrent re-run of an in-flight task is a real double-fetch (blocking) — The prior review called this "latent, masked by dedup," but the dedup does not reliably mask it. The prefetched.add(cacheKey) guard sits behind two awaits (dynamic imports + async createRscRequestUrl), so two concurrent runs of the same task both pass the prefetched.has() check before either adds the key → genuine duplicate network fetch. Triggered by the PR's own second test scenario (hover during pending route-tree fetch). Needs an explicit in-flight guard.

2. App Router lost requestIdleCallback deferral for all apps (regression) — The new gate priority === "high" || hasAppNavigationRuntime() makes every App Router app run low-priority viewport prefetches immediately, not just cache-components apps. Regular App Router apps (cache components off) lose the idle-time deferral that main had. The gate is too broad; should be narrowed or documented as intentional.

3. Hover resets a completed task to route-tree phase (untested)instance.scheduledTask is never nulled on completion, so a later hover restarts from the tree phase; combined with #1 this can re-issue the route-tree fetch.

4. /_page is a vinext-specific marker — Needs a code comment per AGENTS.md (Next.js has no /_page segment key).

Plus two minor non-blocking notes (bot enqueue waste, inter-batch gating). Findings #1 and #2 are worth resolving before merge.

github run

@james-elicx james-elicx force-pushed the codex/fix-segment-cache-prefetch-scheduling-28478866791 branch from 09d0a64 to 6b34a58 Compare July 1, 2026 06:22
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: segment-cache prefetch scheduling (re-review)

Re-reviewed link.tsx and the four new tests. The prior findings all look addressed:

  • #1 in-flight double-runpushLinkPrefetchTask now guards !task.running && !task.completed (link.tsx:777) and scheduleSegmentCacheLinkPrefetch returns early when task.running (link.tsx:863). The running flag is set synchronously before prefetchUrl and cleared in finally (link.tsx:909/922), so the same task can no longer be re-queued concurrently.
  • #2 idle-deferral regression — the gate is now scheduler-specific (priority === "high" || mode === "route-tree" || mode === "segment", link.tsx:674) instead of hasAppNavigationRuntime(), and there's a test asserting non-cache-components App viewport prefetches stay deferred until idle.
  • #3 completed-task resetscheduleSegmentCacheLinkPrefetch returns early on task.completed (link.tsx:869), with a "does not restart completed cacheComponents prefetches on hover" test.
  • #4 /_page marker — now carries a code comment (link.tsx:521-523).

Tests pass (40 passed / 18 skipped) and the targeted suite is green. One new correctness issue below.

1. canceled is never cleared when a link scrolls out then back in while its fetch is in flight (segment phase is dropped)

cancelScheduledSegmentCacheLinkPrefetch sets task.canceled = true (link.tsx:889) but does not stop an already-running phase. If the link returns to the viewport while that phase is still in flight, scheduleSegmentCacheLinkPrefetch hits the task.running early-return branch (link.tsx:863-868), which bumps priority and returns without resetting task.canceled. The canceled = false reset only happens in the not-running/not-completed branch (link.tsx:874), which this path skips.

Sequence:

  1. Link visible → route-tree phase starts → running = true, canceled = false.
  2. Link scrolls out → setVisibleLinkPrefetch(instance, false)cancelScheduledSegmentCacheLinkPrefetch sets canceled = true, isVisible = false. Fetch still in flight.
  3. Link scrolls back in while route-tree is still pendingsetVisibleLinkPrefetch(instance, true) sets isVisible = truescheduleSegmentCacheLinkPrefetch(instance, "low")task.running is true → bumps priority, returns early, canceled stays true.
  4. Route-tree fetch resolves → finally (link.tsx:924) checks !currentTask.canceled — false — so it neither advances to segment nor re-queues. Task is left { canceled: true, running: false, completed: false, queued: false }.

Result: the link is visible with a completed route-tree fetch but the segment phase never runs. The task is orphaned until an intent event (hover/touch) or a pingVisibleLinks call comes in — both take the not-running branch and reset canceled. Plain visibility flicker during the route-tree fetch permanently drops the segment prefetch for that link.

Fix: clear canceled when a still-running task is re-scheduled for a now-visible instance. In the task.running branch, add task.canceled = false (the running phase then re-queues itself in finally since !canceled && isVisible). Alternatively, in the finally re-queue check, treat "canceled but instance is visible again" as a reschedule rather than a drop. A test toggling visibility off→on while the route-tree fetch is pending (the stubIntersectionObserver harness already supports dispatchIntersectingEntry(anchor, false)) would cover this.

Minor / non-blocking

  • mostRecentIntentPrefetchTask keeps pointing at a task after it completes (only cleared on cancel, link.tsx:891-893). A later high-priority intent on a different link then sets the completed task's priority = "low" via trackIntentLinkPrefetchTask — harmless since the task is done, but the stale reference lingers until replaced or canceled. Consider nulling it when a task completes (link.tsx:928) for symmetry.

Verification

  • npx vitest run tests/link-navigation.test.ts -t "prefetch scheduling" — 40 passed / 18 skipped.

Finding #1 is the only one worth addressing before merge; it's a real (if narrow) dropped-prefetch under visibility flicker during an in-flight route-tree fetch. The prior review items are resolved.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2465.

Review summary

I re-reviewed the segment-cache prefetch scheduler in link.tsx and the four new tests. Tests pass (40 passed / 18 skipped).

The four prior findings are all resolved:

  1. In-flight double-run — fixed via running/completed guards in pushLinkPrefetchTask (link.tsx:777) and scheduleSegmentCacheLinkPrefetch (link.tsx:863).
  2. Idle-deferral regression — the gate is now scheduler-specific (mode === "route-tree" || mode === "segment", link.tsx:674) with a regression test.
  3. Completed-task reset on hover — early return on task.completed (link.tsx:869) plus a test.
  4. /_page marker — now has an explanatory comment (link.tsx:521-523).

One new correctness issue (worth fixing before merge):

  • canceled is never cleared when a link scrolls out and back in while its fetch is still in flight. The task.running early-return path (link.tsx:863-868) bumps priority but skips the canceled = false reset (link.tsx:874). When the route-tree fetch then resolves, the finally block sees canceled === true and neither advances to the segment phase nor re-queues — so the segment prefetch is silently dropped until a hover/ping recovers it. It's narrow (requires a visibility flicker during the in-flight route-tree fetch), but it's a real dropped-prefetch. Suggested fix: reset canceled = false in the running branch (or treat "canceled but visible again" as a reschedule in the finally), and add a visibility off→on-during-pending test.

Plus one minor non-blocking nit: mostRecentIntentPrefetchTask isn't nulled on task completion (only on cancel), leaving a stale reference.

github run

@james-elicx james-elicx force-pushed the codex/fix-segment-cache-prefetch-scheduling-28478866791 branch from 6b34a58 to 68ea4fe Compare July 1, 2026 06:27
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: segment-cache prefetch scheduling (re-review)

Re-reviewed link.tsx and the five new scheduler tests against the current tip (68ea4fe). vp check is clean and the full tests/link-navigation.test.ts suite passes (59/59). I traced the scheduler state machine carefully; all four prior findings are now resolved and correctly tested, and I did not find a new blocking issue. Details below.

Prior findings — all resolved

  1. In-flight double-run — resolved. pushLinkPrefetchTask guards !task.queued && !task.running && !task.completed (link.tsx:777), processLinkPrefetchQueue skips task.running (link.tsx:903), and scheduleSegmentCacheLinkPrefetch returns early in the task.running branch (link.tsx:863-869). running is set synchronously before prefetchUrl (link.tsx:910) and cleared in finally (link.tsx:923), so a concurrent reschedule can no longer re-enqueue the same task. scheduledLinkPrefetchInProgress is incremented once (link.tsx:909) and decremented once in the always-running finally (link.tsx:924) — no counter drift or leak.

  2. Idle-deferral regression for non-cacheComponents apps — resolved. The gate is now scheduler-specific: priority === "high" || mode === "route-tree" || mode === "segment" (link.tsx:674), replacing the too-broad hasAppNavigationRuntime(). The new test "keeps non-cacheComponents App viewport prefetches deferred until idle" asserts a regular App Router viewport prefetch goes through requestIdleCallback and stays priority: "low".

  3. Completed-task reset on hover — resolved. scheduleSegmentCacheLinkPrefetch returns early on task.completed (link.tsx:870-872), covered by "does not restart completed cacheComponents prefetches on hover" (hover + pingVisibleLinks, asserts fetch count stays at 2). mostRecentIntentPrefetchTask is also nulled on segment completion (link.tsx:930-932), closing the prior stale-reference nit.

  4. canceled never cleared on visibility flicker during an in-flight fetch (last review's #1) — resolved. The task.running branch now resets task.canceled = false (link.tsx:864), and the new test "continues cacheComponents segment prefetches when visibility returns during route-tree fetch" exercises exactly the off→on-during-pending sequence and asserts the segment phase still runs (2 fetches, /_page marker). I verified this fix also covers the same flicker during the segment phase.

  5. /_page marker — now carries the required explanatory code comment (link.tsx:521-523) per AGENTS.md.

Verified as correct during trace

  • Phase advancement / re-queue: after a route-tree fetch resolves, the finally sets phase = "segment" and re-queues exactly once (link.tsx:925-927); the pushLinkPrefetchTask guard prevents duplicates.
  • Intent priority handoff: trackIntentLinkPrefetchTask demotes the previous most-recent intent task to "low" and the running task inherits the current priority for its next phase — matches "only the latest hover stays high priority."
  • Counter balance under cancel-while-running: cancel sets canceled = true but does not touch the counter; the finally still decrements. No drift.

Minor / non-blocking (unchanged from prior reviews)

  • Bot tasks still enqueue in the scheduler. The bot guard exists on the intent path (link.tsx:1324) and inside runPrefetch (link.tsx:449), so bots issue no network fetch — correct. But the viewport path (setVisibleLinkPrefetchscheduleSegmentCacheLinkPrefetch) still creates a task and briefly increments scheduledLinkPrefetchInProgress before prefetchUrl no-ops at link.tsx:449. Harmless (self-corrects on the next finally), but the scheduler could short-circuit bot instances up front to avoid the churn.
  • hasLinkPrefetchBandwidth inter-batch gating only considers queued earlier-batch route-tree tasks (link.tsx:810-819); an already-running earlier-batch task does not gate later batches. Likely fine given the concurrency cap, but worth a sanity check against the Next.js scheduling contract if strict inter-batch ordering ever matters.

Verification

  • vp check packages/vinext/src/shims/link.tsx — clean (format, lint, types)
  • tests/link-navigation.test.ts — 59 passed

No blocking issues. The scheduler is now self-consistent and no longer relies on downstream prefetchUrl dedup for correctness. The two minor notes are optional follow-ups.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2465.

Review summary

Re-reviewed link.tsx and the five new scheduler tests against the current tip (68ea4fe). vp check is clean and all 59 tests in tests/link-navigation.test.ts pass. I traced the scheduler state machine carefully.

All four prior findings are resolved and correctly tested:

  1. In-flight double-run — fixed via running/completed/queued guards in pushLinkPrefetchTask (link.tsx:777), the queue processor (link.tsx:903), and the task.running early-return (link.tsx:863-869). Counter accounting is balanced (one increment, one always-firing finally decrement).
  2. Idle-deferral regression — the gate is now scheduler-specific (mode === "route-tree" || mode === "segment", link.tsx:674) with a regression test for non-cacheComponents apps.
  3. Completed-task reset on hover — early return on task.completed (link.tsx:870) plus a test; mostRecentIntentPrefetchTask also nulled on completion.
  4. canceled never cleared on visibility flicker during in-flight fetch (last review's Virtual module imports break esbuild dependency optimization when vinext is installed from npm #1) — fixed via task.canceled = false in the running branch (link.tsx:864), with a dedicated off→on-during-pending test.

Plus the /_page marker now carries the required explanatory comment.

No blocking issues. The scheduler is now self-consistent and no longer relies on downstream prefetchUrl dedup for correctness. I left two optional, non-blocking notes: bot instances still briefly enqueue in the viewport scheduler before the no-op fetch, and hasLinkPrefetchBandwidth only gates against queued (not running) earlier-batch tasks.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

This likely also covers the current run 28478866791/job 84413308650 failures in test/e2e/app-dir/segment-cache/vary-params-base-dynamic/vary-params-base-dynamic.test.ts.

That suite is currently failing before vary-param validation: revealing the links starts no Next-Router-Segment-Prefetch request (Timed out waiting for a request to be initiated) or records zero segment-prefetch responses. Since this PR adds the cacheComponents route-tree -> segment-prefetch scheduling and segment-prefetch headers, please include or rerun that target after this lands. Any remaining [teamSlug] / [project] payload leakage after requests are emitted would be a separate follow-up.

@james-elicx james-elicx marked this pull request as ready for review July 1, 2026 06:39

@NathanDrake2406 NathanDrake2406 left a comment

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.

Requesting changes on maintainability/structure. I agree the current behavior is much better than the earlier iterations and CI is green, but this implementation still lowers the architecture bar in a way we should fix before merging.

1. link.tsx is becoming the Segment Cache scheduler owner

packages/vinext/src/shims/link.tsx already owned a lot of public <Link> behavior, and this PR adds a full mutable scheduler directly into it: task lifecycle, batching, queue insertion/removal, bandwidth gates, phase transitions, cancellation, and intent-priority tracking (LinkPrefetchTask plus scheduleSegmentCacheLinkPrefetch / processLinkPrefetchQueue). The file grows from 1450 to 1718 lines.

This is not just a line-count concern. The new code turns a shim that should wire visibility/intent events into the canonical owner of a client-side scheduler. Next keeps this boundary cleaner: client/components/links.ts owns link visibility/intent wiring, while client/components/segment-cache/scheduler.ts owns the scheduler. Vinext does not need to copy that exact implementation, but we should keep the same ownership split.

Can we decompose this first? A good move is to extract a focused internal scheduler module, e.g. shims/internal/link-segment-prefetch-scheduler.ts, with a small API along these lines:

  • create/register a task for a visible link
  • cancel a link task
  • reschedule with low/high priority
  • ping visible links through the existing runtime hook

Then link.tsx only creates the instance, handles observer/hover/unmount, and delegates. That makes the state machine testable without rendering a full <Link> and keeps future Segment Cache parity work out of the shim.

2. prefetchUrl now has a hidden awaitable scheduler contract

prefetchUrl now returns Promise<void> | undefined, and whether callers can rely on the promise depends on the priority/mode combination. The scheduler depends on "route-tree" and "segment" bypassing idle scheduling and awaiting the prefetch-cache entry, while normal viewport callers still treat prefetchUrl as fire-and-forget. That mixes two contracts in one helper.

This is a boundary smell. The public-ish link prefetch wrapper should stay fire-and-forget/idle-aware, while the scheduler should call an explicit promise-returning phase runner, for example:

  • prefetchUrl(...): existing idle/fire-and-forget behavior for Link callers
  • runAppPrefetchPhase({ href, phase, priority, pagesRouteHref }): immediate, awaitable scheduler phase contract

That would remove the mode-specific return-value trap and make the scheduler's dependency explicit instead of encoded through mode === "route-tree" || mode === "segment" inside prefetchUrl.

3. The task lifecycle is encoded as overlapping booleans

LinkPrefetchTask has canceled, completed, queued, and running, and the behavior depends on specific combinations across schedule/cancel/process/finally paths. The previous iterations already found bugs in these combinations: re-running in-flight tasks, completed task resets, and canceled-running visibility flicker. Those bugs are good evidence that the lifecycle is easy to get wrong.

If the scheduler is extracted, please also make this lifecycle more explicit there. A small status model or state-machine-style helper would be easier to reason about than open-coded boolean combinations in the Link shim. At minimum, the invariants should live inside the scheduler module so the rest of link.tsx cannot accidentally depend on or mutate them.

Suggested shape

Keep the behavior and the tests, but move the new scheduler out of link.tsx and give it an explicit promise-returning app-prefetch phase boundary. That should let this PR preserve the parity fix while avoiding another large, stateful subsystem being bolted into an already-large shim.

@james-elicx

Copy link
Copy Markdown
Member Author

Targeted proof for run 28478866791 / job 84413308650: this update covers all 15 original failing rows in test/e2e/app-dir/segment-cache/vary-params-base-dynamic/vary-params-base-dynamic.test.ts.

Command:

REPO="$(pwd)" NEXTJS_DIR="/Users/jamesanderson/Developer/vinext/.nextjs-ref" NEXT_TEST_CONCURRENCY=1 ./scripts/run-targeted-nextjs-e2e.sh test/e2e/app-dir/segment-cache/vary-params-base-dynamic/vary-params-base-dynamic.test.ts 2>&1 | tee /tmp/vinext-vary-params-base-dynamic-28478866791-final-proof.log

Result: PASS Turbopack, 15 passed / 15 total, retry 0/0, exiting with code 0.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: segment-cache prefetch scheduling + concrete segment identity

Reviewed the full diff at tip (b78b5af), not just link.tsx. The PR has grown well past the original scheduler change — it now also adds concrete segment-prefetch cache identity, cacheLife stale propagation, a foreground stale-revalidate path, and an unrelated RSC reference-validation normalizer plugin. The scheduler state machine in link.tsx looks solid (all prior review findings are resolved and tests/link-navigation.test.ts passes 61/61). But there is a blocking problem: the PR does not pass vp check, so the CI Check job will fail.

1. BLOCKING — vp check fails with 3 type errors (CI Check job will be red)

npx vitest run passes because Vitest does not type-check, but vp check (which CI runs) reports 3 errors, all introduced by this PR:

a) packages/vinext/src/server/app-page-render.ts:367metadata.sourcePage is possibly null (TS18047)

const metadata = AppElementsWire.readMetadata(input.element);
if (metadata.sourcePage.includes("[")) {   // sourcePage: string | null
  return input.layoutFlags;
}

readMetadata().sourcePage is typed string | null (app-elements-wire.ts:205, 862). This dereference has no null guard. Beyond the type error, the runtime behavior for a null sourcePage needs a deliberate decision: the guard exists to bail out of materialization when the source page is itself dynamic. If sourcePage is null you can't tell, so decide whether null should bail (safest — metadata.sourcePage?.includes("[") ?? true) or proceed with materialization (?? false), and encode that intent explicitly rather than crashing.

b) & c) tests/app-page-render.test.ts:1557 and :1601 — element object not assignable to Record<string, ReactNode> (TS2322)

Both new tests spread AppElementsWire.createMetadataEntries({ ..., sourcePage: "…/page" }) into the element object. Because createMetadataEntries includes the __sourcePage key (typed string | null) when sourcePage is passed, the resulting object literal is no longer assignable to Record<string, ReactNode>. The existing helper usage at line 235 doesn't hit this because it omits sourcePage. These need a cast (as Record<string, ReactNode>) or an adjusted element type in createRscOptions.

Please run vp check locally before pushing — the validation section in the PR description only lists vp check packages/vinext/src/shims/link.tsx tests/link-navigation.test.ts, which is why these three (in app-page-render.ts and app-page-render.test.ts) were missed.

2. Non-blocking — foreground stale-revalidate can render the page twice

In readAppPageCacheResponse (app-page-cache.ts:412-497), when shouldForegroundRevalidateStaleAppPage is true, renderFreshPageForCache() is awaited (line 414) and used to build the response. But if buildAppPageCachedResponse returns null (RSC request with no rscData, or HTML request where the fresh render produced empty HTML — e.g. a render that resolves to redirect/notFound), control falls through the if (response) block at 486 into the legacy scheduleBackgroundRegeneration path (line 506), which calls renderFreshPageForCache() again. That is a redundant second full render for the same request. It's not a correctness bug (the second render is background/deduped), but for stale === 0 routes it defeats the point of the foreground revalidate. Consider bailing to a normal fresh-render fall-through (or reusing the already-rendered artifact) when the foreground response comes back null, instead of re-entering the background regen path.

3. Non-blocking — scope creep: unrelated RSC reference-validation normalizer

packages/vinext/src/plugins/rsc-reference-validation-normalizer.ts (+74) and its wiring in index.ts are a dev-only fix for @vitejs/plugin-rsc client/server reference-key encoding mismatches. That's a legitimate change, but it is unrelated to segment-cache prefetch scheduling and has no dedicated test. Per the repo's small-PR guidance, this would be cleaner as its own PR with its own test; at minimum call it out in the PR description so it isn't lost in the "segment-cache" framing. (Same applies to the cacheLife stale plumbing across cache-handler.ts, cache-runtime.ts, isr-cache.ts, seed-cache.ts, prerender.ts, app-page-response.ts — it's coherent and tested, but it's a second feature riding along.)

Confirmed good

  • Scheduler self-consistency — the running/completed/queued/canceled guards in pushLinkPrefetchTask, processLinkPrefetchQueue, and scheduleSegmentCacheLinkPrefetch are internally consistent; scheduledLinkPrefetchInProgress is balanced (one increment, one always-firing finally decrement). The prior double-fetch, completed-reset, and visibility-flicker findings are all fixed and tested.
  • Idle-deferral gate (link.tsx:688-695) — I traced the main baseline: main ran all App Router prefetches immediately (priority === "high" || hasAppNavigationRuntime()). The new gate only adds idle-deferral for the explicit __NEXT_CACHE_COMPONENTS === "false" case, so the earlier reviews' "idle regression" concern does not apply to the current code — this is a narrowing, not a regression.
  • /_page marker — carries the required explanatory comment (link.tsx) noting it is a vinext-specific marker, not a Next.js segment key. Segment-prefetch fresh renders are intentionally never teed into the cache (app-page-render.ts:831), with a test to lock that in.
  • Segment cache key isolation (appIsrRscKey) and materializeDynamicSegmentsInText catch-all/optional-catch-all regex both look correct and are covered by the ported vary-params tests.

Verification

  • vp checkFAILS (3 type errors above)
  • tests/app-page-cache.test.ts, tests/isr-cache.test.ts, tests/link-navigation.test.ts — 161 passed
  • tests/app-page-render.test.ts, tests/app-page-element-builder.test.ts, tests/app-page-route-wiring.test.ts, tests/seed-cache.test.ts — 169 passed

Finding #1 must be fixed before merge (it will block the Check job). #2 and #3 are worth addressing but not blocking.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2465.

Review summary

I reviewed the full diff at tip (b78b5af), not just link.tsx. The scheduler state machine is now solid (all prior findings resolved, link-navigation.test.ts passes 61/61), but I found one blocking issue.

1. BLOCKING — vp check fails with 3 type errors (CI Check job will be red)

npx vitest run passes because Vitest doesn't type-check, but vp check (which CI runs) reports 3 errors, all new to this PR:

  • app-page-render.ts:367metadata.sourcePage.includes("[") dereferences a string | null value with no null guard (TS18047). Also needs a deliberate decision on null-source-page behavior.
  • tests/app-page-render.test.ts:1557 and :1601 — the two new tests spread createMetadataEntries({ …, sourcePage }) (which adds a string | null key) into an element: Record<string, ReactNode>, breaking assignability (TS2322).

The PR's listed validation only ran vp check on link.tsx + link-navigation.test.ts, which is why the app-page-render errors slipped through.

2. Non-blocking — foreground stale-revalidate can double-render. When buildAppPageCachedResponse returns null after a foreground render, control falls through to scheduleBackgroundRegeneration, which renders the page a second time.

3. Non-blocking — scope creep. The RSC reference-validation normalizer plugin and the cacheLife stale plumbing are coherent but unrelated to "segment-cache prefetch scheduling"; worth splitting or at least calling out in the description.

Confirmed good: scheduler counter balance, the /_page marker comment, segment-key isolation, and — importantly — the earlier reviews' "idle-deferral regression" does not apply to the current code (I traced main; the new gate is a narrowing, not a regression).

Finding #1 must be fixed before merge.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

Latest-tip proof after the app-page-render type-check repair: the exact target still passes all 15 original rows from run 28478866791 / job 84413308650.

Command:

REPO="$(pwd)" NEXTJS_DIR="/Users/jamesanderson/Developer/vinext/.nextjs-ref" NEXT_TEST_CONCURRENCY=1 ./scripts/run-targeted-nextjs-e2e.sh test/e2e/app-dir/segment-cache/vary-params-base-dynamic/vary-params-base-dynamic.test.ts 2>&1 | tee /tmp/vinext-vary-params-base-dynamic-28478866791-final-proof-after-type-fix.log

Result: PASS Turbopack, 15 passed / 15 total, retry 0/0, exiting with code 0.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

1 similar comment
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: segment-cache prefetch scheduling (re-review at tip 9744631)

Re-reviewed the full diff, not just link.tsx. The prior blocking issue is resolved and I found no new blocking issues. The scheduler state machine is self-consistent and well-tested; the stale/segment-key plumbing is coherent. Two carry-over non-blocking notes and one pending structural review remain.

Prior blocking issue — RESOLVED

The last review flagged 3 type errors failing vp check (app-page-render.ts:367 TS18047 + two TS2322 in app-page-render.test.ts). Commit 9744631 fixes them:

  • metadata.sourcePage?.includes("[") guards the null deref.
  • The two tests cast the element literal as unknown as Record<string, ReactNode>.

I ran the full vp check — clean (2359 formatted, 1058 lint/type-checked, no errors). CI Check job should now be green.

Verified good

  • vp check full run — clean.
  • Test suiteslink-navigation.test.ts 61/61; app-page-cache / app-page-render / isr-cache / seed-cache / app-page-element-builder / app-page-route-wiring 269/269.
  • Scheduler self-consistency — the queued/running/completed/canceled guards in pushLinkPrefetchTask (link.tsx:828), processLinkPrefetchQueue (link.tsx:976), and scheduleSegmentCacheLinkPrefetch (link.tsx:935-953) are internally consistent. scheduledLinkPrefetchInProgress is balanced: one increment (link.tsx:982) and one always-firing finally decrement (link.tsx:998). A running task is removed from the queue before it runs (link.tsx:975) and only re-queued after running=false, so it cannot be double-picked.
  • All five prior scheduler findings (double-run, idle-deferral gate, completed-reset, visibility-flicker canceled reset, /_page marker comment) are fixed and each has a dedicated test (link.tsx:1326–1594 in the test file).
  • Segment-key isolation is safe. appIsrRscKey now folds segmentPrefetchPath into the cache variant (isr-cache.ts:407-425), and segment-prefetch fresh renders are intentionally never teed into the cache (shouldCaptureRscForCacheMetadata gates on !segmentPrefetchPath, app-page-render.ts:831). Because segment-prefetch responses are never written, the finalizer's 4-arg isrRscKey call (finalizer.ts:153, no segment path) can never collide with the 5-arg read key (app-page-cache.ts:340). Consistent.
  • cacheLife stale plumbing is threaded end-to-end (prerender → manifest → seed → MemoryCacheHandlerisrSet → response header) with backward-compatible optional args (setAppPageCacheEntry only forwards staleSeconds when defined). Covered by the new isr-cache, seed-cache, and app-page-cache tests.
  • Segment-prefetch headers — both phases set Next-Router-Prefetch: 1 (link.tsx:518/521) and the /_page marker carries the required vinext-divergence comment (link.tsx:522-524) per AGENTS.md.

Non-blocking

1. null sourcePage now falls through to materialization — undertested deliberate decision. materializeConcreteLayoutFlags runs for every app render (app-page-render.ts:718), not just segment prefetches. With sourcePage?.includes("[") a null source page now proceeds to materialize dynamic layout-flag IDs. This is mostly safe because resolveAppPageConcreteRouteSegments keeps the symbolic segment when a param is missing (?? segment, app-page-segment-state.ts:142/148), so absent params can't be mis-baked. But the null-source-page + dynamic-layout-flags path has no test, and the choice of "materialize on null" vs "bail on null" (?? true) is a deliberate behavioral decision. Please add a focused test pinning the null-source-page behavior so a future refactor doesn't silently flip it.

2. Foreground stale-revalidate can render twice (carried over, still present). In readAppPageCacheResponse (app-page-cache.ts:412), when shouldForegroundRevalidateStaleAppPage is true, renderFreshPageForCache() is awaited (line 414). If buildAppPageCachedResponse returns null (RSC request with no rscData, or empty HTML from a redirect/notFound render), the if (response) at line 486 is skipped and control falls through to scheduleBackgroundRegeneration (line 506), which calls renderFreshPageForCache() a second time. Not a correctness bug (the second render is background/deduped) but it defeats the point of the foreground revalidate for stale === 0 routes. Consider bailing to a normal fresh-render fall-through (or reusing the artifact) when the foreground response is null.

3. Scope creep (carried over). The RSC reference-validation normalizer plugin (plugins/rsc-reference-validation-normalizer.ts, +74, dev-only, no dedicated test) and the cacheLife stale propagation are unrelated to "segment-cache prefetch scheduling." Both are coherent, but per the repo's small-PR guidance they'd be cleaner as separate PRs; at minimum call them out in the PR description. Minor robustness note on the normalizer: it locates the RSC plugin by the hard-coded name "rsc:minimal" (line 51); if that name changes upstream it silently no-ops (graceful, but a one-line comment or a warn-on-missing would help future debugging).

Outstanding structural review

@NathanDrake2406's "changes requested" review (maintainability — extract the scheduler out of link.tsx, give prefetchUrl an explicit promise-returning phase contract instead of the mode-dependent return-value trap, and model the task lifecycle explicitly rather than via overlapping booleans) is still unaddressed: link.tsx remains 1797 lines with the full scheduler inline, and prefetchUrl still returns Promise<void> | undefined with behavior keyed on mode === "route-tree" || "segment". That review is a merge blocker until resolved or explicitly waived by the maintainer.

Summary

No new blocking issues; the prior vp check blocker is fixed. Behavior looks correct and is well-tested. Recommend addressing note #1 (test the null-source-page decision) in this PR, and resolving the pending structural review before merge.

@james-elicx james-elicx force-pushed the codex/fix-segment-cache-prefetch-scheduling-28478866791 branch from 4c23523 to f584994 Compare July 1, 2026 13:48
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: segment-cache prefetch scheduling (re-review at tip f584994)

Re-reviewed the full diff at the current tip. The only change since the last reviewed head (4c23523) is commit f584994 ("preserve immediate app viewport prefetches"), which rewrites the idle-deferral gate in prefetchUrl. No blocking issues. vp type-check is clean (tsc --noEmit -p packages/vinext/tsconfig.json → exit 0) and the relevant suites pass (link-navigation 61/61, link-segment-prefetch-scheduler 5/5, app-page-cache/render/isr/seed 181/181).

The new gate (f584994) — verify the intended parity, add a comment

The gate is now:

if (
  priority === "high" ||
  mode === "route-tree" ||
  mode === "segment" ||
  (hasAppNavigationRuntime() && String(process.env.__NEXT_CACHE_COMPONENTS) !== "false")
) {
  return runPrefetch();
}

I traced the full history of this line to be sure what the net effect vs origin/main is, because earlier rounds went back and forth on whether the idle change is a regression:

  • main / 0e63ab0: priority === "high" || hasAppNavigationRuntime()every App Router prefetch (any mode/priority) ran immediately; only Pages low-priority prefetches were idle-deferred.
  • bb6a17c (mid-PR): dropped hasAppNavigationRuntime() entirely → all App Router auto/low viewport prefetches started deferring to idle. This was the real behavioral delta earlier reviews called a "regression."
  • f584994 (current): re-adds hasAppNavigationRuntime() && __NEXT_CACHE_COMPONENTS !== "false".

Net result at this tip, in a production build (where process.env.__NEXT_CACHE_COMPONENTS is DefinePlugin-inlined as a boolean per index.ts:1882-1884, so String(false) === "false"):

App type main this PR
cacheComponents on immediate immediate
cacheComponents off (default) immediate idle-deferred

So a plain App Router app with cache components off — the common case — still has its low-priority viewport prefetches moved from immediate to requestIdleCallback, which is a behavior change from main. The new test at link-navigation.test.ts:1542 codifies this as intended (__NEXT_CACHE_COMPONENTS = "false" → deferred until idle). That may well be more Next.js-aligned (App Router does schedule prefetches through idle-time), but per AGENTS.md this should be a deliberate, documented decision rather than an artifact of iterating on the gate. Two asks:

  1. Confirm this against Next.js and state the intent — is deferring cacheComponents-off App Router viewport prefetches to idle the desired parity, or should the common case stay immediate as on main? The .nextjs-ref clone wasn't present in this environment so I couldn't diff their scheduler directly.
  2. Add a short code comment on the gate explaining the __NEXT_CACHE_COMPONENTS !== "false" clause and the String(...) wrap (which exists because the value is a build-time-inlined boolean, not a string). Without it, the mismatch between this gate's !== "false" and usesSegmentCachePrefetchScheduler's === "true" (link.tsx:800) reads as an inconsistency to a future reader, even though both resolve correctly for the boolean define.

Verified good at this tip

  • Extracted scheduler is self-consistent (shims/internal/link-segment-prefetch-scheduler.ts). The single status enum (idle/queued/running/running-canceled/completed) replaces the old overlapping booleans. inProgress has exactly one increment (line 235) and one always-firing finally decrement (line 252); cancel-while-running flips to running-canceled but still hits the finally — no counter leak. A running task can't be double-picked (schedule returns early in the running branch; processQueue re-checks status !== "idle" || !isVisible after dequeue). The visibility-flicker recovery (a running-canceled task rescheduled while visible → back to runningfinally continues) is correct.
  • No stuck queue / infinite loophasBandwidth gates on inProgress < limit; removeQueuedTask runs before the skip guard; finally re-arms via scheduleQueue().
  • Concrete segment identity (app-page-segment-state.ts) slice offsets are correct ([[...]]→5/-2, [...]→4/-1, []→1/-1) and missing params keep the symbolic segment (?? segment), so absent params can't be mis-baked.
  • Segment-key isolation is safe — segment-prefetch renders are never captured/written (!segmentPrefetchPath gate, app-page-render.ts), and appIsrRscKey folds the segment path into the read variant, so the finalizer's shorter write key can't collide with the read key.
  • Prior TS blocker stays fixed (sourcePage?.includes("[") + element-literal casts); full package type-check is clean.
  • /_page marker carries the required vinext-divergence comment; both scheduler phases set Next-Router-Prefetch: 1.

Non-blocking (carried over, still valid)

  1. Foreground stale-revalidate can render twice. In readAppPageCacheResponse (app-page-cache.ts:412), when shouldForegroundRevalidateStaleAppPage is true, renderFreshPageForCache() is awaited (line 414); if buildAppPageCachedResponse returns null (RSC with no rscData, or empty HTML from a redirect/notFound render), the if (response) at line 486 is skipped and control falls through to scheduleBackgroundRegeneration (line 506) — a second renderFreshPageForCache(), then serves stale. Wasteful (and a minor stale: 0 parity gap), not incorrect; mirrors legacy main.
  2. null-sourcePage → materialization is an undertested deliberate decision. materializeConcreteLayoutFlags uses metadata.sourcePage?.includes("["); a null source page proceeds to materialize. Safe today (missing params keep symbolic segments) but "materialize on null" vs "bail on null" (?? true) has no pinning test. Recommend adding one.
  3. Scope creep. The dev-only rsc-reference-validation-normalizer plugin and the cacheLife stale propagation are unrelated to "segment-cache prefetch scheduling." Both are now tested (rsc-reference-validation-normalizer.test.ts was added, +123), but per the repo's small-PR guidance they'd be cleaner as separate PRs — at minimum call them out in the description.

Summary

No new blocking issues; the extraction addresses @NathanDrake2406's structural review. The one item worth resolving in this PR is the f584994 gate: confirm (against Next.js) that deferring cacheComponents-off App Router viewport prefetches to idle is intentional, and add a code comment documenting the !== "false" boolean-define check per AGENTS.md.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2465.

Review summary

Re-reviewed the full diff at tip f584994. The only change since the last reviewed head is commit f584994 ("preserve immediate app viewport prefetches"), which rewrites the idle-deferral gate. No blocking issues — type-check clean (tsc --noEmit -p packages/vinext/tsconfig.json exit 0), all relevant suites pass (link-navigation 61/61, scheduler 5/5, app-page-cache/render/isr/seed 181/181).

Main finding — the new gate needs parity confirmation + a comment. I traced the full history of the gate line to settle the earlier "regression vs narrowing" debate:

  • main: priority === "high" || hasAppNavigationRuntime() → every App Router prefetch ran immediately.
  • current: adds || (hasAppNavigationRuntime() && String(__NEXT_CACHE_COMPONENTS) !== "false").

Net effect in a prod build (where __NEXT_CACHE_COMPONENTS is inlined as a boolean, so String(false) === "false"): cacheComponents-on apps stay immediate, but cacheComponents-off apps (the common default) now defer viewport prefetches to requestIdleCallback — a behavior change from main, codified by the new test at link-navigation.test.ts:1542. Asked the author to (1) confirm against Next.js that idle-deferring cacheComponents-off App Router prefetches is intentional, and (2) add a code comment explaining the !== "false"/String(...) boolean-define check (per AGENTS.md), since it visually mismatches the sibling === "true" check at link.tsx:800.

Verified good: extracted scheduler state machine is self-consistent (balanced inProgress counter, no double-run, correct visibility-flicker recovery, no stuck queue); concrete segment slice offsets and ?? segment fallback are sound; segment-key isolation is safe; the prior TS blocker stays fixed.

Non-blocking carry-overs: foreground stale-revalidate double-render, undertested null-sourcePage materialization, and scope creep (RSC normalizer + cacheLife stale are unrelated to prefetch scheduling, though the normalizer now has a test).

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

1 similar comment
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx james-elicx force-pushed the codex/fix-segment-cache-prefetch-scheduling-28478866791 branch from bb27831 to e172419 Compare July 1, 2026 14:00
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: segment-cache prefetch scheduling (re-review at tip 20929ba)

Re-reviewed the full diff and the four new commits since the last reviewed head (f584994): 54055c7 (rewarm invalidated segment prefetch links), e1724197 (preserve app prefetch scheduling parity), 83bb70aa (restart dirty segment prefetch tasks), 20929ba (ping late app runtime links). No new blocking issues. vp check is clean on all changed files, the full package type-check passes (tsc --noEmit -p packages/vinext/tsconfig.json → exit 0), and every relevant suite is green (link-navigation 64/64, link-segment-prefetch-scheduler 7/7, app-page-render/cache/isr/seed 181/181).

The idle-deferral gate debate is now definitively closed — the PR matches main

I diffed origin/main directly. The f584994 gate that earlier rounds flagged (hasAppNavigationRuntime() && String(__NEXT_CACHE_COMPONENTS) !== "false") was reverted in e1724197. The gate at link.tsx:693-698 is now:

priority === "high" || mode === "route-tree" || mode === "segment" || hasAppNavigationRuntime()

main was priority === "high" || hasAppNavigationRuntime(). The added mode === "route-tree" || "segment" clauses only ever apply to App Router scheduler phases (Pages Router never uses those modes), so the idle-deferral behavior is now functionally identical to main for every app type. The "idle regression" thread from prior rounds no longer applies and requires no code comment / Next.js parity confirmation — there is no delta to explain.

New running-dirty state + force re-warm — traced, correct

The new mechanism lets invalidatePrefetchCache() re-warm visible links (navigation.ts:679pingVisibleLinks({ force: true })). I traced the added state machine:

  • schedule(..., { force: true }) on a running/running-canceled/running-dirty task (scheduler.ts:189-205) flips to running-dirty, re-arms batch/phase/sortId, and returns. The finally (scheduler.ts:266-273) sees running-dirty + visible → resets phase = "route-tree" and re-enqueues, so the in-flight phase completes then the task restarts from the tree phase. Covered by the two new "restarts from route-tree after forced invalidation while route-tree/segment is running" tests.
  • force on a completed task (scheduler.ts:207) falls through to the reset+enqueue path — covered by "restarts a completed task only when forced."
  • Counter balance holds: a running-dirty task still hits the single finally decrement; no double-increment because the force branch returns without re-entering the queue while running.
  • cancel on a running-dirty task (scheduler.ts:230) → running-canceled, which makes both shouldRestart and shouldContinue false in finally, so it drops cleanly. No leak.
  • Non-visible dirty task drops in finally (both guards require isVisible), correctly avoiding re-warm of off-screen links.
  • Non-segment-cache app links re-warm correctly too: invalidatePrefetchCache clears the prefetched set before pinging, and drainVisibleAppPrefetchQueue resets queuedViewportPrefetch, so prefetchUrl re-fetches on the next drain. The force flag is only needed for the scheduler path (which tracks task state independent of the prefetched set) — a correct, deliberate split.

pingVisibleLinkPrefetches router-mode fix

The loop guard changed from instance.routerMode === "app" to resolveCurrentLinkPrefetchRouterMode(instance) === "app" (link.tsx:861), matching the guard already used in drainVisibleAppPrefetchQueue (link.tsx:767) and usesSegmentCachePrefetchScheduler (link.tsx:799). Consistent — resolves the live router mode rather than a stale snapshot.

Non-blocking (carried over, still valid)

  1. Foreground stale-revalidate can render twice. readAppPageCacheResponse (app-page-cache.ts) awaits renderFreshPageForCache(); if buildAppPageCachedResponse returns null (RSC with no rscData, or empty HTML from a redirect/notFound render), control falls through to scheduleBackgroundRegeneration — a second render, then serves stale. Wasteful + minor stale: 0 parity gap, not incorrect; mirrors legacy main.
  2. null-sourcePage → materialization is an undertested deliberate decision. metadata.sourcePage?.includes("[") (app-page-render.ts:367) proceeds to materialize when sourcePage is null. Safe today (missing params keep symbolic segments), but "materialize on null" vs "bail on null" (?? true) still has no pinning test — recommend adding one in this PR.
  3. Scope creep. The dev-only rsc-reference-validation-normalizer plugin and the cacheLife stale propagation remain unrelated to "segment-cache prefetch scheduling." Both are tested and coherent; per the repo's small-PR guidance they'd be cleaner as separate PRs — at minimum call them out in the description.
  4. Minor stale reference: a running-dirty (or running-canceled) task that drops because its instance went invisible does not null mostRecentIntentTask (only cancel and completion do). Harmless — a stale intent reference is overwritten by the next high-priority intent — but noting for symmetry.

Summary

Behavior is correct and well-tested; the extracted scheduler cleanly absorbs the new re-warm/running-dirty logic. No new blocking issues at this tip, and the long-running idle-deferral concern is resolved by the revert in e1724197 (the gate now equals main). Recommend addressing note #2 (pin the null-source-page decision) in this PR. The structural review from @NathanDrake2406 is materially addressed (scheduler extracted, explicit runPhase contract, status-enum lifecycle).

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2465.

Review summary

Re-reviewed the full diff at the current tip 20929ba, focusing on the four new commits since the last reviewed head (f584994). No new blocking issues. vp check clean, full package type-check passes (exit 0), all relevant suites green (link-navigation 64/64, scheduler 7/7, app-page render/cache/isr/seed 181/181).

Key findings:

  1. The idle-deferral gate debate is definitively closed. I diffed origin/main: the f584994 gate that earlier rounds flagged was reverted in e1724197. The current gate is functionally identical to main (the added mode === "route-tree"/"segment" clauses only ever apply to App Router scheduler phases). No delta to explain, no comment needed.

  2. New running-dirty state + force re-warm — traced and correct. The mechanism lets invalidatePrefetchCache() re-warm visible links. I verified: dirty-during-route-tree and dirty-during-segment both restart from route-tree; force on a completed task re-runs; counter balance holds; cancel/invisible-drop paths are clean; non-segment-cache app links re-warm via the cleared prefetched set. All covered by the three new scheduler tests.

  3. pingVisibleLinkPrefetches router-mode fix (instance.routerModeresolveCurrentLinkPrefetchRouterMode) is consistent with sibling guards.

Non-blocking (carried over): foreground stale-revalidate double-render, undertested null-sourcePage materialization (recommend a pinning test in this PR), and scope creep (RSC normalizer + cacheLife stale).

The scheduler extraction materially addresses @NathanDrake2406's structural review. No blocking issues at this tip.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: segment-cache prefetch scheduling (re-review at tip 3fc7b5b)

Re-reviewed the diff since the last reviewed head (20929ba). The only new commit is 3fc7b5b ("preserve segment prefetch re-entry state"), which adds a running-canceled-dirty scheduler state and fixes a stale-router-mode short-circuit in setVisibleLinkPrefetch. No new blocking issues. vp check is clean on the four changed files, the full package type-check passes (tsc --noEmit -p packages/vinext/tsconfig.json → exit 0), and the suites pass (link-segment-prefetch-scheduler 8/8, link-navigation 66/66).

The new running-canceled-dirty state — traced, correct

The commit closes a real gap in the prior running-dirty mechanism. Before this, a force-invalidated in-flight task (running-dirty) that hit a visibility flap (cancel while invisible → back visible) lost its "restart from route-tree" intent, because cancel collapsed running-dirtyrunning-canceled and the subsequent re-entry only restored it to running (advance to segment), not running-dirty (restart from tree). I traced the full transition table for the four running states:

  • schedule(force) on any running staterunning-canceled/running-canceled-dirty become running-canceled-dirty, else running-dirty (scheduler.ts:197-204). Re-arms batch/phase/sortId. Correct — a canceled task keeps its canceled bit until re-entry.
  • Non-force schedule (visibility return) on running-canceled-dirtyrunning-dirty (scheduler.ts:205-206). Un-cancels while preserving the dirty restart intent — this is the exact fix.
  • Non-force schedule on running-canceledrunning (scheduler.ts:207-209). Pre-existing visibility-flicker recovery, unchanged.
  • cancel on running-dirtyrunning-canceled-dirty (scheduler.ts:238); on runningrunning-canceled. Correct.
  • finallyshouldRestart now includes running-canceled-dirty (scheduler.ts:273-276), so a re-entered dirty task restarts from route-tree; an invisible dirty/canceled-dirty task drops cleanly (both guards require isVisible) and returns to idle, so a later visibility event re-warms via the reset+enqueue path. No stuck-task path and counter balance is preserved (single inProgress++ / single always-firing finally decrement).

Covered by the new unit test "preserves a forced route-tree restart across visibility cancellation and re-entry" and the integration test "restarts in-flight cacheComponents prefetches after invalidation across a visibility flap."

setVisibleLinkPrefetch stale-router-mode fix — correct

setVisibleLinkPrefetch previously gated the viewportPrefetched short-circuit on the stale snapshot instance.routerMode === "pages" while using the live resolveCurrentLinkPrefetchRouterMode(instance) for the app branch (link.tsx:836-839). A Pages-visible link that later has the App runtime register would keep short-circuiting and never enter the segment-cache scheduler on re-entry. The fix resolves the live router mode once and uses it for both the short-circuit and the branch. Matches the new test "uses the cacheComponents scheduler when a Pages-visible Link re-enters after App runtime registers." Consistent with the sibling guards in pingVisibleLinkPrefetches (link.tsx:862) and drainVisibleAppPrefetchQueue.

Non-blocking (carried over, still valid)

  1. Foreground stale-revalidate can render twice. In readAppPageCacheResponse (app-page-cache.ts), when shouldForegroundRevalidateStaleAppPage is true, renderFreshPageForCache() is awaited; if buildAppPageCachedResponse returns null (RSC with no rscData, or empty HTML from a redirect/notFound render), control falls through to scheduleBackgroundRegeneration — a second render, then serves stale. Wasteful + minor stale: 0 parity gap, not incorrect; mirrors legacy main.
  2. null-sourcePage → materialization is an undertested deliberate decision. metadata.sourcePage?.includes("[") (app-page-render.ts:367) proceeds to materialize when sourcePage is null. Safe today (missing params keep symbolic segments), but "materialize on null" vs "bail on null" (?? true) still has no pinning test — recommend adding one in this PR.
  3. Scope creep. The dev-only rsc-reference-validation-normalizer plugin and the cacheLife stale propagation remain unrelated to "segment-cache prefetch scheduling." Both are tested and coherent; per the repo's small-PR guidance they'd be cleaner as separate PRs — at minimum call them out in the description.
  4. Minor stale reference (now slightly broader): a running-dirty/running-canceled-dirty task that drops in finally because its instance went invisible does not null mostRecentIntentTask (only cancel and segment completion do). Harmless — the stale reference is overwritten by the next high-priority intent — but noting for symmetry.

Summary

The 3fc7b5b state-machine addition is correct and closes a genuine dropped-prefetch gap under force-invalidation + visibility flap; both new behaviors are well-tested and the scheduler remains self-consistent (no stuck task, balanced counter). No new blocking issues at this tip. The structural review from @NathanDrake2406 remains materially addressed (scheduler extracted, explicit runPhase contract, status-enum lifecycle). Recommend addressing non-blocking note #2 (pin the null-source-page decision) in this PR.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2465.

Review summary

I re-reviewed the diff since the last reviewed head (20929ba). The only new commit is 3fc7b5b ("preserve segment prefetch re-entry state"). No new blocking issues. vp check clean on the 4 changed files, full package type-check passes, and tests pass (scheduler 8/8, link-navigation 66/66).

The new running-canceled-dirty state is correct. It closes a real gap: previously a force-invalidated in-flight task (running-dirty) that hit a visibility flap (cancel while invisible → back visible) lost its "restart from route-tree" intent because cancel collapsed it to running-canceled, and re-entry only restored it to running (advance to segment) rather than restarting from the tree. I traced the full transition table across all four running states, the cancel handler, and the finally block:

  • Re-entry of a running-canceled-dirty task correctly recovers to running-dirty (preserving the restart intent).
  • Invisible dirty/canceled-dirty tasks drop cleanly to idle — no stuck-task path.
  • inProgress counter stays balanced (one increment / one always-firing finally decrement).

Both new behaviors are well-tested (one scheduler unit test + one integration test).

The setVisibleLinkPrefetch fix is also correct — it stops gating the viewportPrefetched short-circuit on the stale instance.routerMode snapshot and uses the live resolved router mode, so a Pages-visible link that later registers the App runtime now correctly enters the segment-cache scheduler on re-entry.

Non-blocking (carried over): foreground stale-revalidate double-render, undertested null-sourcePage materialization (recommend a pinning test in this PR), and scope creep (RSC normalizer + cacheLife stale). Structural review from @NathanDrake2406 remains materially addressed.

github run

@NathanDrake2406 NathanDrake2406 left a comment

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.

Looks good.

@NathanDrake2406 NathanDrake2406 left a comment

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.

Blocking on one issue in packages/vinext/src/build/prerender.ts.

writePrerenderIndex drops stale when it serializes rendered routes. prerenderApp now computes stale, and seed-cache reads route.stale, so prerendered App routes lose cache-life stale metadata after startup.

Please persist stale in the manifest and add a focused test.

…he-prefetch-scheduling-28478866791

# Conflicts:
#	packages/vinext/src/shims/link.tsx
#	tests/link-navigation.test.ts
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.

2 participants