Skip to content

Cache components migration - #81

Merged
lucasdoell merged 60 commits into
mainfrom
staging
Jul 5, 2026
Merged

Cache components migration#81
lucasdoell merged 60 commits into
mainfrom
staging

Conversation

@lucasdoell

Copy link
Copy Markdown
Member

No description provided.

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.
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.
@vercel

vercel Bot commented Jul 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
parsertime Ready Ready Preview, ✅ 2 resolved Jul 5, 2026 8:03am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 5, 2026 8:03am
docs-legacy Skipped Skipped Jul 5, 2026 8:03am

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​next@​16.3.0-preview.543100909970

View full report

@socket-security

Copy link
Copy Markdown

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.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm next is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: apps/web/package.jsonnpm/next@16.3.0-preview.5

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/next@16.3.0-preview.5. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm next is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: apps/web/package.jsonnpm/next@16.3.0-preview.5

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/next@16.3.0-preview.5. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm next is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: apps/web/package.jsonnpm/next@16.3.0-preview.5

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/next@16.3.0-preview.5. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm next is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: apps/web/package.jsonnpm/next@16.3.0-preview.5

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/next@16.3.0-preview.5. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@lucasdoell
lucasdoell merged commit e1172c5 into main Jul 5, 2026
18 checks passed
@lucasdoell
lucasdoell deleted the staging branch July 5, 2026 08:36
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.

1 participant