fix(app-router): reuse shared loading state for searched routes - #2456
fix(app-router): reuse shared loading state for searched routes#2456james-elicx wants to merge 2 commits into
Conversation
|
/bigbonk review for issues |
commit: |
|
8bef400 to
b48449f
Compare
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: shared loading state for searched routes
Read through the diff and ran vp check plus the two touched Vitest files (tests/prefetch-cache.test.ts, tests/link-navigation.test.ts) — all green. The core idea (reuse a safe empty-search prefetch as the optimistic fallback for a searched navigation, while gating on cache-control/no-store/hydration metadata) is sound and matches the Next.js segment-cache behavior. A few issues worth addressing.
1. Shared empty-search entry is deleted on the sync consume path but preserved on the async path (behavioral inconsistency)
The whole point of a shared loading state is that multiple searched navigations to the same pathname can reuse one empty-search prefetch. But the three consumption paths disagree on whether the shared entry survives:
consumePrefetchResponseForNavigationpending → fallback branch preserves it (packages/vinext/src/shims/navigation.ts:1062,{ deleteEntry: false }). ✅consumePrefetchResponse(sync) deletes it (navigation.ts:976→consumePrefetchCacheEntryMatchwith default delete). ❌consumePrefetchResponseForNavigationnon-pending fall-through also deletes it (navigation.ts:1070, default delete) when the initialmatchis itself the empty-search fallback. ❌
The new tests actually codify this divergence: the sync test asserts cache.has(prefetchedRscUrl)).toBe(false) (tests/prefetch-cache.test.ts ~line 348) while the async pending-fallback test asserts .toBe(true) (~line 654). So a searched nav that lands on the non-pending path consumes-and-evicts the shared shell, and the next searched nav to the same pathname gets nothing to fall back on.
Recommendation: when match.isEmptySearchFallback === true, never delete the source entry (pass { deleteEntry: false }) on all consume paths, and update the sync test to assert the entry is retained. If deletion on the sync path is genuinely intended, please document why the two navigation paths differ.
2. Duplicated isNoStoreCacheControl helper
isNoStoreCacheControl is copy-pasted with identical bodies in packages/vinext/src/shims/navigation.ts:430 and packages/vinext/src/server/app-browser-entry.ts:438. Per AGENTS.md ("Split state from heavy implementations" / keep runtime logic in typed modules), export it once from navigation.ts (or a small shared util) and import it in the browser entry so the no-store parsing rule can't drift between the two.
3. hasPrefetchCacheEntryForNavigation can evict the shared entry as a side effect of a searched probe
With allowEmptySearchFallback: true, if the only match is an expired empty-search fallback, hasPrefetchCacheEntryForNavigation deletes it (navigation.ts:584). The production caller passes notifyInvalidation: false (app-browser-entry.ts:1789) so no callbacks fire, and an expired entry being swept is defensible — but a boolean "do I have a candidate?" probe eagerly mutating shared cache state for a different URL than the entry's own is surprising. Consider not deleting on the fallback path here (leave expiry sweeping to evictPrefetchCacheIfNeeded), or add a comment noting the intentional eviction.
Minor / confirmed-OK
- The opposite handling of
optimisticRouteShellincanLearnOptimisticRouteTemplateFromPrefetch(returns true) vscanUseEmptySearchPrefetchFallbackEntry(returns false) is correct — learning wants shells, payload-reuse must exclude them. A one-line comment on the fallback check would help future readers. - URL normalization (
_rscstripping before comparingsearch === "") correctly avoids matching prefetches that carried real search params. Good. - Rewriting the returned snapshot
urlto the searched target while keeping the empty-search buffer is the intended shared-shell behavior and matches Next.js.
Nothing here is a blocker for the mechanism itself, but issue #1 undercuts the "shared" in "shared loading state" and should be resolved (or explicitly justified) before merge.
|
Review posted to PR #2456. Summary of findings: Review posted to PR #2456Ran 1. Shared entry deletion inconsistency (main concern) — The three consume paths disagree on whether the shared empty-search prefetch survives a searched navigation:
The new tests actually codify this divergence (sync asserts 2. Duplicated 3. Side-effecting probe — Also confirmed several things are correct (opposite |
|
/bigbonk review for issues |
b48449f to
cae84b5
Compare
There was a problem hiding this comment.
Review: shared loading state for searched routes
Read through the full diff, ran vp check on the three touched source files (clean) and the three touched Vitest files (tests/prefetch-cache.test.ts, tests/link-navigation.test.ts, tests/app-page-response.test.ts — 125 passing). The mechanism is sound: reuse a safe empty-search prefetch as the optimistic fallback for a searched navigation, gated on cache-control so no-store / hydration-seeded / metadata-less snapshots can't leak stale server-search payloads. This matches the Next.js segment-cache behavior the PR ports.
Correction to the prior /bigbonk review
The earlier review's two headline findings do not match the code on this branch, so I want to set the record straight to avoid churn:
-
"Shared entry deleted on the sync path" — not accurate.
consumePrefetchCacheEntryMatchguards deletion withmatch.isEmptySearchFallback !== true(packages/vinext/src/shims/navigation.ts:994), so a shared empty-search fallback is never deleted on any consume path (sync or async). The sync test confirms this: it assertsexpect(cache.has(prefetchedRscUrl)).toBe(true)(tests/prefetch-cache.test.ts~line 348), notfalse. The three paths are consistent; the "shared shell gets evicted on the next nav" concern doesn't apply. -
"Duplicated
isNoStoreCacheControl" — not present. The helper is defined once andexported fromnavigation.ts:430, andapp-browser-entry.tsimports it (app-browser-entry.ts:36) and uses it at:444. There is no second copy.
Both were flagged as blockers; neither is real on this diff.
Actual items worth a look
-
RSC vs HTML default-policy asymmetry (parity — please confirm intentional). The RSC policy fall-through changed from
return {}toreturn { cacheControl: STATIC_CACHE_CONTROL, cacheState: "STATIC" }(app-page-response.ts:176). The corresponding HTML policy fall-through still returns{ shouldWriteToCache: false }with noCache-Control(app-page-response.ts:265). So for the same fully-static App Router page, the RSC response now advertisesCache-Control: s-maxage=31536000, stale-while-revalidate+x-vinext-cache-state: STATICwhile the HTML response advertises noCache-Control. This is needed for the fallback (the snapshot must carry a non-no-storecache-controlto passcanUseEmptySearchPrefetchFallbackEntry), but it's a broader change than the PR title implies and it introduces an RSC/HTML header divergence on the default path. Please confirm this asymmetry is deliberate and matches Next.js (Next sends the sameCache-Controlon the RSC and HTML variants of a static page), or align the HTML default. -
Side-effecting
hasprobe (minor, matches prior review's #3). WithallowEmptySearchFallback: true, if the only candidate is an expired empty-search fallback,hasPrefetchCacheEntryForNavigationdeletes it (navigation.ts:584) as a side effect of a boolean probe keyed on a different URL than the entry's own. The production caller passesnotifyInvalidation: false(app-browser-entry.ts:1786) so no callbacks fire, and sweeping an expired entry is defensible — but a one-line comment noting the intentional eviction (or leaving expiry sweeping toevictPrefetchCacheIfNeeded) would prevent future confusion.
Confirmed correct
canUseEmptySearchPrefetchFallbackrequirestarget.search !== "", so empty-search navigations never trigger the fallback path — no self-match risk.- The opposite
optimisticRouteShellhandling (canLearnOptimisticRouteTemplateFromPrefetchreturnstrue,canUseEmptySearchPrefetchFallbackEntryreturnsfalse) is right: learning wants shells, payload reuse must exclude them. A one-line comment on the fallback exclusion would help readers. _rscstripping before comparingsearch === ""correctly avoids matching prefetches that carried real params.- The searched visible link still prefetches its own searched RSC URL even when a shared shell exists (
tests/link-navigation.test.ts"prefetches a searched visible link when only the no-search RSC URL is cached") — the shared shell is loading-state only, real data still fetches. Good. target-page-server-searchreadssearchParams(dynamic →no-store), so it's correctly excluded from fallback reuse, which is the point of the no-store gate.
No blockers on the mechanism. Item #1 (RSC/HTML default-policy asymmetry) is the one to confirm before merge; #2 is a cleanup.
|
Review posted to PR #2456. SummaryReviewed the diff, ran Key finding: the prior
Genuine items I flagged:
Also confirmed several things are correct (empty-search self-match is impossible, opposite |
|
/bigbonk review for issues |
cae84b5 to
4093d8d
Compare
Review: reuse shared loading state for searched routesRead through the full diff, ran Corrections to the earlier
|
|
Review posted to PR #2456. SummaryReviewed the full diff, ran Key finding: all three headline "blocker" claims from the two prior
Genuine (all minor) items I flagged:
No blockers. The feature is correct and well-tested; remaining items are cleanups. I also confirmed several correctness properties (no self-match risk, |
|
This covers the run 28478866791 / job 84413308650 failure for |
|
Coordinator backlog mapping for GitHub Actions run 28478866791 / job 84413308650: this PR appears to own the remaining App Router search-param shared-loading-state failures, especially Per instruction I am skipping a duplicate implementation because this PR is by |
|
Coordinator mapping for deploy-suite run 31290819291: this PR appears to fix the scoped failure in |
Summary
Validation