Cache components migration - #81
Conversation
Turn on the cacheComponents flag and restructure the root layout so the <html>/<body> shell prerenders while the request-dependent provider tree (locale, auth, feature flags) streams under Suspense.
cacheComponents rejects dynamic/runtime route segment configs, so drop the redundant runtime="nodejs"/"edge" and force-dynamic exports from API routes. Convert the five unstable_cache call sites to the use cache directive with cacheLife/cacheTag.
Math.random() during render is disallowed under Cache Components and broke static prerendering. Seed a deterministic width and randomize in useEffect, which also fixes the latent SSR/client hydration mismatch.
The toolbar calls Math.random() during render, which broke the Cache Components static prerender for ~88 routes (surfacing under MetadataOutlet). Gate it behind a mount effect so it never participates in server prerender.
generateMetadata via next-intl's getTranslations always reads the locale cookie through the request config, forcing every route's <head> to render dynamically and blocking the prerender. Resolve metadata in the default locale from the statically-imported catalog instead. Apply to the root layout and sign-in as the reference pattern.
Convert every generateMetadata to the cookie-free getMetadataTranslations helper so route <head> rendering no longer reads the locale cookie and can prerender. Dynamic routes keep their param/auth/DB lookups; only the localized strings move to the default-locale catalog. Also defer the TSR leaderboard's relative-time labels to after mount.
The OTel SDK generates span/trace IDs with Math.random(), which Cache Components forbids during prerendering — every prerendered route that ran a traced Prisma/Effect query failed. Telemetry has no value at build time, so skip tracer registration in phase-production-build; runtime requests still trace and export normally.
The recent-trends aggregation uses a Date.now() time window; wrap it in use cache (cacheLife hours) so it prerenders and the timestamp resolves once at cache fill.
Its data service derives a Date.now() window and localizes map names via the request locale, so it cannot prerender or cache cleanly. Read connection() first to mark it request-time under Cache Components (matching its prior always-dynamic behavior).
Make the converted generateMetadata functions synchronous (no await), thread the leaderboard relative-time `now` value through PlayerRow, and type the metadata translator against the translator's default options so typecheck and oxlint pass.
Required by the Vercel next-dev-loop and Cache Components skills (16.3+ for the /_next/mcp dev endpoint, export const instant, and instant-nav validation). Build stays green: 0 prerender failures, 92 Partial Prerender routes.
Switch to next@preview (16.3) and turn on partialPrefetching for instant navigations. Replace the map hero trends page's full-page connection() block with a Suspense boundary + content-shaped fallback so the route shows an instant shell on navigation while the trends stream in.
A map's stats never change once uploaded, so wrap getMostPlayedHeroes and
getMatchStory in use cache with cacheLife('max') tagged map:${mapId}, used by
the map and player pages. Invalidate that tag from the remove-map and
set-winner routes so the cache clears only when that specific map changes.
"use cache" functions must be async; the plain-function form threw at runtime (build-only checks missed it). Verified the map and player pages render with the cache layer.
Add scrim-level (scrim:${scrimId}) and team-stats (team-stats:${teamId}) cache
readers alongside the per-map ones, and centralize the tags in lib/cache-tags.
Wire revalidate calls into every scrim mutation path (create/add-map/remove-map/
remove-scrim/set-winner/update-scrim-options, plus the streaming variants) so a
scrim aggregate or a team's stats refresh exactly when a map or scrim changes.
SimpleSpanProcessor exports each span synchronously on end, reading Date.now() inside the render — which Cache Components rejects during the per-request shell prerenders the dev server (and partialPrefetching) run. BatchSpanProcessor defers export to a timer, off the render path, in every environment.
Render the static chrome immediately and move the authorization check (and the Footer, which reads the locale cookie + current year) into a Suspense boundary, so navigating to a scrim or map paints instantly instead of blocking. Clears the 16.3 Instant Insight for those routes and re-enables partialPrefetching.
Make Footer self-contained (its own Suspense) so it's safe in any static shell, stream DashboardLayout's auth-derived header and page content, and move the settings/team/team-stats layouts' request reads into Suspense children. Clears Instant Insights for every route built on these layouts.
Give every auth/flag-reading page segment its own loading.tsx (a shared RouteLoading skeleton) so the route paints an instant shell on navigation while its content streams — the per-segment boundary the shared layout's Suspense doesn't provide. Clears the remaining 16.3 Instant Insights.
The ThemeProvider was inside RootProviders, which streams under Suspense
behind auth/locale/flag reads, so next-themes' anti-flash script landed after
the document shell painted — the page flashed light before switching to the
stored theme. Render ThemeProvider in the static shell so its script runs
before first paint. defaultTheme is now request-independent ("system");
disguised stays selectable for DSG members but no longer auto-defaults.
The root Suspense used fallback={null}, so a hard reload painted a blank
document until RootProviders resolved its auth/locale/flag reads. Replace it
with a header-and-content skeleton that mirrors the app chrome, so a reload
shows the app loading and hands off cleanly to each route's loading.tsx.
…sight Expand route-loading into a small library of shape-matched shells — stats tabs (SectionHeader + StatRibbon + panel), list/feed, detail, and settings/form — and point each route family's loading.tsx at the matching one instead of the generic card grid. Also move the dashboard onboarding tour into a client component so useTranslations resolves from context rather than reading the request locale in the server layout, which was blocking the route.
Replace the five shared skeleton variants with a per-route loading.tsx that mirrors each page's actual structure — stat ribbons, comparison grids, match tables, radar charts, forms, message threads, etc. — so navigating to a route paints an accurate placeholder with no layout shift. Generated per route from the page + its layout chrome, then retire the now-unused route-loading library.
Migrate apps/web to Next.js 16 Cache Components with instant navigation
The overview percentile calc passed a hardcoded 0 for damage blocked because the aggregated value was never carried onto PlayerScrimPerformance. Add damageBlocked/damageBlockedPer10 to the type, copy them from the aggregated stats, and feed the real value into the percentile computation.
The footer lives in the layout's static-shell region and reaches `use cache` reads it can't prerender: @vercel/edge-config's next-js build wraps every get() in "use cache", and the feature flags read Edge Config the same way. getTranslations (next-intl) reads the locale from a cache()d value, so it doesn't reliably defer the render to request time. Those cache reads then get prerendered and reject as a dynamic "use cache" hanging promise when the surrounding render aborts (HANGING_PROMISE_REJECTION on /profile/[playerName] and /dashboard). Add `await connection()` at the top of FooterContent — the same request-time guard AuthedAppHeader already uses — so every read runs at request time and the footer simply streams in behind its Suspense boundary. Removing the getCopyrightYear use cache earlier only dropped one of several cached reads; this covers all of them.
TeamStatsNav renders in the team-stats layout's static-shell region and evaluates feature flags, which reach @vercel/edge-config "use cache" reads via vercelAdapter. It was only protected incidentally by awaiting params first (which defers on the fallback shell); make the request-time guard explicit so a reorder can't turn it into a dynamic "use cache" hanging promise, matching the footer and AuthedAppHeader.
identify() built teams.idArray from an unordered Prisma include, so
team order varied between queries. Under partialPrefetching, flags are
precomputed during prerendering, and the unstable array made the
cache-warming and final prerender passes disagree on the cache key
("Unexpected cache miss after cache warming phase during prerendering"),
which cascaded into a dynamic "use cache" hanging promise on
/[team]/scrim/[scrimId]/map/[mapId]. Order teams by id so the entities
— and the cache key — are deterministic across passes.
computeDateRange returned { from, to: new Date() } at millisecond
precision and passed it as the cache key to getCachedTeamWinrates. PPR
renders each request twice (cache-warming + final prerender), so the
raw timestamp differed between passes and the key never matched
("Unexpected cache miss after cache warming phase during prerendering").
Anchor `to` to the start of tomorrow so the key changes once per day —
matching cacheLife("days") — while still including today's scrims.
MapPageContent already resolves positionalDataEnabled and threads it through ActiveMapTab, but the overview case dropped it, so DefaultOverview re-evaluated the positionalData() flag itself — a redundant flag/Edge Config read. Pass it as positionalDataOverride so the overview reuses the value already resolved upstream.
resolveMapDataId / resolveScrimMapDataId used findFirst with no orderBy. A map can have multiple MapData rows (re-uploads), so Postgres returned an arbitrary one that could differ between PPR's cache-warming and final prerender passes. That mapDataId is a cache-key argument for map-tab reads (getCachedMatchStory, heatmap, etc.), so the instability produced "Unexpected cache miss after cache warming phase" and a dynamic "use cache" hanging promise on /[team]/scrim/[scrimId]/map/[mapId]. Pin the oldest row (orderBy id asc) for a stable id.
Add a Cache Components section to apps/web/AGENTS.md capturing the two rules behind the recurring HANGING_PROMISE_REJECTION / "unexpected cache miss" errors: use-cache inputs must be deterministic across PPR's two prerender passes (orderBy on queries, no Set/Date in keys), and layout-region Edge Config / flag reads must be request-time (await connection()). These only surface at request time on param routes, so they need a written convention rather than build-time checks.
loadLatestArtifact used a hand-rolled Date.now() module TTL. It is the first call inside getCachedMatchStory's "use cache" body (MapPageContent runs getCachedMatchStory on every map load, regardless of tab), so that wall-clock read executed inside a cache scope — the same dynamic "use cache" defect already fixed for the footer year. The entry never committed: every /[team]/scrim/[scrimId]/map/[mapId] render logged "Unexpected cache miss after cache warming phase" + a dynamic "use cache" HANGING_PROMISE_REJECTION and re-downloaded the 8.9MB WP model. Earlier fixes targeted cache-key *arguments*, but the key was always fine — the non-determinism was in the *body*. Wrap loadLatestArtifact in "use cache" (cacheLife hours, cacheTag wp-artifact) so Next governs the TTL with no wall-clock read in the cache scope; bust the tag in publishArtifact so new models ship immediately. fetchLatest takes no args and reads only the deterministic featureHash, so it is safe to cache. Update the test: caching is Next's job now, so mock next/cache and drop the module-TTL assertions.
Follow-ups from the cache-nondeterminism audit (none were the every-load map-route cause, but each can desync a use-cache key between PPR passes): - flags identify(): retry the two idempotent DB reads (session + user) so a transient "Connection closed" blip during prerender can't flip the flag entities (and thus the flag value) between the warming and final passes. - mvp-score: break most-played-hero ties on player_hero instead of array position, so an unordered playerStats order can't pick a different hero. - player-outliers: add a deterministic orderBy to the playerStat query and a hero-name tie-break to the primary-hero sort.
The root layout awaited locale, auth, user, DSG membership, and flags before rendering children, so every route's static shell was just the AppBootSkeleton and no page content could partially prerender. The providers are now stateful and request-independent (they render in the static shell with defaults), and three Suspense islands stream the real values in after first paint: LocaleIsland (LOCALE-cookie override), FlagsIsland (precomputed flags + FlagValues), SessionIsland (command menu user + DSG brand-theme unlock). Unblocking the shell exposed every place that read request data outside Suspense during prerender: the intl provider now pins timeZone so next-intl can't environment-fall-back into cookies(), the not-found copy moved to a client component, TeamSwitcherProvider syncs ?team= via a Suspense-wrapped child instead of reading useSearchParams directly, and the seven async loading.tsx skeletons that called getTranslations now use a cookie-free default-locale translator (getStaticTranslations).
The homepage streamed the entire landing page behind a null Suspense fallback because it gated on the new-landing-page flag — which has been 100% enabled in every environment since March. Remove the flag and the retired v2 landing component, and make V3LandingPage shell-safe: copy resolves from the default-locale catalog, the stats/latest-updates/ copyright-year reads are cached, and the only per-visitor bit (whether the CTAs say Dashboard) hydrates through a streamed island that defaults to the logged-out state, so anonymous visitors get the full page from the CDN with no loading state at all.
Navigating to /dashboard flashed four different loading states: the async route loading.tsx (one design), DashboardLayout's generic content skeleton (another), an empty header bar, and finally ScrimPagination's own client-side card skeletons. The page is now a static shell — real title, one Suspense boundary whose fallback mirrors ScrimPagination's pending layout exactly — so the prerendered shell paints the final skeleton immediately and content streams into it in place. The route loading.tsx is gone (the shell replaces it) and the header fallback is a header-shaped skeleton instead of an empty bar.
Apply AGENTS.md rule 4 to every route that had a loading.tsx (82 routes, via a per-route agent workflow): 52 pages restructured into static shells — sync default export rendering the page frame, with one Suspense boundary around an async *Content child holding the request-time work and a fallback that mirrors the content's own pending layout; 28 routes aligned (loading.tsx made sync/cookie-free and matched to the real page frame, or deleted where the page already provides an in-shell fallback); 2 static pages just lost a loading.tsx that could never show. 81 of 82 loading.tsx files are gone — only the scrim page keeps one, aligned to its real frame. Shell text resolves via getStaticTranslations (default locale), all fallbacks are synchronous, and no data-fetching logic changed.
Navigating to a scrim flashed four states: the [scrimId] layout's auth gate showed a chrome-less skeleton, then loading.tsx rendered its own DashboardLayout (header streams in), then the page replaced the whole tree with ANOTHER DashboardLayout instance — remounting the chrome and re-suspending the header — before the content finally painted. The page is now a static shell (DirectionalTransition > DashboardLayout > frame) with ONE Suspense boundary; the chrome mounts at first paint and never remounts. The layout's auth gate is dissolved into each child route's content component (isAuthedToViewScrim/Map → NoAuthCard as the first step of scrim, edit, map, and player content), so the check resolves inside the route's single skeleton instead of adding a phase above it. DashboardLayout gains guestModeSource — a thunk resolved inside the streamed header — because the scrim page's guest-mode state is param-dependent and must not block the shell.
…x pending overlay
Set prefetch = 'allow-runtime' on the map page and prefetch ?tab= URLs
from MapTabs on trigger hover/focus and cursor trajectory (the team
stats predictive-prefetch detector, extended to non-anchor targets and
a full prefetch kind). The server can now prerender the target tab
before the click because everything it needs is cached:
- getMapViewerContext ('use cache: private') holds the per-viewer work —
access gate, session, user row, colorblind palette — letting runtime
prefetches execute it. generateMetadata uses the same context: an
uncached session read there fails the entire prefetch with E1370
('couldn't prerender metadata').
- The map-data resolvers and the page frame reads (matchStart row, map
row, hero bans, scrim visibility) become 'use cache' readers tagged
map:/scrim: for the existing invalidation routes; /api/vod now
revalidates the map tag. The scrim page's metadata scrim-name read is
cached too, so streamed titles flush near the front of the stream.
- Every tab component is a cached component: locale and feature flags
arrive as props (request APIs are forbidden in public cache scopes),
with translation via the new cookie-free getLocaleTranslations. The
notes tab stays uncached (user-editable); heatmap/routes cap their
cache expire below the presigned image URL lifetime.
Verified on a production build: hovering a tab issues the runtime
prefetch, and the click applies from the prefetch cache in ~36ms with
zero network requests.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
No description provided.