From 3e961ec8137a76a8f500f0cca49f58f11e97af6b Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 01:58:08 -0400 Subject: [PATCH 01/59] Enable Cache Components and stream the root provider tree Turn on the cacheComponents flag and restructure the root layout so the / shell prerenders while the request-dependent provider tree (locale, auth, feature flags) streams under Suspense. --- apps/web/next.config.ts | 1 + apps/web/src/app/layout.tsx | 107 ++++++++++++++++++++---------------- 2 files changed, 60 insertions(+), 48 deletions(-) diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 17a0831a7..9104b5ba0 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -25,6 +25,7 @@ const cspHeader = ` `; const nextConfig: NextConfig = { + cacheComponents: true, reactCompiler: true, typedRoutes: true, transpilePackages: ["@parsertime/transactional"], diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index ec83263d6..5d450a190 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -24,7 +24,7 @@ import { SpeedInsights } from "@vercel/speed-insights/next"; import { FlagValues } from "flags/react"; import type { Metadata } from "next"; import { NextIntlClientProvider } from "next-intl"; -import { Suspense } from "react"; +import { Suspense, type ReactNode } from "react"; import { getLocale, getMessages, getTranslations } from "next-intl/server"; import { Geist_Mono } from "next/font/google"; import localFont from "next/font/local"; @@ -81,7 +81,28 @@ const geistMono = Geist_Mono({ void register(); -export default async function RootLayout({ children }: LayoutProps<"/">) { +export default function RootLayout({ children }: LayoutProps<"/">) { + return ( + + + {/* The provider tree depends on request-time data (locale, auth, + feature flags), so it streams under Suspense while the document + shell prerenders. See migrating-to-cache-components. */} + + {children} + + + + ); +} + +async function RootProviders({ children }: { children: ReactNode }) { const locale = await getLocale(); const messages = await getMessages(); const session = await auth(); @@ -108,51 +129,41 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { const flags = await resolveAllFlags(); return ( - - - - - - - - - - - - - - {children} - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + {children} + + + + + + + + + + + + + + + + + + ); } From 4330e0becda6a8c803bb84b5ccc933d7fa3ac426 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 02:02:31 -0400 Subject: [PATCH 02/59] Remove route segment configs and migrate unstable_cache to use cache 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. --- apps/web/src/app/about/page.tsx | 39 +++++++------- .../map-calibration/[id]/align/apply/route.ts | 1 - .../[id]/align/revert/route.ts | 1 - .../admin/map-calibration/[id]/align/route.ts | 1 - .../map-calibration/upload/presign/route.ts | 2 - .../api/admin/map-calibration/upload/route.ts | 1 - .../src/app/api/cron/fsr-recompute/route.ts | 1 - .../app/api/cron/scrape-patch-notes/route.ts | 1 - .../src/app/api/cron/tempo-baseline/route.ts | 1 - apps/web/src/app/api/cron/tsr-enrich/route.ts | 1 - .../src/app/api/cron/tsr-recompute/route.ts | 1 - .../src/app/api/cron/usage-rollup/route.ts | 1 - apps/web/src/app/api/cron/wp-publish/route.ts | 1 - apps/web/src/app/api/cron/wp-retrain/route.ts | 1 - .../src/app/api/dashboard/overview/route.ts | 2 - apps/web/src/app/api/faceit/webhook/route.ts | 2 - apps/web/src/app/api/health/route.ts | 2 - .../src/app/api/image/[type]/[id]/route.ts | 2 - .../app/api/leaderboard/adjusted-csr/route.ts | 2 - apps/web/src/app/api/leaderboard/tsr/route.ts | 2 - apps/web/src/app/api/og/route.tsx | 2 - .../src/app/api/team/avatar-upload/route.ts | 2 - apps/web/src/app/api/usage/ingest/route.ts | 2 - .../src/app/api/user/avatar-upload/route.ts | 2 - .../src/app/api/user/banner-upload/route.ts | 2 - apps/web/src/components/home/landing-page.tsx | 37 +++++++------- apps/web/src/components/home/landing-stats.ts | 51 +++++++++---------- apps/web/src/lib/stat-percentiles.ts | 32 ++++++------ apps/web/src/lib/usage/queries.ts | 15 +++--- 29 files changed, 87 insertions(+), 123 deletions(-) diff --git a/apps/web/src/app/about/page.tsx b/apps/web/src/app/about/page.tsx index a1a72d438..e26b40624 100644 --- a/apps/web/src/app/about/page.tsx +++ b/apps/web/src/app/about/page.tsx @@ -13,7 +13,7 @@ import prisma from "@/lib/prisma"; import type { Metadata, Route } from "next"; import { getLocale, getTranslations } from "next-intl/server"; import { OrganizationJsonLd, ProfilePageJsonLd } from "next-seo"; -import { unstable_cache } from "next/cache"; +import { cacheLife } from "next/cache"; import { Instrument_Serif } from "next/font/google"; import type { SVGProps } from "react"; @@ -41,27 +41,26 @@ const instrumentSerif = Instrument_Serif({ variable: "--font-instrument-serif", }); -const getAboutPageStats = unstable_cache( - async () => { - const results = await Promise.allSettled([ - prisma.playerStat.count(), - prisma.calculatedStat.count(), - prisma.kill.count(), - prisma.map.count(), - ]); +async function getAboutPageStats() { + "use cache"; + cacheLife("hours"); - const [playerStatCount, calculatedStatCount, killCount, mapCount] = - results.map((r) => (r.status === "fulfilled" ? r.value : 0)); + const results = await Promise.allSettled([ + prisma.playerStat.count(), + prisma.calculatedStat.count(), + prisma.kill.count(), + prisma.map.count(), + ]); + + const [playerStatCount, calculatedStatCount, killCount, mapCount] = + results.map((r) => (r.status === "fulfilled" ? r.value : 0)); - return { - statsCount: Math.max(playerStatCount + calculatedStatCount, 800_000), - killCount: Math.max(killCount, 450_000), - mapCount: Math.max(mapCount, 6_000), - }; - }, - ["about-page-stats"], - { revalidate: 3600 } -); + return { + statsCount: Math.max(playerStatCount + calculatedStatCount, 800_000), + killCount: Math.max(killCount, 450_000), + mapCount: Math.max(mapCount, 6_000), + }; +} function roundCount(count: number): { value: number; suffix: string } { if (count >= 100_000) { diff --git a/apps/web/src/app/api/admin/map-calibration/[id]/align/apply/route.ts b/apps/web/src/app/api/admin/map-calibration/[id]/align/apply/route.ts index 5e90ac295..a66b07422 100644 --- a/apps/web/src/app/api/admin/map-calibration/[id]/align/apply/route.ts +++ b/apps/web/src/app/api/admin/map-calibration/[id]/align/apply/route.ts @@ -9,7 +9,6 @@ import { forbidden, unauthorized } from "next/navigation"; import { NextResponse } from "next/server"; import { z } from "zod"; -export const runtime = "nodejs"; export const maxDuration = 60; type Params = { params: Promise<{ id: string }> }; diff --git a/apps/web/src/app/api/admin/map-calibration/[id]/align/revert/route.ts b/apps/web/src/app/api/admin/map-calibration/[id]/align/revert/route.ts index b095f8802..d50d6a165 100644 --- a/apps/web/src/app/api/admin/map-calibration/[id]/align/revert/route.ts +++ b/apps/web/src/app/api/admin/map-calibration/[id]/align/revert/route.ts @@ -6,7 +6,6 @@ import { r2 } from "@/lib/r2"; import { forbidden, unauthorized } from "next/navigation"; import { NextResponse } from "next/server"; -export const runtime = "nodejs"; export const maxDuration = 60; type Params = { params: Promise<{ id: string }> }; diff --git a/apps/web/src/app/api/admin/map-calibration/[id]/align/route.ts b/apps/web/src/app/api/admin/map-calibration/[id]/align/route.ts index b56eeb902..aa31ec0a7 100644 --- a/apps/web/src/app/api/admin/map-calibration/[id]/align/route.ts +++ b/apps/web/src/app/api/admin/map-calibration/[id]/align/route.ts @@ -7,7 +7,6 @@ import { forbidden, unauthorized } from "next/navigation"; import { NextResponse } from "next/server"; import sharp from "sharp"; -export const runtime = "nodejs"; export const maxDuration = 60; type Params = { params: Promise<{ id: string }> }; diff --git a/apps/web/src/app/api/admin/map-calibration/upload/presign/route.ts b/apps/web/src/app/api/admin/map-calibration/upload/presign/route.ts index b633e4566..3f08b6aa5 100644 --- a/apps/web/src/app/api/admin/map-calibration/upload/presign/route.ts +++ b/apps/web/src/app/api/admin/map-calibration/upload/presign/route.ts @@ -5,8 +5,6 @@ import { r2 } from "@/lib/r2"; import { forbidden, unauthorized } from "next/navigation"; import { NextResponse } from "next/server"; -export const runtime = "nodejs"; - const ALLOWED_IMAGE_CONTENT_TYPES = new Set([ "image/jpeg", "image/png", diff --git a/apps/web/src/app/api/admin/map-calibration/upload/route.ts b/apps/web/src/app/api/admin/map-calibration/upload/route.ts index 62f291d1e..b321b47e4 100644 --- a/apps/web/src/app/api/admin/map-calibration/upload/route.ts +++ b/apps/web/src/app/api/admin/map-calibration/upload/route.ts @@ -6,7 +6,6 @@ import { forbidden, unauthorized } from "next/navigation"; import { NextResponse } from "next/server"; import sharp from "sharp"; -export const runtime = "nodejs"; export const maxDuration = 60; const MAX_UPLOAD_BYTES = 15 * 1024 * 1024; diff --git a/apps/web/src/app/api/cron/fsr-recompute/route.ts b/apps/web/src/app/api/cron/fsr-recompute/route.ts index 7b90eea54..85c8c35a8 100644 --- a/apps/web/src/app/api/cron/fsr-recompute/route.ts +++ b/apps/web/src/app/api/cron/fsr-recompute/route.ts @@ -2,7 +2,6 @@ import { Logger } from "@/lib/logger"; import { recomputeAllFsr } from "@/lib/fsr/compute"; import { timingSafeEqual } from "node:crypto"; -export const runtime = "nodejs"; export const maxDuration = 300; type AuthResult = diff --git a/apps/web/src/app/api/cron/scrape-patch-notes/route.ts b/apps/web/src/app/api/cron/scrape-patch-notes/route.ts index c294c4eb7..b1c557b72 100644 --- a/apps/web/src/app/api/cron/scrape-patch-notes/route.ts +++ b/apps/web/src/app/api/cron/scrape-patch-notes/route.ts @@ -3,7 +3,6 @@ import { Logger } from "@/lib/logger"; import { scrapeRecent } from "@/lib/overwatch/patch-scraper"; import { timingSafeEqual } from "node:crypto"; -export const runtime = "nodejs"; export const maxDuration = 60; type AuthResult = diff --git a/apps/web/src/app/api/cron/tempo-baseline/route.ts b/apps/web/src/app/api/cron/tempo-baseline/route.ts index 0cdfab4e7..2ce55de92 100644 --- a/apps/web/src/app/api/cron/tempo-baseline/route.ts +++ b/apps/web/src/app/api/cron/tempo-baseline/route.ts @@ -2,7 +2,6 @@ import { Logger } from "@/lib/logger"; import { recomputeTempoBaselines } from "@/lib/tempo/compute"; import { timingSafeEqual } from "node:crypto"; -export const runtime = "nodejs"; export const maxDuration = 300; type AuthResult = diff --git a/apps/web/src/app/api/cron/tsr-enrich/route.ts b/apps/web/src/app/api/cron/tsr-enrich/route.ts index e527bed5f..7089c4721 100644 --- a/apps/web/src/app/api/cron/tsr-enrich/route.ts +++ b/apps/web/src/app/api/cron/tsr-enrich/route.ts @@ -6,7 +6,6 @@ import { import prisma from "@/lib/prisma"; import { timingSafeEqual } from "node:crypto"; -export const runtime = "nodejs"; export const maxDuration = 300; // Number of stale players whose match history is re-ingested per run. The set diff --git a/apps/web/src/app/api/cron/tsr-recompute/route.ts b/apps/web/src/app/api/cron/tsr-recompute/route.ts index c267c9e7b..fa34a47cb 100644 --- a/apps/web/src/app/api/cron/tsr-recompute/route.ts +++ b/apps/web/src/app/api/cron/tsr-recompute/route.ts @@ -3,7 +3,6 @@ import { recomputeAllTeamTsrSnapshots } from "@/lib/matchmaker/snapshot"; import { recomputeAllTsrs } from "@/lib/tsr/replay"; import { timingSafeEqual } from "node:crypto"; -export const runtime = "nodejs"; export const maxDuration = 300; type AuthResult = diff --git a/apps/web/src/app/api/cron/usage-rollup/route.ts b/apps/web/src/app/api/cron/usage-rollup/route.ts index 7289705be..48fd080a8 100644 --- a/apps/web/src/app/api/cron/usage-rollup/route.ts +++ b/apps/web/src/app/api/cron/usage-rollup/route.ts @@ -8,7 +8,6 @@ import { } from "@/lib/usage/rollup"; import { timingSafeEqual } from "node:crypto"; -export const runtime = "nodejs"; export const maxDuration = 300; type AuthResult = diff --git a/apps/web/src/app/api/cron/wp-publish/route.ts b/apps/web/src/app/api/cron/wp-publish/route.ts index 0e2f8bd3d..4ee8bd7da 100644 --- a/apps/web/src/app/api/cron/wp-publish/route.ts +++ b/apps/web/src/app/api/cron/wp-publish/route.ts @@ -10,7 +10,6 @@ import { MODE_FAMILIES, type ModeFamily } from "@/lib/win-probability/types"; import { timingSafeEqual } from "node:crypto"; import { gunzipSync } from "node:zlib"; -export const runtime = "nodejs"; export const maxDuration = 60; type AuthResult = diff --git a/apps/web/src/app/api/cron/wp-retrain/route.ts b/apps/web/src/app/api/cron/wp-retrain/route.ts index f5f231003..c74809ba6 100644 --- a/apps/web/src/app/api/cron/wp-retrain/route.ts +++ b/apps/web/src/app/api/cron/wp-retrain/route.ts @@ -14,7 +14,6 @@ import { put } from "@vercel/blob"; import { waitUntil } from "@vercel/functions"; import { randomUUID, timingSafeEqual } from "node:crypto"; -export const runtime = "nodejs"; // The export (map fetch + buildRows over every map) is the long pole; training // now runs in a separate Python function, so this route only extracts, writes // the matrices to Blob, and fires the trainer. diff --git a/apps/web/src/app/api/dashboard/overview/route.ts b/apps/web/src/app/api/dashboard/overview/route.ts index 15d1dd557..118a7cd91 100644 --- a/apps/web/src/app/api/dashboard/overview/route.ts +++ b/apps/web/src/app/api/dashboard/overview/route.ts @@ -13,8 +13,6 @@ import { unauthorized } from "next/navigation"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -export const runtime = "nodejs"; - function parseOptionalPositiveInt(value: string | null): number | undefined { if (value === null || !/^[1-9]\d*$/.test(value)) return undefined; const parsed = Number(value); diff --git a/apps/web/src/app/api/faceit/webhook/route.ts b/apps/web/src/app/api/faceit/webhook/route.ts index 1965f98fe..08ad5525c 100644 --- a/apps/web/src/app/api/faceit/webhook/route.ts +++ b/apps/web/src/app/api/faceit/webhook/route.ts @@ -4,8 +4,6 @@ import { recomputeAllTsrs } from "@/lib/tsr/replay"; import { kv } from "@vercel/kv"; import { timingSafeEqual } from "node:crypto"; -export const runtime = "nodejs"; - type FaceitWebhookEvent = { transaction_id?: string; event?: string; diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts index c5b81326a..954b175c4 100644 --- a/apps/web/src/app/api/health/route.ts +++ b/apps/web/src/app/api/health/route.ts @@ -1,8 +1,6 @@ import prisma from "@/lib/prisma"; import { NextResponse } from "next/server"; -export const dynamic = "force-dynamic"; - export async function GET() { const checks: Record = {}; diff --git a/apps/web/src/app/api/image/[type]/[id]/route.ts b/apps/web/src/app/api/image/[type]/[id]/route.ts index 6ad81299e..f12b3ddec 100644 --- a/apps/web/src/app/api/image/[type]/[id]/route.ts +++ b/apps/web/src/app/api/image/[type]/[id]/route.ts @@ -2,8 +2,6 @@ import { imageKey, isImageKind } from "@/lib/avatar"; import { r2 } from "@/lib/r2"; import { NextResponse } from "next/server"; -export const runtime = "nodejs"; - const ID_RE = /^[A-Za-z0-9_-]+$/; export async function GET( diff --git a/apps/web/src/app/api/leaderboard/adjusted-csr/route.ts b/apps/web/src/app/api/leaderboard/adjusted-csr/route.ts index 04db81137..4067e6da3 100644 --- a/apps/web/src/app/api/leaderboard/adjusted-csr/route.ts +++ b/apps/web/src/app/api/leaderboard/adjusted-csr/route.ts @@ -5,8 +5,6 @@ import { import { Logger } from "@/lib/logger"; import { type HeroName, heroRoleMapping } from "@/types/heroes"; -export const runtime = "nodejs"; - function parseInt32(value: string | null, fallback: number): number { if (!value) return fallback; const n = Number.parseInt(value, 10); diff --git a/apps/web/src/app/api/leaderboard/tsr/route.ts b/apps/web/src/app/api/leaderboard/tsr/route.ts index 241826429..9e77c5a34 100644 --- a/apps/web/src/app/api/leaderboard/tsr/route.ts +++ b/apps/web/src/app/api/leaderboard/tsr/route.ts @@ -6,8 +6,6 @@ import { } from "@/lib/tsr/leaderboard"; import { FaceitTier, TsrRegion } from "@/generated/prisma/browser"; -export const runtime = "nodejs"; - const VALID_SORTS: TsrSortKey[] = ["rating", "matches", "recent"]; function parseEnum( diff --git a/apps/web/src/app/api/og/route.tsx b/apps/web/src/app/api/og/route.tsx index 9f1dd57bc..6c124e7d0 100644 --- a/apps/web/src/app/api/og/route.tsx +++ b/apps/web/src/app/api/og/route.tsx @@ -4,8 +4,6 @@ import { Logger } from "@/lib/logger"; import { ImageResponse } from "next/og"; -export const runtime = "edge"; - export async function GET(request: Request) { try { const { searchParams } = new URL(request.url); diff --git a/apps/web/src/app/api/team/avatar-upload/route.ts b/apps/web/src/app/api/team/avatar-upload/route.ts index 8b8bf6d2c..92f4d8b5d 100644 --- a/apps/web/src/app/api/team/avatar-upload/route.ts +++ b/apps/web/src/app/api/team/avatar-upload/route.ts @@ -10,8 +10,6 @@ import { kv } from "@vercel/kv"; import { unauthorized } from "next/navigation"; import { after, NextResponse } from "next/server"; -export const runtime = "nodejs"; - const MAX_BYTES = 5 * 1024 * 1024; export async function POST(request: Request): Promise { diff --git a/apps/web/src/app/api/usage/ingest/route.ts b/apps/web/src/app/api/usage/ingest/route.ts index 7e79bf3e7..6fd68aa85 100644 --- a/apps/web/src/app/api/usage/ingest/route.ts +++ b/apps/web/src/app/api/usage/ingest/route.ts @@ -11,8 +11,6 @@ import { kv } from "@vercel/kv"; import type { NextRequest } from "next/server"; import { z } from "zod"; -export const runtime = "nodejs"; - const ratelimit = new Ratelimit({ redis: kv, limiter: Ratelimit.slidingWindow(120, "1 m"), diff --git a/apps/web/src/app/api/user/avatar-upload/route.ts b/apps/web/src/app/api/user/avatar-upload/route.ts index 84caa2823..18d321714 100644 --- a/apps/web/src/app/api/user/avatar-upload/route.ts +++ b/apps/web/src/app/api/user/avatar-upload/route.ts @@ -10,8 +10,6 @@ import { kv } from "@vercel/kv"; import { unauthorized } from "next/navigation"; import { after, NextResponse } from "next/server"; -export const runtime = "nodejs"; - const MAX_BYTES = 5 * 1024 * 1024; export async function POST(request: Request): Promise { diff --git a/apps/web/src/app/api/user/banner-upload/route.ts b/apps/web/src/app/api/user/banner-upload/route.ts index d39649566..74cc70bd8 100644 --- a/apps/web/src/app/api/user/banner-upload/route.ts +++ b/apps/web/src/app/api/user/banner-upload/route.ts @@ -10,8 +10,6 @@ import { kv } from "@vercel/kv"; import { unauthorized } from "next/navigation"; import { after, NextResponse } from "next/server"; -export const runtime = "nodejs"; - const MAX_BYTES = 5 * 1024 * 1024; export async function POST(request: Request): Promise { diff --git a/apps/web/src/components/home/landing-page.tsx b/apps/web/src/components/home/landing-page.tsx index 8923d17bc..0d1116da2 100644 --- a/apps/web/src/components/home/landing-page.tsx +++ b/apps/web/src/components/home/landing-page.tsx @@ -18,7 +18,7 @@ import { import { get } from "@vercel/edge-config"; import type { Route } from "next"; import { getTranslations } from "next-intl/server"; -import { unstable_cache } from "next/cache"; +import { cacheLife } from "next/cache"; import Image from "next/image"; import Link from "next/link"; import type { SVGProps } from "react"; @@ -45,24 +45,23 @@ function formatRoundedCount(count: number): string { return `${count.toLocaleString("en-US")}+`; } -const getLandingPageStats = unstable_cache( - async () => { - const [playerStatCount, calculatedStatCount, killCount, mapCount] = - await Promise.all([ - prisma.playerStat.count(), - prisma.calculatedStat.count(), - prisma.kill.count(), - prisma.map.count(), - ]); - return { - statsCount: playerStatCount + calculatedStatCount, - killCount, - mapCount, - }; - }, - ["landing-page-stats"], - { revalidate: 3600 } -); +async function getLandingPageStats() { + "use cache"; + cacheLife("hours"); + + const [playerStatCount, calculatedStatCount, killCount, mapCount] = + await Promise.all([ + prisma.playerStat.count(), + prisma.calculatedStat.count(), + prisma.kill.count(), + prisma.map.count(), + ]); + return { + statsCount: playerStatCount + calculatedStatCount, + killCount, + mapCount, + }; +} type FooterNavigation = { social: { diff --git a/apps/web/src/components/home/landing-stats.ts b/apps/web/src/components/home/landing-stats.ts index 7eb76eb6f..012a2d1c6 100644 --- a/apps/web/src/components/home/landing-stats.ts +++ b/apps/web/src/components/home/landing-stats.ts @@ -1,6 +1,6 @@ import { getApproximateCounts } from "@/lib/approximate-count"; import prisma from "@/lib/prisma"; -import { unstable_cache } from "next/cache"; +import { cacheLife } from "next/cache"; /** * Rounds a count down to a "nice" number for display. @@ -21,30 +21,29 @@ export function roundCount(count: number): { value: number; suffix: string } { return { value: count, suffix: "+" }; } -export const getLandingPageStats = unstable_cache( - async () => { - // Large tables use approximate counts (pg_class.reltuples) to avoid a full - // sequential scan per cache refresh; Team is small, so an exact count is fine. - const [approx, teamResult] = await Promise.allSettled([ - getApproximateCounts(["PlayerStat", "CalculatedStat", "Kill", "Map"]), - prisma.team.count(), - ]); +export async function getLandingPageStats() { + "use cache"; + cacheLife("hours"); - const counts = - approx.status === "fulfilled" - ? approx.value - : { PlayerStat: 0, CalculatedStat: 0, Kill: 0, Map: 0 }; - const teamCount = teamResult.status === "fulfilled" ? teamResult.value : 0; + // Large tables use approximate counts (pg_class.reltuples) to avoid a full + // sequential scan per cache refresh; Team is small, so an exact count is fine. + const [approx, teamResult] = await Promise.allSettled([ + getApproximateCounts(["PlayerStat", "CalculatedStat", "Kill", "Map"]), + prisma.team.count(), + ]); - // Use known floor values if a query fails, so the landing page - // never shows "0+" for a stat that clearly isn't zero. - return { - statsCount: Math.max(counts.PlayerStat + counts.CalculatedStat, 800_000), - killCount: Math.max(counts.Kill, 450_000), - mapCount: Math.max(counts.Map, 6_000), - teamCount: Math.max(teamCount, 10), - }; - }, - ["landing-page-stats"], - { revalidate: 3600 } -); + const counts = + approx.status === "fulfilled" + ? approx.value + : { PlayerStat: 0, CalculatedStat: 0, Kill: 0, Map: 0 }; + const teamCount = teamResult.status === "fulfilled" ? teamResult.value : 0; + + // Use known floor values if a query fails, so the landing page + // never shows "0+" for a stat that clearly isn't zero. + return { + statsCount: Math.max(counts.PlayerStat + counts.CalculatedStat, 800_000), + killCount: Math.max(counts.Kill, 450_000), + mapCount: Math.max(counts.Map, 6_000), + teamCount: Math.max(teamCount, 10), + }; +} diff --git a/apps/web/src/lib/stat-percentiles.ts b/apps/web/src/lib/stat-percentiles.ts index f228a8017..65d6354e0 100644 --- a/apps/web/src/lib/stat-percentiles.ts +++ b/apps/web/src/lib/stat-percentiles.ts @@ -1,7 +1,7 @@ import prisma from "@/lib/prisma"; import type { HeroName } from "@/types/heroes"; import { Prisma } from "@/generated/prisma/client"; -import { unstable_cache } from "next/cache"; +import { cacheLife, cacheTag } from "next/cache"; // These baselines scan the whole PlayerStat table per hero (the app's biggest // rows-read offender) yet are global, slowly-changing population statistics — @@ -444,9 +444,16 @@ function buildMultiStatComparisonQuery({ `; } -async function compareMultipleStatsToDistributionUncached( +export async function compareMultipleStatsToDistribution( params: MultiStatComparisonParams ): Promise { + "use cache"; + cacheLife({ + revalidate: HERO_BASELINE_TTL_SECONDS, + expire: HERO_BASELINE_TTL_SECONDS * 4, + }); + cacheTag(HERO_BASELINE_TAG); + const query = buildMultiStatComparisonQuery(params); const result = await prisma.$queryRaw<{ hero: string; comparisons: StatComparison[] }[]>( @@ -463,12 +470,6 @@ async function compareMultipleStatsToDistributionUncached( }; } -export const compareMultipleStatsToDistribution = unstable_cache( - compareMultipleStatsToDistributionUncached, - ["multi-stat-comparison"], - { revalidate: HERO_BASELINE_TTL_SECONDS, tags: [HERO_BASELINE_TAG] } -); - function buildStatDistributionBaselineQuery({ hero, stat, @@ -522,20 +523,21 @@ function buildStatDistributionBaselineQuery({ `; } -async function getStatDistributionBaselineUncached( +export async function getStatDistributionBaseline( params: StatDistributionBaselineParams ): Promise { + "use cache"; + cacheLife({ + revalidate: HERO_BASELINE_TTL_SECONDS, + expire: HERO_BASELINE_TTL_SECONDS * 4, + }); + cacheTag(HERO_BASELINE_TAG); + const query = buildStatDistributionBaselineQuery(params); const result = await prisma.$queryRaw(query); return result[0] ?? null; } -export const getStatDistributionBaseline = unstable_cache( - getStatDistributionBaselineUncached, - ["stat-distribution-baseline"], - { revalidate: HERO_BASELINE_TTL_SECONDS, tags: [HERO_BASELINE_TAG] } -); - export type { StatDistributionBaseline, StatDistributionBaselineParams, diff --git a/apps/web/src/lib/usage/queries.ts b/apps/web/src/lib/usage/queries.ts index 9658c3a8f..deb1a933f 100644 --- a/apps/web/src/lib/usage/queries.ts +++ b/apps/web/src/lib/usage/queries.ts @@ -2,7 +2,7 @@ import "server-only"; import prisma from "@/lib/prisma"; import type { UsageEnv } from "@/generated/prisma/client"; -import { unstable_cache } from "next/cache"; +import { cacheLife } from "next/cache"; import { FUNNELS, computeFunnel, type FunnelStep } from "./funnels"; import { dayKey } from "./rollup"; @@ -170,10 +170,11 @@ async function loadFunnels( })); } -export function getFunnels(env: UsageEnv, days = 30): Promise { - return unstable_cache( - () => loadFunnels(env, days), - ["usage-funnels", env, String(days)], - { revalidate: 60 * 60 * 24 } // daily - )(); +export async function getFunnels( + env: UsageEnv, + days = 30 +): Promise { + "use cache"; + cacheLife("days"); + return loadFunnels(env, days); } From 34954df3be30713950b976e6035e4aad28aba9be Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 02:05:53 -0400 Subject: [PATCH 03/59] Defer sidebar skeleton random width to after mount 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. --- apps/web/src/components/ui/sidebar.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 208386482..a3f5f40af 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -591,10 +591,13 @@ function SidebarMenuSkeleton({ }: React.ComponentProps<"div"> & { showIcon?: boolean; }) { - // Random width between 50 to 90%. - const [width] = React.useState(() => { - return `${Math.floor(Math.random() * 40) + 50}%`; - }); + // Random width between 50 to 90%. Generated after mount so the prerender + // pass stays deterministic (Math.random() during render is disallowed under + // Cache Components) and the server/client markup matches on first paint. + const [width, setWidth] = React.useState("70%"); + React.useEffect(() => { + setWidth(`${Math.floor(Math.random() * 40) + 50}%`); + }, []); return (
Date: Fri, 26 Jun 2026 02:13:43 -0400 Subject: [PATCH 04/59] Mount the Vercel Toolbar on the client only 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. --- .../devtools/client-vercel-toolbar.tsx | 19 +++++++++++++++++++ apps/web/src/components/devtools/index.tsx | 4 ++-- 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/devtools/client-vercel-toolbar.tsx diff --git a/apps/web/src/components/devtools/client-vercel-toolbar.tsx b/apps/web/src/components/devtools/client-vercel-toolbar.tsx new file mode 100644 index 000000000..73b717cee --- /dev/null +++ b/apps/web/src/components/devtools/client-vercel-toolbar.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { VercelToolbar } from "@vercel/toolbar/next"; +import { useEffect, useState } from "react"; + +/** + * The Vercel Toolbar calls `Math.random()` while rendering, which is disallowed + * during the Cache Components prerender pass. Mount it on the client only so it + * never participates in server prerendering. + */ +export function ClientVercelToolbar() { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + return mounted ? : null; +} diff --git a/apps/web/src/components/devtools/index.tsx b/apps/web/src/components/devtools/index.tsx index bde73f74c..c943f426d 100644 --- a/apps/web/src/components/devtools/index.tsx +++ b/apps/web/src/components/devtools/index.tsx @@ -1,7 +1,7 @@ +import { ClientVercelToolbar } from "@/components/devtools/client-vercel-toolbar"; import { ReactScan } from "@/components/devtools/react-scan"; import { TailwindIndicator } from "@/components/devtools/tailwind-indicator"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { VercelToolbar } from "@vercel/toolbar/next"; export function DevTools() { const isProd = process.env.NEXT_PUBLIC_VERCEL_ENV === "production"; @@ -12,7 +12,7 @@ export function DevTools() { - + ); } From b7bff57cee2ff00091a4da6ec3dbff5199e00254 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 02:26:34 -0400 Subject: [PATCH 05/59] Add a cookie-free metadata translator for Cache Components generateMetadata via next-intl's getTranslations always reads the locale cookie through the request config, forcing every route's 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. --- apps/web/src/app/(auth)/sign-in/page.tsx | 18 +++++------------- apps/web/src/app/layout.tsx | 13 ++++++++----- apps/web/src/lib/metadata-i18n.ts | 21 +++++++++++++++++++++ 3 files changed, 34 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/lib/metadata-i18n.ts diff --git a/apps/web/src/app/(auth)/sign-in/page.tsx b/apps/web/src/app/(auth)/sign-in/page.tsx index c3ba4473a..b21ca7c2c 100644 --- a/apps/web/src/app/(auth)/sign-in/page.tsx +++ b/apps/web/src/app/(auth)/sign-in/page.tsx @@ -1,23 +1,15 @@ import { UserAuthForm } from "@/components/auth/user-auth-form"; import { Link } from "@/components/ui/link"; import { auth } from "@/lib/auth"; -import type { PagePropsWithLocale } from "@/types/next"; +import { defaultLocale } from "@/i18n/config"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata, Route } from "next"; import { getTranslations } from "next-intl/server"; import Image from "next/image"; import { redirect } from "next/navigation"; -export async function generateMetadata( - props: PagePropsWithLocale<"/sign-in"> -): Promise { - const params = await props.params; - - const { locale } = params; - - const t = await getTranslations({ - locale, - namespace: "signInPage.metadataSignIn", - }); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("signInPage.metadataSignIn"); return { title: t("title"), @@ -35,7 +27,7 @@ export async function generateMetadata( height: 630, }, ], - locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 5d450a190..f1305c141 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -13,6 +13,7 @@ import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { register } from "@/instrumentation"; import { auth } from "@/lib/auth"; +import { defaultLocale } from "@/i18n/config"; import { DSG_TEAM_ID } from "@/lib/brand-theme"; import { WebVitals } from "@/lib/axiom/client"; import { resolveAllFlags, toFlagValues } from "@/lib/flags-helpers"; @@ -25,15 +26,17 @@ import { FlagValues } from "flags/react"; import type { Metadata } from "next"; import { NextIntlClientProvider } from "next-intl"; import { Suspense, type ReactNode } from "react"; -import { getLocale, getMessages, getTranslations } from "next-intl/server"; +import { getLocale, getMessages } from "next-intl/server"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import { Geist_Mono } from "next/font/google"; import localFont from "next/font/local"; import { NuqsAdapter } from "nuqs/adapters/next/app"; import "./globals.css"; -export async function generateMetadata(): Promise { - const locale = await getLocale(); - const t = await getTranslations({ locale, namespace: "metadata" }); +export function generateMetadata(): Metadata { + // Resolved in the default locale (see getMetadataTranslations) so the route's + // can be prerendered under Cache Components. + const t = getMetadataTranslations("metadata"); return { title: t("title"), @@ -52,7 +55,7 @@ export async function generateMetadata(): Promise { height: 630, }, ], - locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/lib/metadata-i18n.ts b/apps/web/src/lib/metadata-i18n.ts new file mode 100644 index 000000000..5b71ea022 --- /dev/null +++ b/apps/web/src/lib/metadata-i18n.ts @@ -0,0 +1,21 @@ +import { defaultLocale } from "@/i18n/config"; +import { createTranslator } from "next-intl"; +import enMessages from "../../messages/en.json"; + +/** + * Cookie-free translator for `generateMetadata`. + * + * next-intl's `getTranslations` always resolves the per-request locale through + * the request config (`getUserLocale` → `cookies()`), which forces every + * route's `` to render dynamically under Cache Components. Metadata is + * instead resolved in the default locale from the statically-imported catalog + * so it can be prerendered. Page content is still localized at runtime via the + * `NextIntlClientProvider` in the root layout. + */ +export function getMetadataTranslations(namespace?: string) { + return createTranslator({ + locale: defaultLocale, + messages: enMessages, + namespace, + }); +} From 0e6f73f11d156538973672041a62c53dbcce162e Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 02:38:00 -0400 Subject: [PATCH 06/59] Resolve route metadata in the default locale (Cache Components) Convert every generateMetadata to the cookie-free getMetadataTranslations helper so route 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. --- apps/web/src/app/(auth)/auth-error/layout.tsx | 4 +-- apps/web/src/app/(auth)/sign-up/page.tsx | 18 +++-------- .../src/app/(auth)/verify-request/layout.tsx | 4 +-- apps/web/src/app/[team]/compare/page.tsx | 12 ++----- apps/web/src/app/[team]/map-groups/page.tsx | 12 ++----- apps/web/src/app/[team]/ops/page.tsx | 11 ++----- .../app/[team]/scrim/[scrimId]/edit/page.tsx | 3 +- .../scrim/[scrimId]/map/[mapId]/page.tsx | 9 +++--- .../map/[mapId]/player/[playerId]/page.tsx | 9 +++--- .../src/app/[team]/scrim/[scrimId]/page.tsx | 9 +++--- apps/web/src/app/about/page.tsx | 8 ++--- apps/web/src/app/chat/layout.tsx | 4 +-- apps/web/src/app/coaching/layout.tsx | 4 +-- apps/web/src/app/contact/layout.tsx | 10 +++--- apps/web/src/app/dashboard/page.tsx | 18 +++-------- apps/web/src/app/data-labeling/layout.tsx | 4 +-- apps/web/src/app/debug/layout.tsx | 4 +-- apps/web/src/app/demo/page.tsx | 12 +++---- .../src/app/demo/player/[playerId]/page.tsx | 9 +++--- apps/web/src/app/faceit/page.tsx | 3 +- apps/web/src/app/faceit/player/page.tsx | 3 +- apps/web/src/app/leaderboard/csr/page.tsx | 3 +- apps/web/src/app/leaderboard/layout.tsx | 10 +++--- apps/web/src/app/leaderboard/page.tsx | 5 +-- apps/web/src/app/matchmaker/layout.tsx | 4 +-- apps/web/src/app/matchmaker/page.tsx | 4 +-- apps/web/src/app/notifications/layout.tsx | 6 ++-- apps/web/src/app/pricing/page.tsx | 11 +++---- apps/web/src/app/privacy/layout.tsx | 13 +++----- .../src/app/profile/[playerName]/layout.tsx | 8 ++--- apps/web/src/app/query/page.tsx | 5 +-- apps/web/src/app/ranked/page.tsx | 9 ++---- apps/web/src/app/reports/layout.tsx | 6 ++-- apps/web/src/app/scouting/layout.tsx | 12 +++---- apps/web/src/app/scouting/page.tsx | 5 +-- .../src/app/scouting/player/[slug]/layout.tsx | 8 ++--- apps/web/src/app/scouting/player/page.tsx | 5 +-- .../app/scouting/team/[teamAbbr]/layout.tsx | 8 ++--- apps/web/src/app/settings/layout.tsx | 11 +++---- apps/web/src/app/stats/[playerName]/page.tsx | 6 ++-- .../src/app/stats/hero/[heroName]/page.tsx | 10 +++--- apps/web/src/app/stats/hero/page.tsx | 12 +++---- apps/web/src/app/stats/layout.tsx | 12 +++---- .../src/app/stats/team/[teamId]/layout.tsx | 8 ++--- .../team/[teamId]/(app)/availability/page.tsx | 5 +-- .../(app)/availability/settings/page.tsx | 5 +-- apps/web/src/app/team/[teamId]/(app)/page.tsx | 6 ++-- .../app/team/[teamId]/(app)/targets/page.tsx | 5 +-- .../availability/[scheduleId]/page.tsx | 6 ++-- apps/web/src/app/team/join/layout.tsx | 6 ++-- apps/web/src/app/team/page.tsx | 12 +++---- apps/web/src/app/terms-of-service/layout.tsx | 13 +++----- apps/web/src/app/tournaments/create/page.tsx | 6 ++-- apps/web/src/app/tournaments/page.tsx | 6 ++-- .../leaderboard/tsr-leaderboard.tsx | 31 ++++++++++++++----- 55 files changed, 212 insertions(+), 240 deletions(-) diff --git a/apps/web/src/app/(auth)/auth-error/layout.tsx b/apps/web/src/app/(auth)/auth-error/layout.tsx index d96e75ceb..435a7f6bc 100644 --- a/apps/web/src/app/(auth)/auth-error/layout.tsx +++ b/apps/web/src/app/(auth)/auth-error/layout.tsx @@ -1,9 +1,9 @@ import { Footer } from "@/components/footer"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; export async function generateMetadata(): Promise { - const t = await getTranslations("authError.metadata"); + const t = getMetadataTranslations("authError.metadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/app/(auth)/sign-up/page.tsx b/apps/web/src/app/(auth)/sign-up/page.tsx index fc8794c25..8e38419ed 100644 --- a/apps/web/src/app/(auth)/sign-up/page.tsx +++ b/apps/web/src/app/(auth)/sign-up/page.tsx @@ -1,21 +1,13 @@ import { UserAuthForm } from "@/components/auth/user-auth-form"; import { Link } from "@/components/ui/link"; -import type { PagePropsWithLocale } from "@/types/next"; +import { defaultLocale } from "@/i18n/config"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Image from "next/image"; -export async function generateMetadata( - props: PagePropsWithLocale<"/sign-up"> -): Promise { - const params = await props.params; - - const { locale } = params; - - const t = await getTranslations({ - locale, - namespace: "signInPage.metadataSignUp", - }); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("signInPage.metadataSignUp"); return { title: t("title"), @@ -33,7 +25,7 @@ export async function generateMetadata( height: 630, }, ], - locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/(auth)/verify-request/layout.tsx b/apps/web/src/app/(auth)/verify-request/layout.tsx index e266d7fbf..8e4acf340 100644 --- a/apps/web/src/app/(auth)/verify-request/layout.tsx +++ b/apps/web/src/app/(auth)/verify-request/layout.tsx @@ -1,9 +1,9 @@ import { Footer } from "@/components/footer"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; export async function generateMetadata(): Promise { - const t = await getTranslations("verifyRequest.metadata"); + const t = getMetadataTranslations("verifyRequest.metadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/app/[team]/compare/page.tsx b/apps/web/src/app/[team]/compare/page.tsx index 9fe5102f3..6b48c8742 100644 --- a/apps/web/src/app/[team]/compare/page.tsx +++ b/apps/web/src/app/[team]/compare/page.tsx @@ -4,21 +4,15 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import type { PagePropsWithLocale } from "@/types/next"; import { $Enums } from "@/generated/prisma/browser"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; -export async function generateMetadata( - props: PagePropsWithLocale<"/[team]/compare"> -): Promise { - const params = await props.params; - const t = await getTranslations({ - locale: params.locale, - namespace: "comparePage.metadata", - }); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("comparePage.metadata"); return { title: t("title"), diff --git a/apps/web/src/app/[team]/map-groups/page.tsx b/apps/web/src/app/[team]/map-groups/page.tsx index a8505c1c2..f93a8e2b0 100644 --- a/apps/web/src/app/[team]/map-groups/page.tsx +++ b/apps/web/src/app/[team]/map-groups/page.tsx @@ -4,21 +4,15 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import type { PagePropsWithLocale } from "@/types/next"; import { $Enums } from "@/generated/prisma/browser"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; -export async function generateMetadata( - props: PagePropsWithLocale<"/[team]/map-groups"> -): Promise { - const params = await props.params; - const t = await getTranslations({ - locale: params.locale, - namespace: "mapGroupsPage.metadata", - }); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("mapGroupsPage.metadata"); return { title: t("title"), diff --git a/apps/web/src/app/[team]/ops/page.tsx b/apps/web/src/app/[team]/ops/page.tsx index a344d85be..88b7be6cf 100644 --- a/apps/web/src/app/[team]/ops/page.tsx +++ b/apps/web/src/app/[team]/ops/page.tsx @@ -5,6 +5,7 @@ import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { canManageTeam } from "@/lib/auth"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import { listBlacklist, getBlacklistSuggestions, @@ -14,14 +15,8 @@ import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; -export async function generateMetadata( - props: PagePropsWithLocale<"/[team]/ops"> -): Promise { - const params = await props.params; - const t = await getTranslations({ - locale: params.locale, - namespace: "teamOps", - }); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("teamOps"); return { title: t("title"), diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/edit/page.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/edit/page.tsx index 099b06bf1..202769a41 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/edit/page.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/edit/page.tsx @@ -10,6 +10,7 @@ import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { scoutingTool } from "@/lib/flags"; import { resolveMapDataId } from "@/lib/map-data-resolver"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import type { Metadata, Route } from "next"; import { getTranslations } from "next-intl/server"; @@ -18,7 +19,7 @@ export async function generateMetadata( props: PageProps<"/[team]/scrim/[scrimId]/edit"> ): Promise { const params = await props.params; - const t = await getTranslations("scrimPage.editMetadata"); + const t = getMetadataTranslations("scrimPage.editMetadata"); const scrim = await AppRuntime.runPromise( ScrimService.pipe( Effect.flatMap((svc) => svc.getScrim(parseInt(params.scrimId))) diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx index 5739a9dd4..19d8828a1 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx @@ -11,9 +11,11 @@ import { MatchStoryService } from "@/data/map/match-story-service"; import { PlayerService } from "@/data/player"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; +import { defaultLocale } from "@/i18n/config"; import { auth, isAuthedToViewMap } from "@/lib/auth"; import { positionalData, tempoChart } from "@/lib/flags"; import { resolveScrimMapDataId } from "@/lib/map-data-resolver"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import { getColorblindMode } from "@/lib/server-utils"; import { translateMapName } from "@/lib/utils"; @@ -34,10 +36,7 @@ export async function generateMetadata( Number.isSafeInteger(scrimId) && Number.isSafeInteger(mapId) && (await isAuthedToViewMap(scrimId, mapId)); - const t = await getTranslations({ - locale: params.locale, - namespace: "mapPage.mapMetadata", - }); + const t = getMetadataTranslations("mapPage.mapMetadata"); const mapName = canViewMap ? await prisma.matchStart.findFirst({ @@ -68,7 +67,7 @@ export async function generateMetadata( height: 630, }, ], - locale: params.locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx index e19a11f0d..5c283f2eb 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx @@ -11,8 +11,10 @@ import { PlayerService } from "@/data/player"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; +import { defaultLocale } from "@/i18n/config"; import { auth } from "@/lib/auth"; import { resolveMapDataId } from "@/lib/map-data-resolver"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import { translateHeroName, translateMapName } from "@/lib/utils"; import { heroRoleMapping, type HeroName } from "@/types/heroes"; @@ -24,10 +26,7 @@ export async function generateMetadata( props: PagePropsWithLocale<"/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]"> ): Promise { const params = await props.params; - const t = await getTranslations({ - locale: params.locale, - namespace: "mapPage.playerMetadata", - }); + const t = getMetadataTranslations("mapPage.playerMetadata"); const playerName = decodeURIComponent(params.playerId); return { @@ -48,7 +47,7 @@ export async function generateMetadata( height: 630, }, ], - locale: params.locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx index 3e2c90cd1..727eb6198 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx @@ -32,8 +32,10 @@ import { resolveScrimMapWinners } from "@/data/scrim/map-winner-names"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; +import { defaultLocale } from "@/i18n/config"; import { auth, canManageTeam, isAuthedToViewScrim } from "@/lib/auth"; import { mapComparison, overviewCard, positionalData } from "@/lib/flags"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import type { PagePropsWithLocale } from "@/types/next"; import { $Enums } from "@/generated/prisma/browser"; @@ -47,10 +49,7 @@ export async function generateMetadata( props: PagePropsWithLocale<"/[team]/scrim/[scrimId]"> ): Promise { const params = await props.params; - const t = await getTranslations({ - locale: params.locale, - namespace: "scrimPage.metadata", - }); + const t = getMetadataTranslations("scrimPage.metadata"); const scrimId = Number(params.scrimId); const canViewScrim = Number.isSafeInteger(scrimId) && @@ -86,7 +85,7 @@ export async function generateMetadata( height: 630, }, ], - locale: params.locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/about/page.tsx b/apps/web/src/app/about/page.tsx index e26b40624..fd054d453 100644 --- a/apps/web/src/app/about/page.tsx +++ b/apps/web/src/app/about/page.tsx @@ -9,17 +9,17 @@ import { StatsCounter } from "@/components/home/new-landing/stats-counter"; import { TrackedLink } from "@/components/home/new-landing/tracked-link"; import { TrackedSection } from "@/components/home/new-landing/tracked-section"; import { auth } from "@/lib/auth"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import type { Metadata, Route } from "next"; -import { getLocale, getTranslations } from "next-intl/server"; +import { getTranslations } from "next-intl/server"; import { OrganizationJsonLd, ProfilePageJsonLd } from "next-seo"; import { cacheLife } from "next/cache"; import { Instrument_Serif } from "next/font/google"; import type { SVGProps } from "react"; -export async function generateMetadata(): Promise { - const locale = await getLocale(); - const t = await getTranslations({ locale, namespace: "aboutPage.metadata" }); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("aboutPage.metadata"); return { title: t("title"), diff --git a/apps/web/src/app/chat/layout.tsx b/apps/web/src/app/chat/layout.tsx index 10fc940f8..6d19a6058 100644 --- a/apps/web/src/app/chat/layout.tsx +++ b/apps/web/src/app/chat/layout.tsx @@ -1,10 +1,10 @@ import { ChatSidebar } from "@/components/chat/chat-sidebar"; import { DashboardLayout } from "@/components/dashboard-layout"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; export async function generateMetadata(): Promise { - const t = await getTranslations("analyst.metadata"); + const t = getMetadataTranslations("analyst.metadata"); return { title: t("title"), description: t("description"), diff --git a/apps/web/src/app/coaching/layout.tsx b/apps/web/src/app/coaching/layout.tsx index ad57798f5..f9b00523c 100644 --- a/apps/web/src/app/coaching/layout.tsx +++ b/apps/web/src/app/coaching/layout.tsx @@ -1,9 +1,9 @@ import { DashboardLayout } from "@/components/dashboard-layout"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; export async function generateMetadata(): Promise { - const t = await getTranslations("coaching.metadata"); + const t = getMetadataTranslations("coaching.metadata"); return { title: t("title"), diff --git a/apps/web/src/app/contact/layout.tsx b/apps/web/src/app/contact/layout.tsx index 2c91831de..8709ba7f7 100644 --- a/apps/web/src/app/contact/layout.tsx +++ b/apps/web/src/app/contact/layout.tsx @@ -1,11 +1,11 @@ import { Footer } from "@/components/footer"; import { Header } from "@/components/header"; +import { defaultLocale } from "@/i18n/config"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getLocale, getTranslations } from "next-intl/server"; -export async function generateMetadata(): Promise { - const t = await getTranslations("contactPage.metadata"); - const locale = await getLocale(); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("contactPage.metadata"); return { title: t("title"), @@ -23,7 +23,7 @@ export async function generateMetadata(): Promise { height: 630, }, ], - locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index b4e15ffe1..97038fa60 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -5,24 +5,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; +import { defaultLocale } from "@/i18n/config"; import { auth } from "@/lib/auth"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import { getPendingFeedbackCount } from "@/lib/team-ops/scrim-feedback"; -import type { PagePropsWithLocale } from "@/types/next"; import { $Enums } from "@/generated/prisma/browser"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; -export async function generateMetadata( - props: PagePropsWithLocale<"/dashboard"> -): Promise { - const params = await props.params; - - const { locale } = params; - - const t = await getTranslations({ - locale, - namespace: "dashboard.metadata", - }); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("dashboard.metadata"); return { title: t("title"), @@ -40,7 +32,7 @@ export async function generateMetadata( height: 630, }, ], - locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/data-labeling/layout.tsx b/apps/web/src/app/data-labeling/layout.tsx index 96520670f..20e360d26 100644 --- a/apps/web/src/app/data-labeling/layout.tsx +++ b/apps/web/src/app/data-labeling/layout.tsx @@ -1,9 +1,9 @@ import { DashboardLayout } from "@/components/dashboard-layout"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; export async function generateMetadata(): Promise { - const t = await getTranslations("dataLabeling.metadata"); + const t = getMetadataTranslations("dataLabeling.metadata"); return { title: t("title"), diff --git a/apps/web/src/app/debug/layout.tsx b/apps/web/src/app/debug/layout.tsx index cce99fbf5..ceb9ac52f 100644 --- a/apps/web/src/app/debug/layout.tsx +++ b/apps/web/src/app/debug/layout.tsx @@ -1,9 +1,9 @@ import { DashboardLayout } from "@/components/dashboard-layout"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; export async function generateMetadata(): Promise { - const t = await getTranslations("debugPage.metadata"); + const t = getMetadataTranslations("debugPage.metadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/app/demo/page.tsx b/apps/web/src/app/demo/page.tsx index 30c333050..cfc9c9254 100644 --- a/apps/web/src/app/demo/page.tsx +++ b/apps/web/src/app/demo/page.tsx @@ -16,21 +16,19 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { PlayerService } from "@/data/player"; +import { defaultLocale } from "@/i18n/config"; import { resolveMapDataId } from "@/lib/map-data-resolver"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import { toTitleCase, translateMapName } from "@/lib/utils"; -import type { PagePropsWithLocale } from "@/types/next"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; const DEMO_MAP_ID = 10148; -export async function generateMetadata( - props: PagePropsWithLocale<"/demo"> -): Promise { - const params = await props.params; - const t = await getTranslations("demoPage.metadata"); +export async function generateMetadata(): Promise { + const t = getMetadataTranslations("demoPage.metadata"); const mapDataId = await resolveMapDataId(DEMO_MAP_ID); const mapName = await prisma.matchStart.findFirst({ @@ -64,7 +62,7 @@ export async function generateMetadata( height: 630, }, ], - locale: params.locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/demo/player/[playerId]/page.tsx b/apps/web/src/app/demo/player/[playerId]/page.tsx index f63e86f7d..2ff37a59b 100644 --- a/apps/web/src/app/demo/player/[playerId]/page.tsx +++ b/apps/web/src/app/demo/player/[playerId]/page.tsx @@ -11,7 +11,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { PlayerService } from "@/data/player"; +import { defaultLocale } from "@/i18n/config"; import { resolveMapDataId } from "@/lib/map-data-resolver"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import { toTitleCase } from "@/lib/utils"; import type { PagePropsWithLocale } from "@/types/next"; @@ -32,10 +34,7 @@ export async function generateMetadata( props: PagePropsWithLocale<"/demo/player/[playerId]"> ): Promise { const params = await props.params; - const t = await getTranslations({ - locale: params.locale, - namespace: "mapPage.playerMetadata", - }); + const t = getMetadataTranslations("mapPage.playerMetadata"); const playerName = decodePlayerId(params.playerId) ?? "Player"; return { @@ -56,7 +55,7 @@ export async function generateMetadata( height: 630, }, ], - locale: params.locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/faceit/page.tsx b/apps/web/src/app/faceit/page.tsx index aa9922fcf..3bb4356b5 100644 --- a/apps/web/src/app/faceit/page.tsx +++ b/apps/web/src/app/faceit/page.tsx @@ -3,12 +3,13 @@ import { AppRuntime } from "@/data/runtime"; import { FaceitTeamScoutingService } from "@/data/faceit"; import { Effect } from "effect"; import { faceitScouting } from "@/lib/flags"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; export async function generateMetadata(): Promise { - const t = await getTranslations("faceitScoutingPage"); + const t = getMetadataTranslations("faceitScoutingPage"); return { title: t("metadata.searchTitle"), description: t("subtitle") }; } diff --git a/apps/web/src/app/faceit/player/page.tsx b/apps/web/src/app/faceit/player/page.tsx index 39041d3fd..6c8920e25 100644 --- a/apps/web/src/app/faceit/player/page.tsx +++ b/apps/web/src/app/faceit/player/page.tsx @@ -3,12 +3,13 @@ import { AppRuntime } from "@/data/runtime"; import { FaceitPlayerScoutingService } from "@/data/faceit"; import { Effect } from "effect"; import { faceitScouting } from "@/lib/flags"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; export async function generateMetadata(): Promise { - const t = await getTranslations("faceitPlayerPage"); + const t = getMetadataTranslations("faceitPlayerPage"); return { title: t("metadata.searchTitle"), description: t("subtitle") }; } diff --git a/apps/web/src/app/leaderboard/csr/page.tsx b/apps/web/src/app/leaderboard/csr/page.tsx index 5b89dd7ac..0494a0cdc 100644 --- a/apps/web/src/app/leaderboard/csr/page.tsx +++ b/apps/web/src/app/leaderboard/csr/page.tsx @@ -8,13 +8,14 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { getCompositeSRLeaderboard } from "@/lib/hero-rating"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import { getHeroNames, toHero } from "@/lib/utils"; import { heroRoleMapping, type HeroName } from "@/types/heroes"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; export async function generateMetadata(): Promise { - const t = await getTranslations("leaderboardPage.csrPage.metadata"); + const t = getMetadataTranslations("leaderboardPage.csrPage.metadata"); return { title: t("title"), description: t("description"), diff --git a/apps/web/src/app/leaderboard/layout.tsx b/apps/web/src/app/leaderboard/layout.tsx index b5c90695b..03966a69f 100644 --- a/apps/web/src/app/leaderboard/layout.tsx +++ b/apps/web/src/app/leaderboard/layout.tsx @@ -1,10 +1,10 @@ import { DashboardLayout } from "@/components/dashboard-layout"; +import { defaultLocale } from "@/i18n/config"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getLocale, getTranslations } from "next-intl/server"; -export async function generateMetadata(): Promise { - const t = await getTranslations("leaderboardPage.metadata"); - const locale = await getLocale(); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("leaderboardPage.metadata"); return { title: t("title"), @@ -22,7 +22,7 @@ export async function generateMetadata(): Promise { height: 630, }, ], - locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/leaderboard/page.tsx b/apps/web/src/app/leaderboard/page.tsx index 0105aed7c..5ed7e3a8f 100644 --- a/apps/web/src/app/leaderboard/page.tsx +++ b/apps/web/src/app/leaderboard/page.tsx @@ -2,12 +2,13 @@ import { LeaderboardHub, type MetricStats, } from "@/components/leaderboard/leaderboard-hub"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import { getInitialTsrLeaderboard } from "@/lib/tsr/leaderboard"; import type { Metadata } from "next"; import { getFormatter, getTranslations } from "next-intl/server"; -export async function generateMetadata(): Promise { - const t = await getTranslations("leaderboardPage.hub.metadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("leaderboardPage.hub.metadata"); return { title: t("title"), description: t("description"), diff --git a/apps/web/src/app/matchmaker/layout.tsx b/apps/web/src/app/matchmaker/layout.tsx index 9fdc6cabd..0990ed095 100644 --- a/apps/web/src/app/matchmaker/layout.tsx +++ b/apps/web/src/app/matchmaker/layout.tsx @@ -1,9 +1,9 @@ import { DashboardLayout } from "@/components/dashboard-layout"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; export async function generateMetadata(): Promise { - const t = await getTranslations("matchmaker.metadata"); + const t = getMetadataTranslations("matchmaker.metadata"); return { title: t("title"), diff --git a/apps/web/src/app/matchmaker/page.tsx b/apps/web/src/app/matchmaker/page.tsx index 1dc5b0a68..217e12237 100644 --- a/apps/web/src/app/matchmaker/page.tsx +++ b/apps/web/src/app/matchmaker/page.tsx @@ -5,12 +5,12 @@ import { MatchmakerHub, type HubTeam, } from "@/components/matchmaker/matchmaker-hub"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import { getTierBucket } from "@/lib/tsr/tier-bucket"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; export async function generateMetadata(): Promise { - const t = await getTranslations("matchmaker.metadata"); + const t = getMetadataTranslations("matchmaker.metadata"); return { title: t("title"), description: t("description"), diff --git a/apps/web/src/app/notifications/layout.tsx b/apps/web/src/app/notifications/layout.tsx index 54ad8a2b2..0c491f821 100644 --- a/apps/web/src/app/notifications/layout.tsx +++ b/apps/web/src/app/notifications/layout.tsx @@ -1,9 +1,9 @@ import { DashboardLayout } from "@/components/dashboard-layout"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; -export async function generateMetadata(): Promise { - const t = await getTranslations("notifications.metadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("notifications.metadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/app/pricing/page.tsx b/apps/web/src/app/pricing/page.tsx index f7589c2ad..3e586db38 100644 --- a/apps/web/src/app/pricing/page.tsx +++ b/apps/web/src/app/pricing/page.tsx @@ -15,18 +15,15 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import { toTitleCase } from "@/lib/utils"; import type { Metadata, Route } from "next"; -import { getLocale, getTranslations } from "next-intl/server"; +import { getTranslations } from "next-intl/server"; import { Instrument_Serif } from "next/font/google"; import type { SVGProps } from "react"; -export async function generateMetadata(): Promise { - const locale = await getLocale(); - const t = await getTranslations({ - locale, - namespace: "pricingPage.metadata", - }); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("pricingPage.metadata"); return { title: t("title"), diff --git a/apps/web/src/app/privacy/layout.tsx b/apps/web/src/app/privacy/layout.tsx index fe8f25e67..38bf44ed0 100644 --- a/apps/web/src/app/privacy/layout.tsx +++ b/apps/web/src/app/privacy/layout.tsx @@ -1,14 +1,11 @@ import { Footer } from "@/components/footer"; import { Header } from "@/components/header"; +import { defaultLocale } from "@/i18n/config"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getLocale, getTranslations } from "next-intl/server"; -export async function generateMetadata(): Promise { - const locale = await getLocale(); - const t = await getTranslations({ - locale, - namespace: "privacyPage.metadata", - }); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("privacyPage.metadata"); return { title: t("title"), @@ -27,7 +24,7 @@ export async function generateMetadata(): Promise { height: 630, }, ], - locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/profile/[playerName]/layout.tsx b/apps/web/src/app/profile/[playerName]/layout.tsx index bf7b9d06d..556434742 100644 --- a/apps/web/src/app/profile/[playerName]/layout.tsx +++ b/apps/web/src/app/profile/[playerName]/layout.tsx @@ -1,13 +1,13 @@ import { DashboardLayout } from "@/components/dashboard-layout"; +import { defaultLocale } from "@/i18n/config"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getLocale, getTranslations } from "next-intl/server"; export async function generateMetadata( props: LayoutProps<"/profile/[playerName]"> ): Promise { const params = await props.params; - const t = await getTranslations("profilePage.layoutMetadata"); - const locale = await getLocale(); + const t = getMetadataTranslations("profilePage.layoutMetadata"); const playerName = decodeURIComponent(params.playerName); return { @@ -26,7 +26,7 @@ export async function generateMetadata( height: 630, }, ], - locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/query/page.tsx b/apps/web/src/app/query/page.tsx index 9cc1d6e0b..3e03dcfba 100644 --- a/apps/web/src/app/query/page.tsx +++ b/apps/web/src/app/query/page.tsx @@ -2,13 +2,14 @@ import { DashboardLayout } from "@/components/dashboard-layout"; import { QueryBuilder } from "@/components/query-builder/query-builder"; import { auth } from "@/lib/auth"; import { queryBuilder } from "@/lib/flags"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import { getViewableTeams, listSavedQueries } from "@/lib/query-builder/server"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound, redirect } from "next/navigation"; -export async function generateMetadata(): Promise { - const t = await getTranslations("queryBuilderPage.metadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("queryBuilderPage.metadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/app/ranked/page.tsx b/apps/web/src/app/ranked/page.tsx index fd563169e..c1631c7fb 100644 --- a/apps/web/src/app/ranked/page.tsx +++ b/apps/web/src/app/ranked/page.tsx @@ -15,18 +15,15 @@ import { RankedService } from "@/data/ranked"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; -import type { PagePropsWithLocale } from "@/types/next"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import { Effect } from "effect"; import { Crosshair, Plus } from "lucide-react"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; -export async function generateMetadata( - props: PagePropsWithLocale<"/ranked"> -): Promise { - const { locale } = await props.params; - const t = await getTranslations({ locale, namespace: "ranked.metadata" }); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("ranked.metadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/app/reports/layout.tsx b/apps/web/src/app/reports/layout.tsx index dd7b06b3a..767ab710e 100644 --- a/apps/web/src/app/reports/layout.tsx +++ b/apps/web/src/app/reports/layout.tsx @@ -1,9 +1,9 @@ import { DashboardLayout } from "@/components/dashboard-layout"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; -export async function generateMetadata(): Promise { - const t = await getTranslations("reportsPage.metadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("reportsPage.metadata"); return { title: t("title"), diff --git a/apps/web/src/app/scouting/layout.tsx b/apps/web/src/app/scouting/layout.tsx index b4029c0df..bc5b09911 100644 --- a/apps/web/src/app/scouting/layout.tsx +++ b/apps/web/src/app/scouting/layout.tsx @@ -1,12 +1,10 @@ import { DashboardLayout } from "@/components/dashboard-layout"; +import { defaultLocale } from "@/i18n/config"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; -export async function generateMetadata( - props: LayoutProps<"/scouting"> -): Promise { - const params = (await props.params) as { locale: string }; - const t = await getTranslations("scoutingPage.metadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("scoutingPage.metadata"); return { title: t("title"), @@ -24,7 +22,7 @@ export async function generateMetadata( height: 630, }, ], - locale: params.locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/scouting/page.tsx b/apps/web/src/app/scouting/page.tsx index 44ba637cb..30f7f1045 100644 --- a/apps/web/src/app/scouting/page.tsx +++ b/apps/web/src/app/scouting/page.tsx @@ -3,12 +3,13 @@ import { AppRuntime } from "@/data/runtime"; import { ScoutingService } from "@/data/scouting"; import { Effect } from "effect"; import { scoutingTool } from "@/lib/flags"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; -export async function generateMetadata(): Promise { - const t = await getTranslations("scoutingPage.metadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("scoutingPage.metadata"); return { title: t("title"), description: t("description"), diff --git a/apps/web/src/app/scouting/player/[slug]/layout.tsx b/apps/web/src/app/scouting/player/[slug]/layout.tsx index 0eac47411..27d6b234c 100644 --- a/apps/web/src/app/scouting/player/[slug]/layout.tsx +++ b/apps/web/src/app/scouting/player/[slug]/layout.tsx @@ -1,12 +1,12 @@ +import { defaultLocale } from "@/i18n/config"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getLocale, getTranslations } from "next-intl/server"; export async function generateMetadata( props: LayoutProps<"/scouting/player/[slug]"> ): Promise { const params = await props.params; - const t = await getTranslations("scoutingPage.player.profile.metadata"); - const locale = await getLocale(); + const t = getMetadataTranslations("scoutingPage.player.profile.metadata"); const player = decodeURIComponent(params.slug); @@ -26,7 +26,7 @@ export async function generateMetadata( height: 630, }, ], - locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/scouting/player/page.tsx b/apps/web/src/app/scouting/player/page.tsx index 17e714790..d44a18fbe 100644 --- a/apps/web/src/app/scouting/player/page.tsx +++ b/apps/web/src/app/scouting/player/page.tsx @@ -3,12 +3,13 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { ScoutingService } from "@/data/player"; import { scoutingTool } from "@/lib/flags"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; -export async function generateMetadata(): Promise { - const t = await getTranslations("scoutingPage.player.metadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("scoutingPage.player.metadata"); return { title: t("title"), description: t("description"), diff --git a/apps/web/src/app/scouting/team/[teamAbbr]/layout.tsx b/apps/web/src/app/scouting/team/[teamAbbr]/layout.tsx index 647fc47dc..1095beafe 100644 --- a/apps/web/src/app/scouting/team/[teamAbbr]/layout.tsx +++ b/apps/web/src/app/scouting/team/[teamAbbr]/layout.tsx @@ -1,12 +1,12 @@ +import { defaultLocale } from "@/i18n/config"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getLocale, getTranslations } from "next-intl/server"; export async function generateMetadata( props: LayoutProps<"/scouting/team/[teamAbbr]"> ): Promise { const params = await props.params; - const t = await getTranslations("scoutingPage.team.metadata"); - const locale = await getLocale(); + const t = getMetadataTranslations("scoutingPage.team.metadata"); const team = decodeURIComponent(params.teamAbbr); @@ -26,7 +26,7 @@ export async function generateMetadata( height: 630, }, ], - locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/settings/layout.tsx b/apps/web/src/app/settings/layout.tsx index 6f30bbd27..08c3a2e90 100644 --- a/apps/web/src/app/settings/layout.tsx +++ b/apps/web/src/app/settings/layout.tsx @@ -4,16 +4,15 @@ import { Separator } from "@/components/ui/separator"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; +import { defaultLocale } from "@/i18n/config"; import { auth } from "@/lib/auth"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import { $Enums } from "@/generated/prisma/browser"; import type { Metadata, Route } from "next"; import { getTranslations } from "next-intl/server"; -export async function generateMetadata( - props: LayoutProps<"/settings"> -): Promise { - const params = (await props.params) as { locale: string }; - const t = await getTranslations("settingsPage.metadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("settingsPage.metadata"); return { title: t("title"), @@ -31,7 +30,7 @@ export async function generateMetadata( height: 630, }, ], - locale: params.locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/stats/[playerName]/page.tsx b/apps/web/src/app/stats/[playerName]/page.tsx index acd3eb24e..d655a5420 100644 --- a/apps/web/src/app/stats/[playerName]/page.tsx +++ b/apps/web/src/app/stats/[playerName]/page.tsx @@ -11,6 +11,8 @@ import { UserService } from "@/data/user"; import { auth, getViewableScrimIds } from "@/lib/auth"; import { Permission } from "@/lib/permissions"; import prisma from "@/lib/prisma"; +import { defaultLocale } from "@/i18n/config"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { PagePropsWithLocale } from "@/types/next"; import type { Kill, PlayerStat, Scrim } from "@/generated/prisma/client"; import type { Metadata } from "next"; @@ -21,7 +23,7 @@ export async function generateMetadata( props: PagePropsWithLocale<"/stats/[playerName]"> ): Promise { const params = await props.params; - const t = await getTranslations("statsPage.playerMetadata"); + const t = getMetadataTranslations("statsPage.playerMetadata"); const playerName = decodeURIComponent(params.playerName); const suffix = playerName.endsWith("s") ? "'" : "'s"; @@ -44,7 +46,7 @@ export async function generateMetadata( height: 630, }, ], - locale: params.locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/stats/hero/[heroName]/page.tsx b/apps/web/src/app/stats/hero/[heroName]/page.tsx index d99621bc0..34112c8d1 100644 --- a/apps/web/src/app/stats/hero/[heroName]/page.tsx +++ b/apps/web/src/app/stats/hero/[heroName]/page.tsx @@ -9,9 +9,11 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth, getViewableScrimIds } from "@/lib/auth"; +import { defaultLocale } from "@/i18n/config"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import { Permission } from "@/lib/permissions"; import prisma from "@/lib/prisma"; -import { translateHeroName } from "@/lib/utils"; +import { toHero, translateHeroName } from "@/lib/utils"; import { type HeroName, heroRoleMapping } from "@/types/heroes"; import type { PagePropsWithLocale } from "@/types/next"; import type { Kill, PlayerStat, Scrim } from "@/generated/prisma/client"; @@ -24,8 +26,8 @@ export async function generateMetadata( ): Promise { const params = await props.params; const heroName = decodeURIComponent(params.heroName); - const hero = await translateHeroName(heroName); - const t = await getTranslations("statsPage.heroMetadata"); + const hero = getMetadataTranslations("heroes")(toHero(heroName)); + const t = getMetadataTranslations("statsPage.heroMetadata"); return { title: t("title", { hero }), @@ -43,7 +45,7 @@ export async function generateMetadata( height: 630, }, ], - locale: params.locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/stats/hero/page.tsx b/apps/web/src/app/stats/hero/page.tsx index c24bab518..1cdcf5581 100644 --- a/apps/web/src/app/stats/hero/page.tsx +++ b/apps/web/src/app/stats/hero/page.tsx @@ -1,17 +1,15 @@ import { StatPanel } from "@/components/player/stat-panel"; import { Link } from "@/components/ui/link"; +import { defaultLocale } from "@/i18n/config"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import { getHeroNames, toHero } from "@/lib/utils"; import { type HeroName, roleHeroMapping } from "@/types/heroes"; -import type { PagePropsWithLocale } from "@/types/next"; import type { Metadata, Route } from "next"; import { getTranslations } from "next-intl/server"; import Image from "next/image"; -export async function generateMetadata( - props: PagePropsWithLocale<"/stats/hero"> -): Promise { - const params = await props.params; - const t = await getTranslations("statsPage.heroStatsMetadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("statsPage.heroStatsMetadata"); return { title: t("title"), description: t("description"), @@ -28,7 +26,7 @@ export async function generateMetadata( height: 630, }, ], - locale: params.locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/stats/layout.tsx b/apps/web/src/app/stats/layout.tsx index 024a3513b..d98e8ff70 100644 --- a/apps/web/src/app/stats/layout.tsx +++ b/apps/web/src/app/stats/layout.tsx @@ -1,12 +1,10 @@ import { DashboardLayout } from "@/components/dashboard-layout"; +import { defaultLocale } from "@/i18n/config"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; -export async function generateMetadata( - props: LayoutProps<"/stats"> -): Promise { - const params = (await props.params) as { locale: string }; - const t = await getTranslations("statsPage.layoutMetadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("statsPage.layoutMetadata"); return { title: t("title"), @@ -24,7 +22,7 @@ export async function generateMetadata( height: 630, }, ], - locale: params.locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/stats/team/[teamId]/layout.tsx b/apps/web/src/app/stats/team/[teamId]/layout.tsx index ddf357b8f..6b422f1b3 100644 --- a/apps/web/src/app/stats/team/[teamId]/layout.tsx +++ b/apps/web/src/app/stats/team/[teamId]/layout.tsx @@ -2,18 +2,18 @@ import { RangeTransitionProvider } from "@/components/stats/team/range-transitio import { TeamStatsContent } from "@/components/stats/team/team-stats-content"; import { TeamStatsHeaderClient } from "@/components/stats/team/team-stats-header-client"; import { TeamStatsTabsNav } from "@/components/stats/team/team-stats-tabs-nav"; +import { defaultLocale } from "@/i18n/config"; import { isAuthedToViewTeam } from "@/lib/auth"; import { positionalData, simulationTool } from "@/lib/flags"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import type { Metadata } from "next"; -import { getLocale, getTranslations } from "next-intl/server"; export async function generateMetadata( props: LayoutProps<"/stats/team/[teamId]"> ): Promise { const params = await props.params; - const t = await getTranslations("teamStatsPage.layoutMetadata"); - const locale = await getLocale(); + const t = getMetadataTranslations("teamStatsPage.layoutMetadata"); const teamId = parseInt(params.teamId); const canViewTeam = @@ -45,7 +45,7 @@ export async function generateMetadata( height: 630, }, ], - locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/team/[teamId]/(app)/availability/page.tsx b/apps/web/src/app/team/[teamId]/(app)/availability/page.tsx index 0bd7726cc..cfefdb308 100644 --- a/apps/web/src/app/team/[teamId]/(app)/availability/page.tsx +++ b/apps/web/src/app/team/[teamId]/(app)/availability/page.tsx @@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { isTeamOwnerOrManager } from "@/lib/auth"; import { weekEndInTz, weekStartInTz } from "@/lib/availability/tz"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import { revalidatePath } from "next/cache"; import Link from "next/link"; @@ -11,8 +12,8 @@ import type { Metadata } from "next"; type PageProps = { params: Promise<{ teamId: string }> }; -export async function generateMetadata(): Promise { - const t = await getTranslations("availability.metadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("availability.metadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/app/team/[teamId]/(app)/availability/settings/page.tsx b/apps/web/src/app/team/[teamId]/(app)/availability/settings/page.tsx index bde11d063..c3aecb0c9 100644 --- a/apps/web/src/app/team/[teamId]/(app)/availability/settings/page.tsx +++ b/apps/web/src/app/team/[teamId]/(app)/availability/settings/page.tsx @@ -1,5 +1,6 @@ import { AvailabilitySettingsForm } from "@/components/availability/availability-settings-form"; import { isTeamOwnerOrManager } from "@/lib/auth"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import { notFound, redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; @@ -7,8 +8,8 @@ import type { Metadata } from "next"; type PageProps = { params: Promise<{ teamId: string }> }; -export async function generateMetadata(): Promise { - const t = await getTranslations("availability.settingsPage.metadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("availability.settingsPage.metadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/app/team/[teamId]/(app)/page.tsx b/apps/web/src/app/team/[teamId]/(app)/page.tsx index 871d61dce..cc11ab41a 100644 --- a/apps/web/src/app/team/[teamId]/(app)/page.tsx +++ b/apps/web/src/app/team/[teamId]/(app)/page.tsx @@ -15,8 +15,10 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { ScoutingService } from "@/data/scouting"; import { UserService } from "@/data/user"; +import { defaultLocale } from "@/i18n/config"; import { auth, isAuthedToViewTeam } from "@/lib/auth"; import { scoutingTool } from "@/lib/flags"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import { computeTeamTsr } from "@/lib/tsr/team"; import type { PagePropsWithLocale } from "@/types/next"; @@ -29,7 +31,7 @@ export async function generateMetadata( props: PagePropsWithLocale<"/team/[teamId]"> ): Promise { const params = await props.params; - const t = await getTranslations("teamPage.teamMetadata"); + const t = getMetadataTranslations("teamPage.teamMetadata"); const teamId = Number(params.teamId); const canViewTeam = Number.isSafeInteger(teamId) && @@ -61,7 +63,7 @@ export async function generateMetadata( height: 630, }, ], - locale: params.locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/team/[teamId]/(app)/targets/page.tsx b/apps/web/src/app/team/[teamId]/(app)/targets/page.tsx index 741682a45..f4c8eff83 100644 --- a/apps/web/src/app/team/[teamId]/(app)/targets/page.tsx +++ b/apps/web/src/app/team/[teamId]/(app)/targets/page.tsx @@ -10,6 +10,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import type { RoleName } from "@/lib/target-stats"; import { type HeroName, heroRoleMapping } from "@/types/heroes"; @@ -21,8 +22,8 @@ type Props = { params: Promise<{ teamId: string }>; }; -export async function generateMetadata(): Promise { - const t = await getTranslations("targets.metadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("targets.metadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/app/team/[teamId]/availability/[scheduleId]/page.tsx b/apps/web/src/app/team/[teamId]/availability/[scheduleId]/page.tsx index 36ac9f896..76d9c31c2 100644 --- a/apps/web/src/app/team/[teamId]/availability/[scheduleId]/page.tsx +++ b/apps/web/src/app/team/[teamId]/availability/[scheduleId]/page.tsx @@ -1,16 +1,16 @@ import { AvailabilityFillView } from "@/components/availability/availability-fill-view"; import { auth } from "@/lib/auth"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; type PageProps = { params: Promise<{ teamId: string; scheduleId: string }>; }; -export async function generateMetadata(): Promise { - const t = await getTranslations("availability.fillView.metadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("availability.fillView.metadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/app/team/join/layout.tsx b/apps/web/src/app/team/join/layout.tsx index a025b8e3c..5dfb125b4 100644 --- a/apps/web/src/app/team/join/layout.tsx +++ b/apps/web/src/app/team/join/layout.tsx @@ -1,9 +1,9 @@ import { DashboardLayout } from "@/components/dashboard-layout"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; -export async function generateMetadata(): Promise { - const t = await getTranslations("teamPage.joinMetadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("teamPage.joinMetadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/app/team/page.tsx b/apps/web/src/app/team/page.tsx index 0d2da155e..15b178044 100644 --- a/apps/web/src/app/team/page.tsx +++ b/apps/web/src/app/team/page.tsx @@ -6,19 +6,17 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; +import { defaultLocale } from "@/i18n/config"; import { auth } from "@/lib/auth"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; -import type { PagePropsWithLocale } from "@/types/next"; import { $Enums } from "@/generated/prisma/browser"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; -export async function generateMetadata( - props: PagePropsWithLocale<"/team"> -): Promise { - const params = await props.params; - const t = await getTranslations("teamPage.metadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("teamPage.metadata"); return { title: t("title"), description: t("description"), @@ -35,7 +33,7 @@ export async function generateMetadata( height: 630, }, ], - locale: params.locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/terms-of-service/layout.tsx b/apps/web/src/app/terms-of-service/layout.tsx index 2fa29cbd9..bd1c14463 100644 --- a/apps/web/src/app/terms-of-service/layout.tsx +++ b/apps/web/src/app/terms-of-service/layout.tsx @@ -1,14 +1,11 @@ import { Footer } from "@/components/footer"; import { Header } from "@/components/header"; +import { defaultLocale } from "@/i18n/config"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getLocale, getTranslations } from "next-intl/server"; -export async function generateMetadata(): Promise { - const locale = await getLocale(); - const t = await getTranslations({ - locale, - namespace: "termsPage.metadata", - }); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("termsPage.metadata"); return { title: t("title"), @@ -27,7 +24,7 @@ export async function generateMetadata(): Promise { height: 630, }, ], - locale, + locale: defaultLocale, }, }; } diff --git a/apps/web/src/app/tournaments/create/page.tsx b/apps/web/src/app/tournaments/create/page.tsx index 3c364d986..4bd9994f9 100644 --- a/apps/web/src/app/tournaments/create/page.tsx +++ b/apps/web/src/app/tournaments/create/page.tsx @@ -1,12 +1,12 @@ import { DashboardLayout } from "@/components/dashboard-layout"; import { CreateTournamentButton } from "@/components/tournament/create-tournament-button"; import { tournament } from "@/lib/flags"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; -export async function generateMetadata(): Promise { - const t = await getTranslations("tournamentsPage.create.metadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("tournamentsPage.create.metadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/app/tournaments/page.tsx b/apps/web/src/app/tournaments/page.tsx index 9b79fe66d..5786ab1c4 100644 --- a/apps/web/src/app/tournaments/page.tsx +++ b/apps/web/src/app/tournaments/page.tsx @@ -6,12 +6,12 @@ import { AppRuntime } from "@/data/runtime"; import { TournamentService } from "@/data/tournament"; import { auth } from "@/lib/auth"; import { tournament } from "@/lib/flags"; +import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; import { notFound, redirect } from "next/navigation"; -export async function generateMetadata(): Promise { - const t = await getTranslations("tournamentsPage.metadata"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("tournamentsPage.metadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/components/leaderboard/tsr-leaderboard.tsx b/apps/web/src/components/leaderboard/tsr-leaderboard.tsx index 160899bae..0e878dca0 100644 --- a/apps/web/src/components/leaderboard/tsr-leaderboard.tsx +++ b/apps/web/src/components/leaderboard/tsr-leaderboard.tsx @@ -61,9 +61,16 @@ const COLS = const PAGE_SIZE = 50; -function formatRelativeDate(d: Date | null, t: TsrMessages): string { - if (!d) return "—"; - const days = Math.floor((Date.now() - d.getTime()) / 86_400_000); +// `now` is resolved after mount (see the `now` state below) so the prerender +// pass stays deterministic — reading the current time during render is +// disallowed under Cache Components. +function formatRelativeDate( + d: Date | null, + t: TsrMessages, + now: number | null +): string { + if (!d || now === null) return "—"; + const days = Math.floor((now - d.getTime()) / 86_400_000); if (days < 1) return t("relative.today"); if (days < 7) return t("relative.days", { count: days }); if (days < 30) return t("relative.weeks", { count: Math.floor(days / 7) }); @@ -71,9 +78,13 @@ function formatRelativeDate(d: Date | null, t: TsrMessages): string { return t("relative.years", { count: Math.floor(days / 365) }); } -function formatComputedAt(d: Date | null, t: TsrMessages): string | null { - if (!d) return null; - const minutes = Math.floor((Date.now() - d.getTime()) / 60_000); +function formatComputedAt( + d: Date | null, + t: TsrMessages, + now: number | null +): string | null { + if (!d || now === null) return null; + const minutes = Math.floor((now - d.getTime()) / 60_000); if (minutes < 1) return t("relative.justNow"); if (minutes < 60) return t("relative.minutesAgo", { count: minutes }); const hours = Math.floor(minutes / 60); @@ -180,6 +191,10 @@ async function fetchSnapshot( export function TsrLeaderboard({ initialSnapshot }: Props) { const t = useTranslations("leaderboardPage.tsr"); const formatter = useFormatter(); + // Resolved after mount so relative-time labels don't read the clock during + // the prerender pass (disallowed under Cache Components). + const [now, setNow] = useState(null); + useEffect(() => setNow(Date.now()), []); const [region, setRegion] = useState("All"); const [tier, setTier] = useState("All"); const [sortKey, setSortKey] = useState("rating"); @@ -237,7 +252,7 @@ export function TsrLeaderboard({ initialSnapshot }: Props) { } }, [result.data, offset]); - const computedAt = formatComputedAt(snapshot.meta.computedAt, t); + const computedAt = formatComputedAt(snapshot.meta.computedAt, t, now); const showInactive = debouncedQuery.trim() !== ""; const [selectedId, setSelectedId] = useState(null); @@ -595,7 +610,7 @@ function PlayerRow({
- {formatRelativeDate(row.lastMatchAt, t)} + {formatRelativeDate(row.lastMatchAt, t, now)}
); From 88e004054f2ad23b777a161a1877b213252d339d Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 02:52:17 -0400 Subject: [PATCH 07/59] Skip OpenTelemetry setup during the build phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/src/instrumentation.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/src/instrumentation.ts b/apps/web/src/instrumentation.ts index 258741260..1ca430c22 100644 --- a/apps/web/src/instrumentation.ts +++ b/apps/web/src/instrumentation.ts @@ -7,6 +7,12 @@ type RequestErrorArgs = Parameters; export async function register() { if (process.env.NEXT_RUNTIME !== "nodejs") return; + // OpenTelemetry generates span/trace IDs with Math.random(), which Cache + // Components disallows during prerendering. Build-time prerendering needs no + // telemetry, so skip tracer setup in the build phase to keep prerenders + // deterministic. Runtime requests still register and export traces. + if (process.env.NEXT_PHASE === "phase-production-build") return; + const { registerNode } = await import("./instrumentation-node"); registerNode(); } From fff2bce442df00547eae5d2b9720f4ae7652a6a9 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 02:53:37 -0400 Subject: [PATCH 08/59] Cache the map hero trends page data 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. --- apps/web/src/app/stats/map/page.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/stats/map/page.tsx b/apps/web/src/app/stats/map/page.tsx index 1a20cfe7a..8a2c8a7af 100644 --- a/apps/web/src/app/stats/map/page.tsx +++ b/apps/web/src/app/stats/map/page.tsx @@ -4,13 +4,20 @@ import { getOverwatchPatches } from "@/data/overwatch/patches-service"; import { AppRuntime } from "@/data/runtime"; import { Effect } from "effect"; import type { Metadata } from "next"; +import { cacheLife } from "next/cache"; export const metadata: Metadata = { title: "Map Hero Trends | Parsertime", description: "Recent hero pick rates, winrates, and playtime trends by map.", }; -export default async function MapHeroStatsPage() { +// Recent trends are an expensive aggregation that moves slowly; cache them so +// the page prerenders. The service's time-window `Date.now()` resolves once at +// cache fill, which Cache Components permits inside `use cache`. +async function getMapHeroStats() { + "use cache"; + cacheLife("hours"); + const [data, patches] = await Promise.all([ AppRuntime.runPromise( MapHeroTrendsService.pipe( @@ -19,6 +26,11 @@ export default async function MapHeroStatsPage() { ), getOverwatchPatches(), ]); + return { data, patches }; +} + +export default async function MapHeroStatsPage() { + const { data, patches } = await getMapHeroStats(); return ( Date: Fri, 26 Jun 2026 02:56:17 -0400 Subject: [PATCH 09/59] Render the map hero trends page at request time 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). --- apps/web/src/app/stats/map/page.tsx | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/apps/web/src/app/stats/map/page.tsx b/apps/web/src/app/stats/map/page.tsx index 8a2c8a7af..6fb1a8db7 100644 --- a/apps/web/src/app/stats/map/page.tsx +++ b/apps/web/src/app/stats/map/page.tsx @@ -4,20 +4,18 @@ import { getOverwatchPatches } from "@/data/overwatch/patches-service"; import { AppRuntime } from "@/data/runtime"; import { Effect } from "effect"; import type { Metadata } from "next"; -import { cacheLife } from "next/cache"; +import { connection } from "next/server"; export const metadata: Metadata = { title: "Map Hero Trends | Parsertime", description: "Recent hero pick rates, winrates, and playtime trends by map.", }; -// Recent trends are an expensive aggregation that moves slowly; cache them so -// the page prerenders. The service's time-window `Date.now()` resolves once at -// cache fill, which Cache Components permits inside `use cache`. -async function getMapHeroStats() { - "use cache"; - cacheLife("hours"); - +export default async function MapHeroStatsPage() { + // The trends service derives a time window from Date.now() and localizes map + // names via the request locale, so this page renders at request time. Reading + // the connection first keeps that allowed under Cache Components. + await connection(); const [data, patches] = await Promise.all([ AppRuntime.runPromise( MapHeroTrendsService.pipe( @@ -26,11 +24,6 @@ async function getMapHeroStats() { ), getOverwatchPatches(), ]); - return { data, patches }; -} - -export default async function MapHeroStatsPage() { - const { data, patches } = await getMapHeroStats(); return ( Date: Fri, 26 Jun 2026 03:10:37 -0400 Subject: [PATCH 10/59] Clean up metadata migration types and lint 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. --- apps/web/src/app/(auth)/auth-error/layout.tsx | 2 +- apps/web/src/app/(auth)/verify-request/layout.tsx | 2 +- apps/web/src/app/chat/layout.tsx | 2 +- apps/web/src/app/coaching/layout.tsx | 2 +- apps/web/src/app/data-labeling/layout.tsx | 2 +- apps/web/src/app/debug/layout.tsx | 2 +- apps/web/src/app/faceit/page.tsx | 2 +- apps/web/src/app/faceit/player/page.tsx | 2 +- apps/web/src/app/leaderboard/csr/page.tsx | 2 +- apps/web/src/app/matchmaker/layout.tsx | 2 +- apps/web/src/app/matchmaker/page.tsx | 2 +- apps/web/src/components/leaderboard/tsr-leaderboard.tsx | 3 +++ apps/web/src/lib/metadata-i18n.ts | 5 ++++- 13 files changed, 18 insertions(+), 12 deletions(-) diff --git a/apps/web/src/app/(auth)/auth-error/layout.tsx b/apps/web/src/app/(auth)/auth-error/layout.tsx index 435a7f6bc..327562839 100644 --- a/apps/web/src/app/(auth)/auth-error/layout.tsx +++ b/apps/web/src/app/(auth)/auth-error/layout.tsx @@ -2,7 +2,7 @@ import { Footer } from "@/components/footer"; import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -export async function generateMetadata(): Promise { +export function generateMetadata(): Metadata { const t = getMetadataTranslations("authError.metadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/app/(auth)/verify-request/layout.tsx b/apps/web/src/app/(auth)/verify-request/layout.tsx index 8e4acf340..933cd25d1 100644 --- a/apps/web/src/app/(auth)/verify-request/layout.tsx +++ b/apps/web/src/app/(auth)/verify-request/layout.tsx @@ -2,7 +2,7 @@ import { Footer } from "@/components/footer"; import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -export async function generateMetadata(): Promise { +export function generateMetadata(): Metadata { const t = getMetadataTranslations("verifyRequest.metadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/app/chat/layout.tsx b/apps/web/src/app/chat/layout.tsx index 6d19a6058..dc1b1f5a2 100644 --- a/apps/web/src/app/chat/layout.tsx +++ b/apps/web/src/app/chat/layout.tsx @@ -3,7 +3,7 @@ import { DashboardLayout } from "@/components/dashboard-layout"; import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -export async function generateMetadata(): Promise { +export function generateMetadata(): Metadata { const t = getMetadataTranslations("analyst.metadata"); return { title: t("title"), diff --git a/apps/web/src/app/coaching/layout.tsx b/apps/web/src/app/coaching/layout.tsx index f9b00523c..207c3c4ea 100644 --- a/apps/web/src/app/coaching/layout.tsx +++ b/apps/web/src/app/coaching/layout.tsx @@ -2,7 +2,7 @@ import { DashboardLayout } from "@/components/dashboard-layout"; import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -export async function generateMetadata(): Promise { +export function generateMetadata(): Metadata { const t = getMetadataTranslations("coaching.metadata"); return { diff --git a/apps/web/src/app/data-labeling/layout.tsx b/apps/web/src/app/data-labeling/layout.tsx index 20e360d26..ade472529 100644 --- a/apps/web/src/app/data-labeling/layout.tsx +++ b/apps/web/src/app/data-labeling/layout.tsx @@ -2,7 +2,7 @@ import { DashboardLayout } from "@/components/dashboard-layout"; import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -export async function generateMetadata(): Promise { +export function generateMetadata(): Metadata { const t = getMetadataTranslations("dataLabeling.metadata"); return { diff --git a/apps/web/src/app/debug/layout.tsx b/apps/web/src/app/debug/layout.tsx index ceb9ac52f..1d218e4b0 100644 --- a/apps/web/src/app/debug/layout.tsx +++ b/apps/web/src/app/debug/layout.tsx @@ -2,7 +2,7 @@ import { DashboardLayout } from "@/components/dashboard-layout"; import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -export async function generateMetadata(): Promise { +export function generateMetadata(): Metadata { const t = getMetadataTranslations("debugPage.metadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/app/faceit/page.tsx b/apps/web/src/app/faceit/page.tsx index 3bb4356b5..f8c8c60f6 100644 --- a/apps/web/src/app/faceit/page.tsx +++ b/apps/web/src/app/faceit/page.tsx @@ -8,7 +8,7 @@ import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; -export async function generateMetadata(): Promise { +export function generateMetadata(): Metadata { const t = getMetadataTranslations("faceitScoutingPage"); return { title: t("metadata.searchTitle"), description: t("subtitle") }; } diff --git a/apps/web/src/app/faceit/player/page.tsx b/apps/web/src/app/faceit/player/page.tsx index 6c8920e25..ac0b237c5 100644 --- a/apps/web/src/app/faceit/player/page.tsx +++ b/apps/web/src/app/faceit/player/page.tsx @@ -8,7 +8,7 @@ import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; -export async function generateMetadata(): Promise { +export function generateMetadata(): Metadata { const t = getMetadataTranslations("faceitPlayerPage"); return { title: t("metadata.searchTitle"), description: t("subtitle") }; } diff --git a/apps/web/src/app/leaderboard/csr/page.tsx b/apps/web/src/app/leaderboard/csr/page.tsx index 0494a0cdc..d1a221044 100644 --- a/apps/web/src/app/leaderboard/csr/page.tsx +++ b/apps/web/src/app/leaderboard/csr/page.tsx @@ -14,7 +14,7 @@ import { heroRoleMapping, type HeroName } from "@/types/heroes"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; -export async function generateMetadata(): Promise { +export function generateMetadata(): Metadata { const t = getMetadataTranslations("leaderboardPage.csrPage.metadata"); return { title: t("title"), diff --git a/apps/web/src/app/matchmaker/layout.tsx b/apps/web/src/app/matchmaker/layout.tsx index 0990ed095..3ede0dcfd 100644 --- a/apps/web/src/app/matchmaker/layout.tsx +++ b/apps/web/src/app/matchmaker/layout.tsx @@ -2,7 +2,7 @@ import { DashboardLayout } from "@/components/dashboard-layout"; import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -export async function generateMetadata(): Promise { +export function generateMetadata(): Metadata { const t = getMetadataTranslations("matchmaker.metadata"); return { diff --git a/apps/web/src/app/matchmaker/page.tsx b/apps/web/src/app/matchmaker/page.tsx index 217e12237..3a4fe331b 100644 --- a/apps/web/src/app/matchmaker/page.tsx +++ b/apps/web/src/app/matchmaker/page.tsx @@ -9,7 +9,7 @@ import { getMetadataTranslations } from "@/lib/metadata-i18n"; import { getTierBucket } from "@/lib/tsr/tier-bucket"; import type { Metadata } from "next"; -export async function generateMetadata(): Promise { +export function generateMetadata(): Metadata { const t = getMetadataTranslations("matchmaker.metadata"); return { title: t("title"), diff --git a/apps/web/src/components/leaderboard/tsr-leaderboard.tsx b/apps/web/src/components/leaderboard/tsr-leaderboard.tsx index 0e878dca0..edd481a78 100644 --- a/apps/web/src/components/leaderboard/tsr-leaderboard.tsx +++ b/apps/web/src/components/leaderboard/tsr-leaderboard.tsx @@ -410,6 +410,7 @@ export function TsrLeaderboard({ initialSnapshot }: Props) { showInactive={showInactive} t={t} formatter={formatter} + now={now} /> ))} @@ -511,6 +512,7 @@ function PlayerRow({ showInactive, t, formatter, + now, }: { row: TsrLeaderboardRow; selected: boolean; @@ -518,6 +520,7 @@ function PlayerRow({ showInactive: boolean; t: TsrMessages; formatter: ReturnType; + now: number | null; }) { const isTop = row.rank === 1; const rank = String(row.rank).padStart(2, "0"); diff --git a/apps/web/src/lib/metadata-i18n.ts b/apps/web/src/lib/metadata-i18n.ts index 5b71ea022..763cca7d6 100644 --- a/apps/web/src/lib/metadata-i18n.ts +++ b/apps/web/src/lib/metadata-i18n.ts @@ -13,9 +13,12 @@ import enMessages from "../../messages/en.json"; * `NextIntlClientProvider` in the root layout. */ export function getMetadataTranslations(namespace?: string) { + // The app does not augment next-intl's global `Messages` type, so cast to the + // translator's default (loosely-typed) options instead of having it infer a + // strict namespace union from the imported catalog. return createTranslator({ locale: defaultLocale, messages: enMessages, namespace, - }); + } as Parameters[0]); } From 94fcaa3f6c65c06b63fcb73eb678609f8569a41a Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 03:30:21 -0400 Subject: [PATCH 11/59] Tighten Cache Components migration comments --- apps/web/src/app/layout.tsx | 2 +- apps/web/src/app/stats/map/page.tsx | 5 ++--- apps/web/src/components/leaderboard/tsr-leaderboard.tsx | 5 +---- apps/web/src/components/ui/sidebar.tsx | 6 +++--- apps/web/src/instrumentation.ts | 7 +++---- 5 files changed, 10 insertions(+), 15 deletions(-) diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index f1305c141..6704af7a7 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -96,7 +96,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) { > {/* The provider tree depends on request-time data (locale, auth, feature flags), so it streams under Suspense while the document - shell prerenders. See migrating-to-cache-components. */} + shell prerenders. */} {children} diff --git a/apps/web/src/app/stats/map/page.tsx b/apps/web/src/app/stats/map/page.tsx index 6fb1a8db7..ef797e746 100644 --- a/apps/web/src/app/stats/map/page.tsx +++ b/apps/web/src/app/stats/map/page.tsx @@ -12,9 +12,8 @@ export const metadata: Metadata = { }; export default async function MapHeroStatsPage() { - // The trends service derives a time window from Date.now() and localizes map - // names via the request locale, so this page renders at request time. Reading - // the connection first keeps that allowed under Cache Components. + // The trends service reads Date.now() and the request locale, so render at + // request time rather than prerendering. await connection(); const [data, patches] = await Promise.all([ AppRuntime.runPromise( diff --git a/apps/web/src/components/leaderboard/tsr-leaderboard.tsx b/apps/web/src/components/leaderboard/tsr-leaderboard.tsx index edd481a78..3e9bfcccb 100644 --- a/apps/web/src/components/leaderboard/tsr-leaderboard.tsx +++ b/apps/web/src/components/leaderboard/tsr-leaderboard.tsx @@ -61,9 +61,6 @@ const COLS = const PAGE_SIZE = 50; -// `now` is resolved after mount (see the `now` state below) so the prerender -// pass stays deterministic — reading the current time during render is -// disallowed under Cache Components. function formatRelativeDate( d: Date | null, t: TsrMessages, @@ -192,7 +189,7 @@ export function TsrLeaderboard({ initialSnapshot }: Props) { const t = useTranslations("leaderboardPage.tsr"); const formatter = useFormatter(); // Resolved after mount so relative-time labels don't read the clock during - // the prerender pass (disallowed under Cache Components). + // the Cache Components prerender pass. const [now, setNow] = useState(null); useEffect(() => setNow(Date.now()), []); const [region, setRegion] = useState("All"); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index a3f5f40af..b21655e70 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -591,9 +591,9 @@ function SidebarMenuSkeleton({ }: React.ComponentProps<"div"> & { showIcon?: boolean; }) { - // Random width between 50 to 90%. Generated after mount so the prerender - // pass stays deterministic (Math.random() during render is disallowed under - // Cache Components) and the server/client markup matches on first paint. + // Random width between 50 to 90%, set after mount so render stays + // deterministic (Math.random() during render is disallowed under Cache + // Components). const [width, setWidth] = React.useState("70%"); React.useEffect(() => { setWidth(`${Math.floor(Math.random() * 40) + 50}%`); diff --git a/apps/web/src/instrumentation.ts b/apps/web/src/instrumentation.ts index 1ca430c22..298bf9a39 100644 --- a/apps/web/src/instrumentation.ts +++ b/apps/web/src/instrumentation.ts @@ -7,10 +7,9 @@ type RequestErrorArgs = Parameters; export async function register() { if (process.env.NEXT_RUNTIME !== "nodejs") return; - // OpenTelemetry generates span/trace IDs with Math.random(), which Cache - // Components disallows during prerendering. Build-time prerendering needs no - // telemetry, so skip tracer setup in the build phase to keep prerenders - // deterministic. Runtime requests still register and export traces. + // OpenTelemetry generates span IDs with Math.random(), which Cache Components + // disallows during prerendering. Build-time prerenders need no telemetry; + // runtime requests still register and export traces. if (process.env.NEXT_PHASE === "phase-production-build") return; const { registerNode } = await import("./instrumentation-node"); From cf3384413d9369b613bfaa036277a680fc45a407 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 03:36:27 -0400 Subject: [PATCH 12/59] Upgrade apps/web to Next.js 16.3 canary 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. --- apps/web/package.json | 2 +- pnpm-lock.yaml | 202 ++++++++++++++++++++++-------------------- 2 files changed, 107 insertions(+), 97 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 58cf25be0..d9dd72b93 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -135,7 +135,7 @@ "media-chrome": "^4.18.2", "motion": "^12.23.26", "nanoid": "^5.1.5", - "next": "16.2.9", + "next": "16.3.0-canary.68", "next-axiom": "^1.9.2", "next-intl": "^4.9.2", "next-seo": "^7.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb575e691..abe7c38d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,7 +199,7 @@ importers: version: 0.2.2(@axiomhq/js@1.7.0) '@axiomhq/nextjs': specifier: ^0.2.2 - version: 0.2.2(@axiomhq/logging@0.2.2(@axiomhq/js@1.7.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0)) + version: 0.2.2(@axiomhq/logging@0.2.2(@axiomhq/js@1.7.0))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0)) '@axiomhq/react': specifier: ^0.2.2 version: 0.2.2(@axiomhq/logging@0.2.2(@axiomhq/js@1.7.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -223,7 +223,7 @@ importers: version: 0.63.0(@effect/platform@0.96.0(effect@3.21.4))(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.6.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1)(effect@3.21.4) '@flags-sdk/vercel': specifier: ^1.0.1 - version: 1.4.2(@aws-sdk/credential-provider-web-identity@3.972.55)(flags@4.2.0(@opentelemetry/api@1.9.1)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0) + version: 1.4.2(@aws-sdk/credential-provider-web-identity@3.972.55)(flags@4.2.0(@opentelemetry/api@1.9.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0) '@floating-ui/react': specifier: ^0.27.16 version: 0.27.19(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -238,7 +238,7 @@ importers: version: 5.4.0(react-hook-form@7.80.0(react@19.2.3)) '@next/third-parties': specifier: ^16.1.1 - version: 16.2.9(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + version: 16.2.9(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) '@opentelemetry/api': specifier: ^1.9.1 version: 1.9.1 @@ -418,16 +418,16 @@ importers: version: 1.38.0 '@vercel/analytics': specifier: ^2.0.1 - version: 2.0.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + version: 2.0.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) '@vercel/blob': specifier: ^2.4.1 version: 2.4.1 '@vercel/edge-config': specifier: ^1.4.0 - version: 1.4.3(@opentelemetry/api@1.9.1)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0)) + version: 1.4.3(@opentelemetry/api@1.9.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0)) '@vercel/flags-core': specifier: ^1.0.1 - version: 1.5.1(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0) + version: 1.5.1(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0) '@vercel/functions': specifier: ^3.1.0 version: 3.7.3(@aws-sdk/credential-provider-web-identity@3.972.55)(ws@8.21.0) @@ -436,10 +436,10 @@ importers: version: 3.0.0 '@vercel/speed-insights': specifier: ^1.2.0 - version: 1.3.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + version: 1.3.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) '@vercel/toolbar': specifier: ^0.2.6 - version: 0.2.6(@vercel/analytics@2.0.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(@vercel/speed-insights@1.3.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0)) + version: 0.2.6(@vercel/analytics@2.0.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(@vercel/speed-insights@1.3.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0)) '@xstate/store': specifier: ^3.17.1 version: 3.17.5(react@19.2.3) @@ -457,7 +457,7 @@ importers: version: 1.0.0 better-auth: specifier: ^1.6.20 - version: 1.6.20(@opentelemetry/api@1.9.1)(@prisma/client@7.8.0(@typescript/typescript6@6.0.1)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)))(mysql2@3.15.3)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(pg@8.22.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0)) + version: 1.6.20(@opentelemetry/api@1.9.1)(@prisma/client@7.8.0(@typescript/typescript6@6.0.1)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)))(mysql2@3.15.3)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(pg@8.22.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0)) cheerio: specifier: ^1.2.0 version: 1.2.0 @@ -484,7 +484,7 @@ importers: version: 8.6.0(react@19.2.3) flags: specifier: ^4.0.3 - version: 4.2.0(@opentelemetry/api@1.9.1)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 4.2.0(@opentelemetry/api@1.9.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) framer-motion: specifier: ^12.23.13 version: 12.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -493,7 +493,7 @@ importers: version: 7.4.2 geist: specifier: ^1.5.1 - version: 1.7.2(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0)) + version: 1.7.2(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0)) input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -513,26 +513,26 @@ importers: specifier: ^5.1.5 version: 5.1.16 next: - specifier: 16.2.9 - version: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + specifier: 16.3.0-canary.68 + version: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) next-axiom: specifier: ^1.9.2 - version: 1.10.0(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + version: 1.10.0(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) next-intl: specifier: ^4.9.2 - version: 4.13.0(@swc/helpers@0.5.17)(@typescript/typescript6@6.0.1)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + version: 4.13.0(@swc/helpers@0.5.17)(@typescript/typescript6@6.0.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) next-seo: specifier: ^7.2.0 - version: 7.2.0(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + version: 7.2.0(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nextstepjs: specifier: ^2.1.2 - version: 2.2.0(motion@12.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 2.2.0(motion@12.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nuqs: specifier: ^2.8.9 - version: 2.8.9(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + version: 2.8.9(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) pg: specifier: ^8.21.0 version: 8.22.0 @@ -1877,8 +1877,8 @@ packages: '@next/env@16.2.6': resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} - '@next/env@16.2.9': - resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==} + '@next/env@16.3.0-canary.68': + resolution: {integrity: sha512-zNMcEZdRK68jaEwaGNoJ2BogrWrQJx1/lakWvlSm5HWwp0vSFnlq3PgvhjVcjHpQg+JoGrg3UKOlJXlltJZnpQ==} '@next/swc-darwin-arm64@16.2.6': resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} @@ -1886,8 +1886,8 @@ packages: cpu: [arm64] os: [darwin] - '@next/swc-darwin-arm64@16.2.9': - resolution: {integrity: sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==} + '@next/swc-darwin-arm64@16.3.0-canary.68': + resolution: {integrity: sha512-qTnqWiDn/RJPnMJMvkBPqqxq4H24qvj6aToq5fzOv67zRbKqAor9HFemUC1zd7pqIJnMbY6WK2gZm+i/YQ/aPw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -1898,8 +1898,8 @@ packages: cpu: [x64] os: [darwin] - '@next/swc-darwin-x64@16.2.9': - resolution: {integrity: sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==} + '@next/swc-darwin-x64@16.3.0-canary.68': + resolution: {integrity: sha512-FTKDyZzfoOIALiUX2OrUbf8we1HCOg7lf+jCFnurML8q9kSvzJ3pS1ITNAeZhf52NJHWDF4/JDqFCqRO9wi1uw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -1910,8 +1910,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-gnu@16.2.9': - resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==} + '@next/swc-linux-arm64-gnu@16.3.0-canary.68': + resolution: {integrity: sha512-JRatTwMiN91Ny1+CQ59WIp/Fy6t7VfdU2u4dw7w1p60DAfEFPQlYU04G4Jmrm8EP/rWYLBaq5iBDQRGkqs/AEQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1922,8 +1922,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.2.9': - resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==} + '@next/swc-linux-arm64-musl@16.3.0-canary.68': + resolution: {integrity: sha512-x27TWgIZYISzkbivD7/1BMQ9OkItBpA16I2beOANwBR2E9fT1opt35MEvEBdcAWD8nnKDJ3AJyBBZy4AK+/oeQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1934,8 +1934,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-gnu@16.2.9': - resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==} + '@next/swc-linux-x64-gnu@16.3.0-canary.68': + resolution: {integrity: sha512-RalGvF5crlmUcgypBe4X0Ud45BJfhECj39AxvLy+rafPplvaEiAU5Md9h8AdY1awG1X6Gzy9zvScCSDNXONnNg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1946,8 +1946,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.2.9': - resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==} + '@next/swc-linux-x64-musl@16.3.0-canary.68': + resolution: {integrity: sha512-cLp55X9LbzaOLV4ar0R10bu6Fyy5RHuA++U1hZlv2WE3hUS57tttfirQugY4nAZXgCgCusolsIaGWbXRV9cAZA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1958,8 +1958,8 @@ packages: cpu: [arm64] os: [win32] - '@next/swc-win32-arm64-msvc@16.2.9': - resolution: {integrity: sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==} + '@next/swc-win32-arm64-msvc@16.3.0-canary.68': + resolution: {integrity: sha512-Xrseocqygkr+AaR8oGHYHgXqmh6FutXAKUq2Rn9E1HxSAXD9UsWLqP/gerQaIH1lbFKUXypJOwifmMA9kcxVsw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -1970,8 +1970,8 @@ packages: cpu: [x64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.9': - resolution: {integrity: sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==} + '@next/swc-win32-x64-msvc@16.3.0-canary.68': + resolution: {integrity: sha512-5UyhQAPtCzCbeqR4xgpbCuOoGr2Pg9HmfOMJ+nRRMHAgKSTtl8SDEYZmhBL17gghiWE/lcCpPf8Hk+V/UzURng==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -7557,8 +7557,8 @@ packages: sass: optional: true - next@16.2.9: - resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==} + next@16.3.0-canary.68: + resolution: {integrity: sha512-dJaEy6XUchNbOca93QjzaYm7/LpuHd+dxgXi3kR46I2Viuo5bJa98c7Li0fQfE3Jm8XkNLqj6xle6ApbhUtL/g==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -7854,6 +7854,10 @@ packages: points-on-path@0.2.1: resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -9553,10 +9557,10 @@ snapshots: optionalDependencies: '@axiomhq/js': 1.7.0 - '@axiomhq/nextjs@0.2.2(@axiomhq/logging@0.2.2(@axiomhq/js@1.7.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))': + '@axiomhq/nextjs@0.2.2(@axiomhq/logging@0.2.2(@axiomhq/js@1.7.0))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))': dependencies: '@axiomhq/logging': 0.2.2(@axiomhq/js@1.7.0) - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) '@axiomhq/react@0.2.2(@axiomhq/logging@0.2.2(@axiomhq/js@1.7.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: @@ -10091,10 +10095,10 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@flags-sdk/vercel@1.4.2(@aws-sdk/credential-provider-web-identity@3.972.55)(flags@4.2.0(@opentelemetry/api@1.9.1)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0)': + '@flags-sdk/vercel@1.4.2(@aws-sdk/credential-provider-web-identity@3.972.55)(flags@4.2.0(@opentelemetry/api@1.9.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0)': dependencies: - '@vercel/flags-core': 1.5.1(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0) - flags: 4.2.0(@opentelemetry/api@1.9.1)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@vercel/flags-core': 1.5.1(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0) + flags: 4.2.0(@opentelemetry/api@1.9.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) transitivePeerDependencies: - '@aws-sdk/credential-provider-web-identity' - '@openfeature/server-sdk' @@ -10407,59 +10411,59 @@ snapshots: '@next/env@16.2.6': {} - '@next/env@16.2.9': {} + '@next/env@16.3.0-canary.68': {} '@next/swc-darwin-arm64@16.2.6': optional: true - '@next/swc-darwin-arm64@16.2.9': + '@next/swc-darwin-arm64@16.3.0-canary.68': optional: true '@next/swc-darwin-x64@16.2.6': optional: true - '@next/swc-darwin-x64@16.2.9': + '@next/swc-darwin-x64@16.3.0-canary.68': optional: true '@next/swc-linux-arm64-gnu@16.2.6': optional: true - '@next/swc-linux-arm64-gnu@16.2.9': + '@next/swc-linux-arm64-gnu@16.3.0-canary.68': optional: true '@next/swc-linux-arm64-musl@16.2.6': optional: true - '@next/swc-linux-arm64-musl@16.2.9': + '@next/swc-linux-arm64-musl@16.3.0-canary.68': optional: true '@next/swc-linux-x64-gnu@16.2.6': optional: true - '@next/swc-linux-x64-gnu@16.2.9': + '@next/swc-linux-x64-gnu@16.3.0-canary.68': optional: true '@next/swc-linux-x64-musl@16.2.6': optional: true - '@next/swc-linux-x64-musl@16.2.9': + '@next/swc-linux-x64-musl@16.3.0-canary.68': optional: true '@next/swc-win32-arm64-msvc@16.2.6': optional: true - '@next/swc-win32-arm64-msvc@16.2.9': + '@next/swc-win32-arm64-msvc@16.3.0-canary.68': optional: true '@next/swc-win32-x64-msvc@16.2.6': optional: true - '@next/swc-win32-x64-msvc@16.2.9': + '@next/swc-win32-x64-msvc@16.3.0-canary.68': optional: true - '@next/third-parties@16.2.9(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3)': + '@next/third-parties@16.2.9(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3)': dependencies: - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) react: 19.2.3 third-party-capital: 1.0.20 @@ -13711,9 +13715,9 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.7 - '@vercel/analytics@2.0.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3)': + '@vercel/analytics@2.0.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3)': optionalDependencies: - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) react: 19.2.3 '@vercel/blob@2.4.1': @@ -13743,21 +13747,21 @@ snapshots: '@opentelemetry/api': 1.9.1 next: 16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) - '@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.1)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))': + '@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))': dependencies: '@vercel/edge-config-fs': 0.1.0 optionalDependencies: '@opentelemetry/api': 1.9.1 - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) - '@vercel/flags-core@1.5.1(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0)': + '@vercel/flags-core@1.5.1(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0)': dependencies: '@vercel/functions': 3.7.3(@aws-sdk/credential-provider-web-identity@3.972.55)(ws@8.21.0) '@vercel/oidc': 3.5.0 jose: 5.2.1 js-xxhash: 4.0.0 optionalDependencies: - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) transitivePeerDependencies: - '@aws-sdk/credential-provider-web-identity' - ws @@ -13779,7 +13783,7 @@ snapshots: dependencies: '@upstash/redis': 1.38.0 - '@vercel/microfrontends@2.3.6(@vercel/analytics@2.0.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(@vercel/speed-insights@1.3.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0))': + '@vercel/microfrontends@2.3.6(@vercel/analytics@2.0.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(@vercel/speed-insights@1.3.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0))': dependencies: '@next/env': 16.0.10 '@types/md5': 2.3.6 @@ -13794,9 +13798,9 @@ snapshots: path-to-regexp: 6.3.0 semver: 7.8.5 optionalDependencies: - '@vercel/analytics': 2.0.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) - '@vercel/speed-insights': 1.3.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + '@vercel/analytics': 2.0.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + '@vercel/speed-insights': 1.3.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) vite: 7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0) @@ -13818,22 +13822,22 @@ snapshots: '@vercel/cli-exec': 1.0.0 jose: 5.10.0 - '@vercel/speed-insights@1.3.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3)': + '@vercel/speed-insights@1.3.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3)': optionalDependencies: - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) react: 19.2.3 - '@vercel/toolbar@0.2.6(@vercel/analytics@2.0.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(@vercel/speed-insights@1.3.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0))': + '@vercel/toolbar@0.2.6(@vercel/analytics@2.0.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(@vercel/speed-insights@1.3.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0))': dependencies: '@tinyhttp/app': 1.3.0 - '@vercel/microfrontends': 2.3.6(@vercel/analytics@2.0.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(@vercel/speed-insights@1.3.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0)) + '@vercel/microfrontends': 2.3.6(@vercel/analytics@2.0.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(@vercel/speed-insights@1.3.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0)) chokidar: 3.6.0 execa: 5.1.1 find-up: 5.0.0 get-port: 5.1.1 strip-ansi: 6.0.1 optionalDependencies: - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) react: 19.2.3 vite: 7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0) transitivePeerDependencies: @@ -14031,7 +14035,7 @@ snapshots: baseline-browser-mapping@2.10.38: {} - better-auth@1.6.20(@opentelemetry/api@1.9.1)(@prisma/client@7.8.0(@typescript/typescript6@6.0.1)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)))(mysql2@3.15.3)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(pg@8.22.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0)): + better-auth@1.6.20(@opentelemetry/api@1.9.1)(@prisma/client@7.8.0(@typescript/typescript6@6.0.1)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)))(mysql2@3.15.3)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(pg@8.22.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0)): dependencies: '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) @@ -14053,7 +14057,7 @@ snapshots: optionalDependencies: '@prisma/client': 7.8.0(@typescript/typescript6@6.0.1)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) mysql2: 3.15.3 - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) pg: 8.22.0 prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 @@ -15024,13 +15028,13 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - flags@4.2.0(@opentelemetry/api@1.9.1)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + flags@4.2.0(@opentelemetry/api@1.9.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@edge-runtime/cookies': 5.0.2 jose: 5.10.0 optionalDependencies: '@opentelemetry/api': 1.9.1 - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -15172,9 +15176,9 @@ snapshots: fuse.js@7.4.2: {} - geist@1.7.2(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0)): + geist@1.7.2(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0)): dependencies: - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) generate-function@2.3.1: dependencies: @@ -16400,10 +16404,10 @@ snapshots: negotiator@1.0.0: {} - next-axiom@1.10.0(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3): + next-axiom@1.10.0(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3): dependencies: '@vercel/functions': 2.2.13(@aws-sdk/credential-provider-web-identity@3.972.55) - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) react: 19.2.3 use-deep-compare: 1.3.0(react@19.2.3) whatwg-fetch: 3.6.20 @@ -16412,14 +16416,14 @@ snapshots: next-intl-swc-plugin-extractor@4.13.0: {} - next-intl@4.13.0(@swc/helpers@0.5.17)(@typescript/typescript6@6.0.1)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3): + next-intl@4.13.0(@swc/helpers@0.5.17)(@typescript/typescript6@6.0.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3): dependencies: '@formatjs/intl-localematcher': 0.8.10 '@parcel/watcher': 2.5.1 '@swc/core': 1.15.43(@swc/helpers@0.5.17) icu-minify: 4.13.0 negotiator: 1.0.0 - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) next-intl-swc-plugin-extractor: 4.13.0 po-parser: 2.1.1 react: 19.2.3 @@ -16434,9 +16438,9 @@ snapshots: next: 16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) react: 19.2.7 - next-seo@7.2.0(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3): + next-seo@7.2.0(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3): dependencies: - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) react: 19.2.3 next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3): @@ -16503,25 +16507,25 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0): + next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0): dependencies: - '@next/env': 16.2.9 + '@next/env': 16.3.0-canary.68 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.38 caniuse-lite: 1.0.30001799 - postcss: 8.5.15 + postcss: 8.5.10 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.3) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.9 - '@next/swc-darwin-x64': 16.2.9 - '@next/swc-linux-arm64-gnu': 16.2.9 - '@next/swc-linux-arm64-musl': 16.2.9 - '@next/swc-linux-x64-gnu': 16.2.9 - '@next/swc-linux-x64-musl': 16.2.9 - '@next/swc-win32-arm64-msvc': 16.2.9 - '@next/swc-win32-x64-msvc': 16.2.9 + '@next/swc-darwin-arm64': 16.3.0-canary.68 + '@next/swc-darwin-x64': 16.3.0-canary.68 + '@next/swc-linux-arm64-gnu': 16.3.0-canary.68 + '@next/swc-linux-arm64-musl': 16.3.0-canary.68 + '@next/swc-linux-x64-gnu': 16.3.0-canary.68 + '@next/swc-linux-x64-musl': 16.3.0-canary.68 + '@next/swc-win32-arm64-msvc': 16.3.0-canary.68 + '@next/swc-win32-x64-msvc': 16.3.0-canary.68 '@opentelemetry/api': 1.9.1 babel-plugin-react-compiler: 1.0.0 sass: 1.101.0 @@ -16530,13 +16534,13 @@ snapshots: - '@babel/core' - babel-plugin-macros - nextstepjs@2.2.0(motion@12.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + nextstepjs@2.2.0(motion@12.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: motion: 12.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) node-addon-api@7.1.1: {} @@ -16559,12 +16563,12 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.8.9(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3): + nuqs@2.8.9(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.3 optionalDependencies: - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) nypm@0.6.6: dependencies: @@ -16899,6 +16903,12 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 + postcss@8.5.10: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.15: dependencies: nanoid: 3.3.15 From fe432fb931292144f3ae1d1006d220cf8ee2af39 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 03:53:44 -0400 Subject: [PATCH 13/59] Enable partial prefetching and stream the map trends page 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. --- apps/web/AGENTS.md | 10 +- apps/web/next.config.ts | 1 + apps/web/package.json | 2 +- apps/web/src/app/stats/map/page.tsx | 31 ++++- pnpm-lock.yaml | 190 ++++++++++++++-------------- 5 files changed, 135 insertions(+), 99 deletions(-) diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 08f447d41..e99b55890 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -1,5 +1,3 @@ -[Next.js Docs Index]|root: ./.next-docs|STOP. What you remember about Next.js is WRONG for this project. Always search docs and read before any task.|If docs missing, run this command first: npx @next/codemod agents-md --output CLAUDE.md|01-app:{04-glossary.mdx}|01-app/01-getting-started:{01-installation.mdx,02-project-structure.mdx,03-layouts-and-pages.mdx,04-linking-and-navigating.mdx,05-server-and-client-components.mdx,06-fetching-data.mdx,07-mutating-data.mdx,08-caching.mdx,09-revalidating.mdx,10-error-handling.mdx,11-css.mdx,12-images.mdx,13-fonts.mdx,14-metadata-and-og-images.mdx,15-route-handlers.mdx,16-proxy.mdx,17-deploying.mdx,18-upgrading.mdx}|01-app/02-guides:{ai-agents.mdx,analytics.mdx,authentication.mdx,backend-for-frontend.mdx,caching-without-cache-components.mdx,ci-build-caching.mdx,content-security-policy.mdx,css-in-js.mdx,custom-server.mdx,data-security.mdx,debugging.mdx,draft-mode.mdx,environment-variables.mdx,forms.mdx,incremental-static-regeneration.mdx,instant-navigation.mdx,instrumentation.mdx,internationalization.mdx,json-ld.mdx,lazy-loading.mdx,local-development.mdx,mcp.mdx,mdx.mdx,memory-usage.mdx,migrating-to-cache-components.mdx,multi-tenant.mdx,multi-zones.mdx,open-telemetry.mdx,package-bundling.mdx,prefetching.mdx,preserving-ui-state.mdx,production-checklist.mdx,progressive-web-apps.mdx,public-static-pages.mdx,redirecting.mdx,sass.mdx,scripts.mdx,self-hosting.mdx,single-page-applications.mdx,static-exports.mdx,streaming.mdx,tailwind-v3-css.mdx,third-party-libraries.mdx,videos.mdx}|01-app/02-guides/migrating:{app-router-migration.mdx,from-create-react-app.mdx,from-vite.mdx}|01-app/02-guides/testing:{cypress.mdx,jest.mdx,playwright.mdx,vitest.mdx}|01-app/02-guides/upgrading:{codemods.mdx,version-14.mdx,version-15.mdx,version-16.mdx}|01-app/03-api-reference:{07-edge.mdx,08-turbopack.mdx}|01-app/03-api-reference/01-directives:{use-cache-private.mdx,use-cache-remote.mdx,use-cache.mdx,use-client.mdx,use-server.mdx}|01-app/03-api-reference/02-components:{font.mdx,form.mdx,image.mdx,link.mdx,script.mdx}|01-app/03-api-reference/03-file-conventions/01-metadata:{app-icons.mdx,manifest.mdx,opengraph-image.mdx,robots.mdx,sitemap.mdx}|01-app/03-api-reference/03-file-conventions/02-route-segment-config:{dynamicParams.mdx,instant.mdx,maxDuration.mdx,preferredRegion.mdx,runtime.mdx}|01-app/03-api-reference/03-file-conventions:{default.mdx,dynamic-routes.mdx,error.mdx,forbidden.mdx,instrumentation-client.mdx,instrumentation.mdx,intercepting-routes.mdx,layout.mdx,loading.mdx,mdx-components.mdx,not-found.mdx,page.mdx,parallel-routes.mdx,proxy.mdx,public-folder.mdx,route-groups.mdx,route.mdx,src-folder.mdx,template.mdx,unauthorized.mdx}|01-app/03-api-reference/04-functions:{after.mdx,cacheLife.mdx,cacheTag.mdx,catchError.mdx,connection.mdx,cookies.mdx,draft-mode.mdx,fetch.mdx,forbidden.mdx,generate-image-metadata.mdx,generate-metadata.mdx,generate-sitemaps.mdx,generate-static-params.mdx,generate-viewport.mdx,headers.mdx,image-response.mdx,next-request.mdx,next-response.mdx,not-found.mdx,permanentRedirect.mdx,redirect.mdx,refresh.mdx,revalidatePath.mdx,revalidateTag.mdx,unauthorized.mdx,unstable_cache.mdx,unstable_noStore.mdx,unstable_rethrow.mdx,updateTag.mdx,use-link-status.mdx,use-params.mdx,use-pathname.mdx,use-report-web-vitals.mdx,use-router.mdx,use-search-params.mdx,use-selected-layout-segment.mdx,use-selected-layout-segments.mdx,userAgent.mdx}|01-app/03-api-reference/05-config/01-next-config-js:{adapterPath.mdx,allowedDevOrigins.mdx,appDir.mdx,assetPrefix.mdx,authInterrupts.mdx,basePath.mdx,cacheComponents.mdx,cacheHandlers.mdx,cacheLife.mdx,compress.mdx,crossOrigin.mdx,cssChunking.mdx,deploymentId.mdx,devIndicators.mdx,distDir.mdx,env.mdx,expireTime.mdx,exportPathMap.mdx,generateBuildId.mdx,generateEtags.mdx,headers.mdx,htmlLimitedBots.mdx,httpAgentOptions.mdx,images.mdx,incrementalCacheHandlerPath.mdx,inlineCss.mdx,logging.mdx,mdxRs.mdx,onDemandEntries.mdx,optimizePackageImports.mdx,output.mdx,pageExtensions.mdx,poweredByHeader.mdx,productionBrowserSourceMaps.mdx,proxyClientMaxBodySize.mdx,reactCompiler.mdx,reactMaxHeadersLength.mdx,reactStrictMode.mdx,redirects.mdx,rewrites.mdx,sassOptions.mdx,serverActions.mdx,serverComponentsHmrCache.mdx,serverExternalPackages.mdx,staleTimes.mdx,staticGeneration.mdx,taint.mdx,trailingSlash.mdx,transpilePackages.mdx,turbopack.mdx,turbopackFileSystemCache.mdx,turbopackIgnoreIssue.mdx,typedRoutes.mdx,typescript.mdx,urlImports.mdx,useLightningcss.mdx,viewTransition.mdx,webVitalsAttribution.mdx,webpack.mdx}|01-app/03-api-reference/05-config:{02-typescript.mdx,03-eslint.mdx}|01-app/03-api-reference/06-cli:{create-next-app.mdx,next.mdx}|02-pages/01-getting-started:{01-installation.mdx,02-project-structure.mdx,04-images.mdx,05-fonts.mdx,06-css.mdx,11-deploying.mdx}|02-pages/02-guides:{analytics.mdx,authentication.mdx,babel.mdx,ci-build-caching.mdx,content-security-policy.mdx,css-in-js.mdx,custom-server.mdx,debugging.mdx,draft-mode.mdx,environment-variables.mdx,forms.mdx,incremental-static-regeneration.mdx,instrumentation.mdx,internationalization.mdx,lazy-loading.mdx,mdx.mdx,multi-zones.mdx,open-telemetry.mdx,package-bundling.mdx,post-css.mdx,preview-mode.mdx,production-checklist.mdx,redirecting.mdx,sass.mdx,scripts.mdx,self-hosting.mdx,static-exports.mdx,tailwind-v3-css.mdx,third-party-libraries.mdx}|02-pages/02-guides/migrating:{app-router-migration.mdx,from-create-react-app.mdx,from-vite.mdx}|02-pages/02-guides/testing:{cypress.mdx,jest.mdx,playwright.mdx,vitest.mdx}|02-pages/02-guides/upgrading:{codemods.mdx,version-10.mdx,version-11.mdx,version-12.mdx,version-13.mdx,version-14.mdx,version-9.mdx}|02-pages/03-building-your-application/01-routing:{01-pages-and-layouts.mdx,02-dynamic-routes.mdx,03-linking-and-navigating.mdx,05-custom-app.mdx,06-custom-document.mdx,07-api-routes.mdx,08-custom-error.mdx}|02-pages/03-building-your-application/02-rendering:{01-server-side-rendering.mdx,02-static-site-generation.mdx,04-automatic-static-optimization.mdx,05-client-side-rendering.mdx}|02-pages/03-building-your-application/03-data-fetching:{01-get-static-props.mdx,02-get-static-paths.mdx,03-forms-and-mutations.mdx,03-get-server-side-props.mdx,05-client-side.mdx}|02-pages/03-building-your-application/06-configuring:{12-error-handling.mdx}|02-pages/04-api-reference:{06-edge.mdx,08-turbopack.mdx}|02-pages/04-api-reference/01-components:{font.mdx,form.mdx,head.mdx,image-legacy.mdx,image.mdx,link.mdx,script.mdx}|02-pages/04-api-reference/02-file-conventions:{instrumentation.mdx,proxy.mdx,public-folder.mdx,src-folder.mdx}|02-pages/04-api-reference/03-functions:{get-initial-props.mdx,get-server-side-props.mdx,get-static-paths.mdx,get-static-props.mdx,next-request.mdx,next-response.mdx,use-params.mdx,use-report-web-vitals.mdx,use-router.mdx,use-search-params.mdx,userAgent.mdx}|02-pages/04-api-reference/04-config/01-next-config-js:{adapterPath.mdx,allowedDevOrigins.mdx,assetPrefix.mdx,basePath.mdx,bundlePagesRouterDependencies.mdx,compress.mdx,crossOrigin.mdx,deploymentId.mdx,devIndicators.mdx,distDir.mdx,env.mdx,exportPathMap.mdx,generateBuildId.mdx,generateEtags.mdx,headers.mdx,httpAgentOptions.mdx,images.mdx,logging.mdx,onDemandEntries.mdx,optimizePackageImports.mdx,output.mdx,pageExtensions.mdx,poweredByHeader.mdx,productionBrowserSourceMaps.mdx,proxyClientMaxBodySize.mdx,reactStrictMode.mdx,redirects.mdx,rewrites.mdx,serverExternalPackages.mdx,trailingSlash.mdx,transpilePackages.mdx,turbopack.mdx,typescript.mdx,urlImports.mdx,useLightningcss.mdx,webVitalsAttribution.mdx,webpack.mdx}|02-pages/04-api-reference/04-config:{01-typescript.mdx,02-eslint.mdx}|02-pages/04-api-reference/05-cli:{create-next-app.mdx,next.mdx}|03-architecture:{accessibility.mdx,fast-refresh.mdx,nextjs-compiler.mdx,supported-browsers.mdx}|04-community:{01-contribution-guide.mdx,02-rspack.mdx} - ## Internationalization The Next.js app uses `next-intl` for internationalization, with `en.json`, `ko.json`, and `zh.json` files living at `apps/web/messages`. When building user-facing components, make sure to include proper translation support. @@ -21,3 +19,11 @@ Canonical defaults (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for- ### Domain docs Single-context: one `CONTEXT.md` + `docs/adr/` at the repo root (created lazily by `/grill-with-docs`). See `docs/agents/domain.md`. + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + +**Keep this block, including in commits.** It is part of the project's agent setup, maintained by `next dev` for every agent that works here. If it appears as an uncommitted change, that is intentional — commit it as-is. Do not remove it to clean up a diff; it will be regenerated. + diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 9104b5ba0..6881b833a 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -26,6 +26,7 @@ const cspHeader = ` const nextConfig: NextConfig = { cacheComponents: true, + partialPrefetching: true, reactCompiler: true, typedRoutes: true, transpilePackages: ["@parsertime/transactional"], diff --git a/apps/web/package.json b/apps/web/package.json index d9dd72b93..0d90aa7b1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -135,7 +135,7 @@ "media-chrome": "^4.18.2", "motion": "^12.23.26", "nanoid": "^5.1.5", - "next": "16.3.0-canary.68", + "next": "16.3.0-preview.5", "next-axiom": "^1.9.2", "next-intl": "^4.9.2", "next-seo": "^7.2.0", diff --git a/apps/web/src/app/stats/map/page.tsx b/apps/web/src/app/stats/map/page.tsx index ef797e746..b7caf0d27 100644 --- a/apps/web/src/app/stats/map/page.tsx +++ b/apps/web/src/app/stats/map/page.tsx @@ -1,17 +1,27 @@ import { MapHeroTrends } from "@/components/stats/map/map-hero-trends"; +import { Skeleton } from "@/components/ui/skeleton"; import { MapHeroTrendsService } from "@/data/map"; import { getOverwatchPatches } from "@/data/overwatch/patches-service"; import { AppRuntime } from "@/data/runtime"; import { Effect } from "effect"; import type { Metadata } from "next"; import { connection } from "next/server"; +import { Suspense } from "react"; export const metadata: Metadata = { title: "Map Hero Trends | Parsertime", description: "Recent hero pick rates, winrates, and playtime trends by map.", }; -export default async function MapHeroStatsPage() { +export default function MapHeroStatsPage() { + return ( + }> + + + ); +} + +async function MapHeroTrendsContent() { // The trends service reads Date.now() and the request locale, so render at // request time rather than prerendering. await connection(); @@ -31,3 +41,22 @@ export default async function MapHeroStatsPage() { /> ); } + +function MapHeroTrendsFallback() { + return ( +
+
+
+ + + +
+
+
+ {["a", "b", "c", "d", "e", "f"].map((key) => ( + + ))} +
+
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abe7c38d2..231c080ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,7 +199,7 @@ importers: version: 0.2.2(@axiomhq/js@1.7.0) '@axiomhq/nextjs': specifier: ^0.2.2 - version: 0.2.2(@axiomhq/logging@0.2.2(@axiomhq/js@1.7.0))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0)) + version: 0.2.2(@axiomhq/logging@0.2.2(@axiomhq/js@1.7.0))(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0)) '@axiomhq/react': specifier: ^0.2.2 version: 0.2.2(@axiomhq/logging@0.2.2(@axiomhq/js@1.7.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -223,7 +223,7 @@ importers: version: 0.63.0(@effect/platform@0.96.0(effect@3.21.4))(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.6.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1)(effect@3.21.4) '@flags-sdk/vercel': specifier: ^1.0.1 - version: 1.4.2(@aws-sdk/credential-provider-web-identity@3.972.55)(flags@4.2.0(@opentelemetry/api@1.9.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0) + version: 1.4.2(@aws-sdk/credential-provider-web-identity@3.972.55)(flags@4.2.0(@opentelemetry/api@1.9.1)(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0) '@floating-ui/react': specifier: ^0.27.16 version: 0.27.19(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -238,7 +238,7 @@ importers: version: 5.4.0(react-hook-form@7.80.0(react@19.2.3)) '@next/third-parties': specifier: ^16.1.1 - version: 16.2.9(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + version: 16.2.9(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) '@opentelemetry/api': specifier: ^1.9.1 version: 1.9.1 @@ -418,16 +418,16 @@ importers: version: 1.38.0 '@vercel/analytics': specifier: ^2.0.1 - version: 2.0.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + version: 2.0.1(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) '@vercel/blob': specifier: ^2.4.1 version: 2.4.1 '@vercel/edge-config': specifier: ^1.4.0 - version: 1.4.3(@opentelemetry/api@1.9.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0)) + version: 1.4.3(@opentelemetry/api@1.9.1)(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0)) '@vercel/flags-core': specifier: ^1.0.1 - version: 1.5.1(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0) + version: 1.5.1(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0) '@vercel/functions': specifier: ^3.1.0 version: 3.7.3(@aws-sdk/credential-provider-web-identity@3.972.55)(ws@8.21.0) @@ -436,10 +436,10 @@ importers: version: 3.0.0 '@vercel/speed-insights': specifier: ^1.2.0 - version: 1.3.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + version: 1.3.1(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) '@vercel/toolbar': specifier: ^0.2.6 - version: 0.2.6(@vercel/analytics@2.0.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(@vercel/speed-insights@1.3.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0)) + version: 0.2.6(@vercel/analytics@2.0.1(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(@vercel/speed-insights@1.3.1(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0)) '@xstate/store': specifier: ^3.17.1 version: 3.17.5(react@19.2.3) @@ -457,7 +457,7 @@ importers: version: 1.0.0 better-auth: specifier: ^1.6.20 - version: 1.6.20(@opentelemetry/api@1.9.1)(@prisma/client@7.8.0(@typescript/typescript6@6.0.1)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)))(mysql2@3.15.3)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(pg@8.22.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0)) + version: 1.6.20(@opentelemetry/api@1.9.1)(@prisma/client@7.8.0(@typescript/typescript6@6.0.1)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)))(mysql2@3.15.3)(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(pg@8.22.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0)) cheerio: specifier: ^1.2.0 version: 1.2.0 @@ -484,7 +484,7 @@ importers: version: 8.6.0(react@19.2.3) flags: specifier: ^4.0.3 - version: 4.2.0(@opentelemetry/api@1.9.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 4.2.0(@opentelemetry/api@1.9.1)(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) framer-motion: specifier: ^12.23.13 version: 12.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -493,7 +493,7 @@ importers: version: 7.4.2 geist: specifier: ^1.5.1 - version: 1.7.2(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0)) + version: 1.7.2(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0)) input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -513,26 +513,26 @@ importers: specifier: ^5.1.5 version: 5.1.16 next: - specifier: 16.3.0-canary.68 - version: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + specifier: 16.3.0-preview.5 + version: 16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) next-axiom: specifier: ^1.9.2 - version: 1.10.0(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + version: 1.10.0(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) next-intl: specifier: ^4.9.2 - version: 4.13.0(@swc/helpers@0.5.17)(@typescript/typescript6@6.0.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + version: 4.13.0(@swc/helpers@0.5.17)(@typescript/typescript6@6.0.1)(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) next-seo: specifier: ^7.2.0 - version: 7.2.0(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + version: 7.2.0(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nextstepjs: specifier: ^2.1.2 - version: 2.2.0(motion@12.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 2.2.0(motion@12.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nuqs: specifier: ^2.8.9 - version: 2.8.9(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + version: 2.8.9(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) pg: specifier: ^8.21.0 version: 8.22.0 @@ -1877,8 +1877,8 @@ packages: '@next/env@16.2.6': resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} - '@next/env@16.3.0-canary.68': - resolution: {integrity: sha512-zNMcEZdRK68jaEwaGNoJ2BogrWrQJx1/lakWvlSm5HWwp0vSFnlq3PgvhjVcjHpQg+JoGrg3UKOlJXlltJZnpQ==} + '@next/env@16.3.0-preview.5': + resolution: {integrity: sha512-XqdVR0utAWMsVc1OIyO48D32vrdmC4/uAgI3Ds088YlOO4vfGKXXVyvkGFkOZkOK0xg7bNYNfJAarX4A0tYqGg==} '@next/swc-darwin-arm64@16.2.6': resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} @@ -1886,8 +1886,8 @@ packages: cpu: [arm64] os: [darwin] - '@next/swc-darwin-arm64@16.3.0-canary.68': - resolution: {integrity: sha512-qTnqWiDn/RJPnMJMvkBPqqxq4H24qvj6aToq5fzOv67zRbKqAor9HFemUC1zd7pqIJnMbY6WK2gZm+i/YQ/aPw==} + '@next/swc-darwin-arm64@16.3.0-preview.5': + resolution: {integrity: sha512-PPWAJGoIkzVpz5hOD9V/qGNdkBuWj3QXhjQU8BQ1FXlMy6xsy4+aD/3UoasKy/HYInW4h1LqdQtDhiQkLYrrMA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -1898,8 +1898,8 @@ packages: cpu: [x64] os: [darwin] - '@next/swc-darwin-x64@16.3.0-canary.68': - resolution: {integrity: sha512-FTKDyZzfoOIALiUX2OrUbf8we1HCOg7lf+jCFnurML8q9kSvzJ3pS1ITNAeZhf52NJHWDF4/JDqFCqRO9wi1uw==} + '@next/swc-darwin-x64@16.3.0-preview.5': + resolution: {integrity: sha512-UPN/RS1H+kr9fgJrbFoH7bs1b9q2/G5cFe+uUf0nP4Hlgfl8NzfTBHEJKTfLAGqi1Qemwuyd29pvRy2vwEjL5g==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -1910,8 +1910,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-gnu@16.3.0-canary.68': - resolution: {integrity: sha512-JRatTwMiN91Ny1+CQ59WIp/Fy6t7VfdU2u4dw7w1p60DAfEFPQlYU04G4Jmrm8EP/rWYLBaq5iBDQRGkqs/AEQ==} + '@next/swc-linux-arm64-gnu@16.3.0-preview.5': + resolution: {integrity: sha512-kh+bKgk9ZIlmxMkEPnQZXtKc7/AyUyIS9jXgbKt4hWyxXEEZVDmXhiU2bh1zZpthMr/l09wz9z6CvfXtCWUJBA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1922,8 +1922,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.3.0-canary.68': - resolution: {integrity: sha512-x27TWgIZYISzkbivD7/1BMQ9OkItBpA16I2beOANwBR2E9fT1opt35MEvEBdcAWD8nnKDJ3AJyBBZy4AK+/oeQ==} + '@next/swc-linux-arm64-musl@16.3.0-preview.5': + resolution: {integrity: sha512-m09/acXFGhlp+U6m7Wn0AqsmLqars3qI9eBXDpPJm4h/XVS9HPHNzWGy2BI7F1iLoFX59Uy0tcau9ey7JVud3w==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1934,8 +1934,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-gnu@16.3.0-canary.68': - resolution: {integrity: sha512-RalGvF5crlmUcgypBe4X0Ud45BJfhECj39AxvLy+rafPplvaEiAU5Md9h8AdY1awG1X6Gzy9zvScCSDNXONnNg==} + '@next/swc-linux-x64-gnu@16.3.0-preview.5': + resolution: {integrity: sha512-/EBiqRjLZJWJo6Keq9upJfhrP+tNpePy1beBfOL+tUn68inwNiJEjx+0Lgve99Zur8kSk9TgSmDmwgQxX4iM+g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1946,8 +1946,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.3.0-canary.68': - resolution: {integrity: sha512-cLp55X9LbzaOLV4ar0R10bu6Fyy5RHuA++U1hZlv2WE3hUS57tttfirQugY4nAZXgCgCusolsIaGWbXRV9cAZA==} + '@next/swc-linux-x64-musl@16.3.0-preview.5': + resolution: {integrity: sha512-lUCiPFoecSGkM8aeY6UAgQDiJjR3DhPsI036mznlHFg89ZLoeRdo521N4nmk6EpbPpNzRujgiboBkbuyexDgCg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1958,8 +1958,8 @@ packages: cpu: [arm64] os: [win32] - '@next/swc-win32-arm64-msvc@16.3.0-canary.68': - resolution: {integrity: sha512-Xrseocqygkr+AaR8oGHYHgXqmh6FutXAKUq2Rn9E1HxSAXD9UsWLqP/gerQaIH1lbFKUXypJOwifmMA9kcxVsw==} + '@next/swc-win32-arm64-msvc@16.3.0-preview.5': + resolution: {integrity: sha512-Nr4e3dRB86gElIgysL/L7dr9tuRLIq3looK8hLxnYDLUvLza2Tu/7Ik/X6DSRGejIrbZsYjnH3S4xYeAAf7Prw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -1970,8 +1970,8 @@ packages: cpu: [x64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.0-canary.68': - resolution: {integrity: sha512-5UyhQAPtCzCbeqR4xgpbCuOoGr2Pg9HmfOMJ+nRRMHAgKSTtl8SDEYZmhBL17gghiWE/lcCpPf8Hk+V/UzURng==} + '@next/swc-win32-x64-msvc@16.3.0-preview.5': + resolution: {integrity: sha512-Svg+VCRUbyNsBuh96hN+1ael8dNXqVQVZqOe9tqFlF4mUzIk5CQFcn5VsZPrz8GNP9HCxJfrfy3PM0cXoSXliw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -7557,8 +7557,8 @@ packages: sass: optional: true - next@16.3.0-canary.68: - resolution: {integrity: sha512-dJaEy6XUchNbOca93QjzaYm7/LpuHd+dxgXi3kR46I2Viuo5bJa98c7Li0fQfE3Jm8XkNLqj6xle6ApbhUtL/g==} + next@16.3.0-preview.5: + resolution: {integrity: sha512-I5rVC4VcvAL1FPr6AY5WEQUSe6o1Bt0Oa/qH5hfPhci4FRMCPeAQ95tgxFOgJDk2wME1K009k0bjS17nQ0Bq1w==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -9557,10 +9557,10 @@ snapshots: optionalDependencies: '@axiomhq/js': 1.7.0 - '@axiomhq/nextjs@0.2.2(@axiomhq/logging@0.2.2(@axiomhq/js@1.7.0))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))': + '@axiomhq/nextjs@0.2.2(@axiomhq/logging@0.2.2(@axiomhq/js@1.7.0))(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))': dependencies: '@axiomhq/logging': 0.2.2(@axiomhq/js@1.7.0) - next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) '@axiomhq/react@0.2.2(@axiomhq/logging@0.2.2(@axiomhq/js@1.7.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: @@ -10095,10 +10095,10 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@flags-sdk/vercel@1.4.2(@aws-sdk/credential-provider-web-identity@3.972.55)(flags@4.2.0(@opentelemetry/api@1.9.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0)': + '@flags-sdk/vercel@1.4.2(@aws-sdk/credential-provider-web-identity@3.972.55)(flags@4.2.0(@opentelemetry/api@1.9.1)(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0)': dependencies: - '@vercel/flags-core': 1.5.1(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0) - flags: 4.2.0(@opentelemetry/api@1.9.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@vercel/flags-core': 1.5.1(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0) + flags: 4.2.0(@opentelemetry/api@1.9.1)(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) transitivePeerDependencies: - '@aws-sdk/credential-provider-web-identity' - '@openfeature/server-sdk' @@ -10411,59 +10411,59 @@ snapshots: '@next/env@16.2.6': {} - '@next/env@16.3.0-canary.68': {} + '@next/env@16.3.0-preview.5': {} '@next/swc-darwin-arm64@16.2.6': optional: true - '@next/swc-darwin-arm64@16.3.0-canary.68': + '@next/swc-darwin-arm64@16.3.0-preview.5': optional: true '@next/swc-darwin-x64@16.2.6': optional: true - '@next/swc-darwin-x64@16.3.0-canary.68': + '@next/swc-darwin-x64@16.3.0-preview.5': optional: true '@next/swc-linux-arm64-gnu@16.2.6': optional: true - '@next/swc-linux-arm64-gnu@16.3.0-canary.68': + '@next/swc-linux-arm64-gnu@16.3.0-preview.5': optional: true '@next/swc-linux-arm64-musl@16.2.6': optional: true - '@next/swc-linux-arm64-musl@16.3.0-canary.68': + '@next/swc-linux-arm64-musl@16.3.0-preview.5': optional: true '@next/swc-linux-x64-gnu@16.2.6': optional: true - '@next/swc-linux-x64-gnu@16.3.0-canary.68': + '@next/swc-linux-x64-gnu@16.3.0-preview.5': optional: true '@next/swc-linux-x64-musl@16.2.6': optional: true - '@next/swc-linux-x64-musl@16.3.0-canary.68': + '@next/swc-linux-x64-musl@16.3.0-preview.5': optional: true '@next/swc-win32-arm64-msvc@16.2.6': optional: true - '@next/swc-win32-arm64-msvc@16.3.0-canary.68': + '@next/swc-win32-arm64-msvc@16.3.0-preview.5': optional: true '@next/swc-win32-x64-msvc@16.2.6': optional: true - '@next/swc-win32-x64-msvc@16.3.0-canary.68': + '@next/swc-win32-x64-msvc@16.3.0-preview.5': optional: true - '@next/third-parties@16.2.9(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3)': + '@next/third-parties@16.2.9(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3)': dependencies: - next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) react: 19.2.3 third-party-capital: 1.0.20 @@ -13715,9 +13715,9 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.7 - '@vercel/analytics@2.0.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3)': + '@vercel/analytics@2.0.1(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3)': optionalDependencies: - next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) react: 19.2.3 '@vercel/blob@2.4.1': @@ -13747,21 +13747,21 @@ snapshots: '@opentelemetry/api': 1.9.1 next: 16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) - '@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))': + '@vercel/edge-config@1.4.3(@opentelemetry/api@1.9.1)(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))': dependencies: '@vercel/edge-config-fs': 0.1.0 optionalDependencies: '@opentelemetry/api': 1.9.1 - next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) - '@vercel/flags-core@1.5.1(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0)': + '@vercel/flags-core@1.5.1(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(ws@8.21.0)': dependencies: '@vercel/functions': 3.7.3(@aws-sdk/credential-provider-web-identity@3.972.55)(ws@8.21.0) '@vercel/oidc': 3.5.0 jose: 5.2.1 js-xxhash: 4.0.0 optionalDependencies: - next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) transitivePeerDependencies: - '@aws-sdk/credential-provider-web-identity' - ws @@ -13783,7 +13783,7 @@ snapshots: dependencies: '@upstash/redis': 1.38.0 - '@vercel/microfrontends@2.3.6(@vercel/analytics@2.0.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(@vercel/speed-insights@1.3.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0))': + '@vercel/microfrontends@2.3.6(@vercel/analytics@2.0.1(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(@vercel/speed-insights@1.3.1(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0))': dependencies: '@next/env': 16.0.10 '@types/md5': 2.3.6 @@ -13798,9 +13798,9 @@ snapshots: path-to-regexp: 6.3.0 semver: 7.8.5 optionalDependencies: - '@vercel/analytics': 2.0.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) - '@vercel/speed-insights': 1.3.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) - next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + '@vercel/analytics': 2.0.1(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + '@vercel/speed-insights': 1.3.1(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3) + next: 16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) vite: 7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0) @@ -13822,22 +13822,22 @@ snapshots: '@vercel/cli-exec': 1.0.0 jose: 5.10.0 - '@vercel/speed-insights@1.3.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3)': + '@vercel/speed-insights@1.3.1(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3)': optionalDependencies: - next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) react: 19.2.3 - '@vercel/toolbar@0.2.6(@vercel/analytics@2.0.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(@vercel/speed-insights@1.3.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0))': + '@vercel/toolbar@0.2.6(@vercel/analytics@2.0.1(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(@vercel/speed-insights@1.3.1(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0))': dependencies: '@tinyhttp/app': 1.3.0 - '@vercel/microfrontends': 2.3.6(@vercel/analytics@2.0.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(@vercel/speed-insights@1.3.1(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0)) + '@vercel/microfrontends': 2.3.6(@vercel/analytics@2.0.1(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(@vercel/speed-insights@1.3.1(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3))(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0)) chokidar: 3.6.0 execa: 5.1.1 find-up: 5.0.0 get-port: 5.1.1 strip-ansi: 6.0.1 optionalDependencies: - next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) react: 19.2.3 vite: 7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0) transitivePeerDependencies: @@ -14035,7 +14035,7 @@ snapshots: baseline-browser-mapping@2.10.38: {} - better-auth@1.6.20(@opentelemetry/api@1.9.1)(@prisma/client@7.8.0(@typescript/typescript6@6.0.1)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)))(mysql2@3.15.3)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(pg@8.22.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0)): + better-auth@1.6.20(@opentelemetry/api@1.9.1)(@prisma/client@7.8.0(@typescript/typescript6@6.0.1)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)))(mysql2@3.15.3)(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(pg@8.22.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(tsx@4.20.5)(yaml@2.9.0)): dependencies: '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) @@ -14057,7 +14057,7 @@ snapshots: optionalDependencies: '@prisma/client': 7.8.0(@typescript/typescript6@6.0.1)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) mysql2: 3.15.3 - next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) pg: 8.22.0 prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@typescript/typescript6@6.0.1)(magicast@0.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 @@ -15028,13 +15028,13 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - flags@4.2.0(@opentelemetry/api@1.9.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + flags@4.2.0(@opentelemetry/api@1.9.1)(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@edge-runtime/cookies': 5.0.2 jose: 5.10.0 optionalDependencies: '@opentelemetry/api': 1.9.1 - next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -15176,9 +15176,9 @@ snapshots: fuse.js@7.4.2: {} - geist@1.7.2(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0)): + geist@1.7.2(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0)): dependencies: - next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) generate-function@2.3.1: dependencies: @@ -16404,10 +16404,10 @@ snapshots: negotiator@1.0.0: {} - next-axiom@1.10.0(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3): + next-axiom@1.10.0(@aws-sdk/credential-provider-web-identity@3.972.55)(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3): dependencies: '@vercel/functions': 2.2.13(@aws-sdk/credential-provider-web-identity@3.972.55) - next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) react: 19.2.3 use-deep-compare: 1.3.0(react@19.2.3) whatwg-fetch: 3.6.20 @@ -16416,14 +16416,14 @@ snapshots: next-intl-swc-plugin-extractor@4.13.0: {} - next-intl@4.13.0(@swc/helpers@0.5.17)(@typescript/typescript6@6.0.1)(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3): + next-intl@4.13.0(@swc/helpers@0.5.17)(@typescript/typescript6@6.0.1)(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3): dependencies: '@formatjs/intl-localematcher': 0.8.10 '@parcel/watcher': 2.5.1 '@swc/core': 1.15.43(@swc/helpers@0.5.17) icu-minify: 4.13.0 negotiator: 1.0.0 - next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) next-intl-swc-plugin-extractor: 4.13.0 po-parser: 2.1.1 react: 19.2.3 @@ -16438,9 +16438,9 @@ snapshots: next: 16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) react: 19.2.7 - next-seo@7.2.0(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3): + next-seo@7.2.0(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3): dependencies: - next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) react: 19.2.3 next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3): @@ -16507,9 +16507,9 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0): + next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0): dependencies: - '@next/env': 16.3.0-canary.68 + '@next/env': 16.3.0-preview.5 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.38 caniuse-lite: 1.0.30001799 @@ -16518,14 +16518,14 @@ snapshots: react-dom: 19.2.3(react@19.2.3) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.3) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.0-canary.68 - '@next/swc-darwin-x64': 16.3.0-canary.68 - '@next/swc-linux-arm64-gnu': 16.3.0-canary.68 - '@next/swc-linux-arm64-musl': 16.3.0-canary.68 - '@next/swc-linux-x64-gnu': 16.3.0-canary.68 - '@next/swc-linux-x64-musl': 16.3.0-canary.68 - '@next/swc-win32-arm64-msvc': 16.3.0-canary.68 - '@next/swc-win32-x64-msvc': 16.3.0-canary.68 + '@next/swc-darwin-arm64': 16.3.0-preview.5 + '@next/swc-darwin-x64': 16.3.0-preview.5 + '@next/swc-linux-arm64-gnu': 16.3.0-preview.5 + '@next/swc-linux-arm64-musl': 16.3.0-preview.5 + '@next/swc-linux-x64-gnu': 16.3.0-preview.5 + '@next/swc-linux-x64-musl': 16.3.0-preview.5 + '@next/swc-win32-arm64-msvc': 16.3.0-preview.5 + '@next/swc-win32-x64-msvc': 16.3.0-preview.5 '@opentelemetry/api': 1.9.1 babel-plugin-react-compiler: 1.0.0 sass: 1.101.0 @@ -16534,13 +16534,13 @@ snapshots: - '@babel/core' - babel-plugin-macros - nextstepjs@2.2.0(motion@12.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + nextstepjs@2.2.0(motion@12.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: motion: 12.42.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) node-addon-api@7.1.1: {} @@ -16563,12 +16563,12 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.8.9(next@16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3): + nuqs@2.8.9(next@16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0))(react@19.2.3): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.3 optionalDependencies: - next: 16.3.0-canary.68(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) + next: 16.3.0-preview.5(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.101.0) nypm@0.6.6: dependencies: From 5ddb127aadeef493ffe2dc006dc42bbb182533a9 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 04:05:04 -0400 Subject: [PATCH 14/59] Cache immutable per-map reads aggressively 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. --- .../scrim/[scrimId]/map/[mapId]/page.tsx | 18 ++++------ .../map/[mapId]/player/[playerId]/page.tsx | 6 ++-- .../api/scrim/map/[mapId]/set-winner/route.ts | 3 ++ .../web/src/app/api/scrim/remove-map/route.ts | 3 ++ apps/web/src/data/cached/map-cache.ts | 34 +++++++++++++++++++ 5 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/data/cached/map-cache.ts diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx index 19d8828a1..2470f2a02 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx @@ -7,8 +7,10 @@ import { MapTabsSkeleton } from "@/components/map/map-tabs-skeleton"; import { PlayerSwitcher } from "@/components/map/player-switcher"; import { ReplayCode } from "@/components/scrim/replay-code"; import { StatsViewBeacon } from "@/components/usage/stats-view-beacon"; -import { MatchStoryService } from "@/data/map/match-story-service"; -import { PlayerService } from "@/data/player"; +import { + getCachedMatchStory, + getCachedMostPlayedHeroes, +} from "@/data/cached/map-cache"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { defaultLocale } from "@/i18n/config"; @@ -105,9 +107,7 @@ export default async function MapDashboardPage( positionalDataEnabled, matchStory, ] = await Promise.all([ - AppRuntime.runPromise( - PlayerService.pipe(Effect.flatMap((svc) => svc.getMostPlayedHeroes(id))) - ), + getCachedMostPlayedHeroes(id), prisma.matchStart.findFirst({ where: { MapDataId: mapDataId }, select: { map_name: true, team_1_name: true }, @@ -132,13 +132,7 @@ export default async function MapDashboardPage( }), tempoChart(), positionalData(), - // A story failure must never break the map page — the tab just hides. - AppRuntime.runPromise( - MatchStoryService.pipe( - Effect.flatMap((svc) => svc.getMatchStory(mapDataId)), - Effect.catchAll(() => Effect.succeed(null)) - ) - ), + getCachedMatchStory(id, mapDataId), ]); const translatedMapName = await translateMapName( diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx index 5c283f2eb..96aec7e9f 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx @@ -7,7 +7,7 @@ import { DefaultOverview } from "@/components/player/default-overview"; import { PlayerTelemetry } from "@/components/player/player-telemetry"; import { Link } from "@/components/ui/link"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { PlayerService } from "@/data/player"; +import { getCachedMostPlayedHeroes } from "@/data/cached/map-cache"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; @@ -61,9 +61,7 @@ export default async function PlayerDashboardPage( const mapDataId = await resolveMapDataId(id); const playerName = decodeURIComponent(params.playerId); - const mostPlayedHeroes = await AppRuntime.runPromise( - PlayerService.pipe(Effect.flatMap((svc) => svc.getMostPlayedHeroes(id))) - ); + const mostPlayedHeroes = await getCachedMostPlayedHeroes(id); const mapName = await prisma.matchStart.findFirst({ where: { diff --git a/apps/web/src/app/api/scrim/map/[mapId]/set-winner/route.ts b/apps/web/src/app/api/scrim/map/[mapId]/set-winner/route.ts index f6dedc4ea..77d8bdc7f 100644 --- a/apps/web/src/app/api/scrim/map/[mapId]/set-winner/route.ts +++ b/apps/web/src/app/api/scrim/map/[mapId]/set-winner/route.ts @@ -3,6 +3,7 @@ import { auth, canEditScrim, getCurrentUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { resolveSetWinnerOutcome } from "@/lib/scrim/set-winner-validation"; +import { revalidateTag } from "next/cache"; import { unauthorized, unstable_rethrow } from "next/navigation"; import { after, type NextRequest } from "next/server"; import { z } from "zod"; @@ -70,6 +71,8 @@ export async function POST( data: { winner: parsed.data.winner, winnerSource: "manual" }, }); + revalidateTag(`map:${mapId}`, "max"); + after(async () => { await auditLog.createAuditLog({ userEmail: session.user.email, diff --git a/apps/web/src/app/api/scrim/remove-map/route.ts b/apps/web/src/app/api/scrim/remove-map/route.ts index 6e3226ed3..66648a228 100644 --- a/apps/web/src/app/api/scrim/remove-map/route.ts +++ b/apps/web/src/app/api/scrim/remove-map/route.ts @@ -3,6 +3,7 @@ import { auth, canEditScrim, getCurrentUser } from "@/lib/auth"; import { mapDeletionDuration, mapRemovedCounter } from "@/lib/axiom/metrics"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; +import { revalidateTag } from "next/cache"; import { unauthorized } from "next/navigation"; import { after, type NextRequest } from "next/server"; @@ -43,6 +44,8 @@ export async function POST(req: NextRequest) { mapDeletionDuration.record(performance.now() - deleteStart); mapRemovedCounter.add(1); + revalidateTag(`map:${mapId}`, "max"); + after(async () => { await auditLog.createAuditLog({ userEmail: user.email, diff --git a/apps/web/src/data/cached/map-cache.ts b/apps/web/src/data/cached/map-cache.ts new file mode 100644 index 000000000..de7d407b8 --- /dev/null +++ b/apps/web/src/data/cached/map-cache.ts @@ -0,0 +1,34 @@ +import "server-only"; + +import { MatchStoryService } from "@/data/map/match-story-service"; +import { PlayerService } from "@/data/player"; +import { AppRuntime } from "@/data/runtime"; +import { Effect } from "effect"; +import { cacheLife, cacheTag } from "next/cache"; + +// A map's stats are immutable once it's uploaded — adding more maps to the +// scrim never changes an existing map's data. Cache the per-map reads +// indefinitely and invalidate `map:${mapId}` only when that map is removed or +// its winner changes (see the scrim mutation routes). + +export function getCachedMostPlayedHeroes(mapId: number) { + "use cache"; + cacheLife("max"); + cacheTag(`map:${mapId}`); + return AppRuntime.runPromise( + PlayerService.pipe(Effect.flatMap((svc) => svc.getMostPlayedHeroes(mapId))) + ); +} + +export function getCachedMatchStory(mapId: number, mapDataId: number) { + "use cache"; + cacheLife("max"); + cacheTag(`map:${mapId}`); + // A story failure must never break the map page — the tab just hides. + return AppRuntime.runPromise( + MatchStoryService.pipe( + Effect.flatMap((svc) => svc.getMatchStory(mapDataId)), + Effect.catchAll(() => Effect.succeed(null)) + ) + ); +} From b095d86eb674607a3daa2276e45689fec2660d91 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 04:07:19 -0400 Subject: [PATCH 15/59] Make the cached map readers async "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. --- apps/web/src/data/cached/map-cache.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/data/cached/map-cache.ts b/apps/web/src/data/cached/map-cache.ts index de7d407b8..5a045baf8 100644 --- a/apps/web/src/data/cached/map-cache.ts +++ b/apps/web/src/data/cached/map-cache.ts @@ -11,7 +11,7 @@ import { cacheLife, cacheTag } from "next/cache"; // indefinitely and invalidate `map:${mapId}` only when that map is removed or // its winner changes (see the scrim mutation routes). -export function getCachedMostPlayedHeroes(mapId: number) { +export async function getCachedMostPlayedHeroes(mapId: number) { "use cache"; cacheLife("max"); cacheTag(`map:${mapId}`); @@ -20,7 +20,7 @@ export function getCachedMostPlayedHeroes(mapId: number) { ); } -export function getCachedMatchStory(mapId: number, mapDataId: number) { +export async function getCachedMatchStory(mapId: number, mapDataId: number) { "use cache"; cacheLife("max"); cacheTag(`map:${mapId}`); From 23fd2b689aac17318f667b910e709cfb6073470c Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 04:26:08 -0400 Subject: [PATCH 16/59] Cache scrim aggregates and team stats with tag invalidation 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. --- .../src/app/[team]/scrim/[scrimId]/page.tsx | 42 +++-------- .../src/app/api/scrim/add-map-stream/route.ts | 6 ++ apps/web/src/app/api/scrim/add-map/route.ts | 6 ++ .../api/scrim/create-scrim-stream/route.ts | 4 + .../src/app/api/scrim/create-scrim/route.ts | 4 + .../api/scrim/map/[mapId]/set-winner/route.ts | 17 ++++- .../web/src/app/api/scrim/remove-map/route.ts | 10 ++- .../src/app/api/scrim/remove-scrim/route.ts | 4 + .../api/scrim/update-scrim-options/route.ts | 7 ++ .../app/stats/team/[teamId]/_lib/context.ts | 15 ++-- apps/web/src/data/cached/map-cache.ts | 5 +- apps/web/src/data/cached/scrim-cache.ts | 73 +++++++++++++++++++ apps/web/src/data/cached/team-cache.ts | 35 +++++++++ apps/web/src/lib/cache-tags.ts | 29 ++++++++ 14 files changed, 211 insertions(+), 46 deletions(-) create mode 100644 apps/web/src/data/cached/scrim-cache.ts create mode 100644 apps/web/src/data/cached/team-cache.ts create mode 100644 apps/web/src/lib/cache-tags.ts diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx index 727eb6198..513061566 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx @@ -22,12 +22,12 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { - ScrimOverviewService, - ScrimPositionalArtifactsService, - ScrimPositionalStatsService, - ScrimService, -} from "@/data/scrim"; -import { ScrimInitiationService } from "@/data/scrim/initiation-service"; + getCachedScrim, + getCachedScrimInitiation, + getCachedScrimOverview, + getCachedScrimPositionalArtifacts, + getCachedScrimPositionalStats, +} from "@/data/cached/scrim-cache"; import { resolveScrimMapWinners } from "@/data/scrim/map-winner-names"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; @@ -112,9 +112,7 @@ export default async function ScrimDashboardPage( overviewCardEnabled, showPositional, ] = await Promise.all([ - AppRuntime.runPromise( - ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(id))) - ), + getCachedScrim(id), prisma.map.findMany({ where: { scrimId: id }, orderBy: [{ order: "asc" }, { id: "asc" }], @@ -228,34 +226,16 @@ export default async function ScrimDashboardPage( const [overviewData, positionalStats, positionalArtifacts, scrimInitiation] = await Promise.all([ overviewCardEnabled && maps.length > 0 && teamId && !isNewTeam - ? AppRuntime.runPromise( - ScrimOverviewService.pipe( - Effect.flatMap((svc) => svc.getScrimOverview(id, teamId)) - ) - ) + ? getCachedScrimOverview(id, teamId) : Promise.resolve(null), showPositional && maps.length > 0 - ? AppRuntime.runPromise( - ScrimPositionalStatsService.pipe( - Effect.flatMap((svc) => svc.getScrimPositionalStats(id)) - ) - ) + ? getCachedScrimPositionalStats(id) : Promise.resolve(null), showPositional && maps.length > 0 && teamId - ? AppRuntime.runPromise( - ScrimPositionalArtifactsService.pipe( - Effect.flatMap((svc) => - svc.getScrimPositionalArtifacts(id, teamId) - ) - ) - ) + ? getCachedScrimPositionalArtifacts(id, teamId) : Promise.resolve(null), overviewCardEnabled && maps.length > 0 && teamId && !isNewTeam - ? AppRuntime.runPromise( - ScrimInitiationService.pipe( - Effect.flatMap((svc) => svc.getScrimInitiation(id)) - ) - ) + ? getCachedScrimInitiation(id) : Promise.resolve(null), ]); diff --git a/apps/web/src/app/api/scrim/add-map-stream/route.ts b/apps/web/src/app/api/scrim/add-map-stream/route.ts index e42fac261..742208936 100644 --- a/apps/web/src/app/api/scrim/add-map-stream/route.ts +++ b/apps/web/src/app/api/scrim/add-map-stream/route.ts @@ -1,6 +1,7 @@ import { auditLog } from "@/lib/audit-logs"; import { auth, canEditScrim, getCurrentUser } from "@/lib/auth"; import { mapAddedCounter, scrimParsingDuration } from "@/lib/axiom/metrics"; +import { revalidateScrim, revalidateTeamStats } from "@/lib/cache-tags"; import { Logger } from "@/lib/logger"; import { createNewMap } from "@/lib/parser"; import prisma from "@/lib/prisma"; @@ -111,6 +112,11 @@ export async function POST(req: NextRequest) { const parseDuration = performance.now() - parseStart; scrimParsingDuration.record(parseDuration); mapAddedCounter.add(1); + + // A new map changes the scrim's aggregates and the team's stats. + revalidateScrim(scrimId); + if (scrim?.teamId) revalidateTeamStats(scrim.teamId); + void usage.track({ name: UsageEventName.SCRIM_MAP_ADD, userId: user?.id, diff --git a/apps/web/src/app/api/scrim/add-map/route.ts b/apps/web/src/app/api/scrim/add-map/route.ts index 0e29b5870..371b4cb18 100644 --- a/apps/web/src/app/api/scrim/add-map/route.ts +++ b/apps/web/src/app/api/scrim/add-map/route.ts @@ -1,6 +1,7 @@ import { auditLog } from "@/lib/audit-logs"; import { auth, canEditScrim, getCurrentUser } from "@/lib/auth"; import { mapAddedCounter, scrimParsingDuration } from "@/lib/axiom/metrics"; +import { revalidateScrim, revalidateTeamStats } from "@/lib/cache-tags"; import { Logger } from "@/lib/logger"; import { createNewMap } from "@/lib/parser"; import prisma from "@/lib/prisma"; @@ -136,6 +137,11 @@ export async function POST(req: NextRequest) { const parseDuration = performance.now() - parseStart; scrimParsingDuration.record(parseDuration); mapAddedCounter.add(1); + + // A new map changes the scrim's aggregates and the team's stats. + revalidateScrim(scrimId); + if (scrim?.teamId) revalidateTeamStats(scrim.teamId); + void usage.track({ name: UsageEventName.SCRIM_MAP_ADD, userId: user?.id, diff --git a/apps/web/src/app/api/scrim/create-scrim-stream/route.ts b/apps/web/src/app/api/scrim/create-scrim-stream/route.ts index b8e8ff7c2..69d1b5598 100644 --- a/apps/web/src/app/api/scrim/create-scrim-stream/route.ts +++ b/apps/web/src/app/api/scrim/create-scrim-stream/route.ts @@ -7,6 +7,7 @@ import { scrimCreatedCounter, scrimParsingDuration, } from "@/lib/axiom/metrics"; +import { revalidateTeamStats } from "@/lib/cache-tags"; import { UsageEventName } from "@/lib/usage/names"; import { usage } from "@/lib/usage/server"; import { sendScrimNotifications } from "@/lib/bot-events"; @@ -156,6 +157,9 @@ export async function POST(request: NextRequest) { } } + // A new scrim for the team changes its aggregated stats. + if (teamId) revalidateTeamStats(teamId); + emit({ type: "done", scrimId: newScrimId }); event.outcome = "success"; event.scrim_id = newScrimId; diff --git a/apps/web/src/app/api/scrim/create-scrim/route.ts b/apps/web/src/app/api/scrim/create-scrim/route.ts index 1d31dd85f..dd732f731 100644 --- a/apps/web/src/app/api/scrim/create-scrim/route.ts +++ b/apps/web/src/app/api/scrim/create-scrim/route.ts @@ -4,6 +4,7 @@ import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; import { auth, canManageTeam } from "@/lib/auth"; import { withRequestContext } from "@/lib/axiom/baggage"; +import { revalidateTeamStats } from "@/lib/cache-tags"; import { rateLimitHitCounter, scrimCreatedCounter, @@ -325,6 +326,9 @@ export async function POST(request: NextRequest) { } }); + // A new scrim for the team changes its aggregated stats. + if (teamId) revalidateTeamStats(teamId); + return Response.json({ scrimId: newScrimId }, { status: 200 }); } ); diff --git a/apps/web/src/app/api/scrim/map/[mapId]/set-winner/route.ts b/apps/web/src/app/api/scrim/map/[mapId]/set-winner/route.ts index 77d8bdc7f..6ef676137 100644 --- a/apps/web/src/app/api/scrim/map/[mapId]/set-winner/route.ts +++ b/apps/web/src/app/api/scrim/map/[mapId]/set-winner/route.ts @@ -1,9 +1,13 @@ import { auditLog } from "@/lib/audit-logs"; import { auth, canEditScrim, getCurrentUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; +import { + revalidateMap, + revalidateScrim, + revalidateTeamStats, +} from "@/lib/cache-tags"; import prisma from "@/lib/prisma"; import { resolveSetWinnerOutcome } from "@/lib/scrim/set-winner-validation"; -import { revalidateTag } from "next/cache"; import { unauthorized, unstable_rethrow } from "next/navigation"; import { after, type NextRequest } from "next/server"; import { z } from "zod"; @@ -39,7 +43,12 @@ export async function POST( const map = await prisma.map.findUnique({ where: { id: mapId }, - select: { id: true, scrimId: true, mapData: { select: { id: true } } }, + select: { + id: true, + scrimId: true, + mapData: { select: { id: true } }, + Scrim: { select: { teamId: true } }, + }, }); if (!map?.scrimId) { return Response.json({ error: "Map not found" }, { status: 404 }); @@ -71,7 +80,9 @@ export async function POST( data: { winner: parsed.data.winner, winnerSource: "manual" }, }); - revalidateTag(`map:${mapId}`, "max"); + revalidateMap(mapId); + revalidateScrim(map.scrimId); + if (map.Scrim?.teamId) revalidateTeamStats(map.Scrim.teamId); after(async () => { await auditLog.createAuditLog({ diff --git a/apps/web/src/app/api/scrim/remove-map/route.ts b/apps/web/src/app/api/scrim/remove-map/route.ts index 66648a228..f7615ceec 100644 --- a/apps/web/src/app/api/scrim/remove-map/route.ts +++ b/apps/web/src/app/api/scrim/remove-map/route.ts @@ -2,8 +2,12 @@ import { auditLog } from "@/lib/audit-logs"; import { auth, canEditScrim, getCurrentUser } from "@/lib/auth"; import { mapDeletionDuration, mapRemovedCounter } from "@/lib/axiom/metrics"; import { Logger } from "@/lib/logger"; +import { + revalidateMap, + revalidateScrim, + revalidateTeamStats, +} from "@/lib/cache-tags"; import prisma from "@/lib/prisma"; -import { revalidateTag } from "next/cache"; import { unauthorized } from "next/navigation"; import { after, type NextRequest } from "next/server"; @@ -44,7 +48,9 @@ export async function POST(req: NextRequest) { mapDeletionDuration.record(performance.now() - deleteStart); mapRemovedCounter.add(1); - revalidateTag(`map:${mapId}`, "max"); + revalidateMap(mapId); + revalidateScrim(scrim.id); + if (scrim.teamId) revalidateTeamStats(scrim.teamId); after(async () => { await auditLog.createAuditLog({ diff --git a/apps/web/src/app/api/scrim/remove-scrim/route.ts b/apps/web/src/app/api/scrim/remove-scrim/route.ts index f5b68e8de..cd5e4af79 100644 --- a/apps/web/src/app/api/scrim/remove-scrim/route.ts +++ b/apps/web/src/app/api/scrim/remove-scrim/route.ts @@ -1,6 +1,7 @@ import { auditLog } from "@/lib/audit-logs"; import { auth, canEditScrim, getCurrentUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; +import { revalidateScrim, revalidateTeamStats } from "@/lib/cache-tags"; import { notifications } from "@/lib/notifications"; import prisma from "@/lib/prisma"; import { unauthorized } from "next/navigation"; @@ -53,6 +54,9 @@ export async function POST(req: NextRequest) { await prisma.map.deleteMany({ where: { scrimId } }); await prisma.scrim.delete({ where: { id: scrimId } }); + revalidateScrim(scrimId); + if (scrim.teamId) revalidateTeamStats(scrim.teamId); + after(async () => { await auditLog.createAuditLog({ userEmail: user.email, diff --git a/apps/web/src/app/api/scrim/update-scrim-options/route.ts b/apps/web/src/app/api/scrim/update-scrim-options/route.ts index 5c1468308..80254fbc0 100644 --- a/apps/web/src/app/api/scrim/update-scrim-options/route.ts +++ b/apps/web/src/app/api/scrim/update-scrim-options/route.ts @@ -1,5 +1,6 @@ import { auditLog } from "@/lib/audit-logs"; import { auth, canEditScrim, canManageTeam, getCurrentUser } from "@/lib/auth"; +import { revalidateScrim, revalidateTeamStats } from "@/lib/cache-tags"; import prisma from "@/lib/prisma"; import { unauthorized } from "next/navigation"; import { after, type NextRequest } from "next/server"; @@ -115,6 +116,12 @@ export async function POST(req: NextRequest) { } }); + revalidateScrim(scrim.id); + // The scrim may have moved teams, so refresh both the old and new team's stats. + const newTeamId = targetTeamId === 0 ? null : targetTeamId; + if (scrim.teamId) revalidateTeamStats(scrim.teamId); + if (newTeamId && newTeamId !== scrim.teamId) revalidateTeamStats(newTeamId); + after(async () => { await auditLog.createAuditLog({ userEmail: user.email, diff --git a/apps/web/src/app/stats/team/[teamId]/_lib/context.ts b/apps/web/src/app/stats/team/[teamId]/_lib/context.ts index 5c0770c6f..3cbc9564d 100644 --- a/apps/web/src/app/stats/team/[teamId]/_lib/context.ts +++ b/apps/web/src/app/stats/team/[teamId]/_lib/context.ts @@ -1,6 +1,9 @@ +import { + getCachedTeamSubstituteNames, + getCachedTeamWinrates, +} from "@/data/cached/team-cache"; import { AppRuntime } from "@/data/runtime"; -import { type TeamDateRange, TeamStatsService } from "@/data/team"; -import { getTeamSubstituteNames } from "@/data/team/substitutes"; +import { type TeamDateRange } from "@/data/team"; import { UserService } from "@/data/user"; import { $Enums } from "@/generated/prisma/browser"; import { auth, canManageTeam } from "@/lib/auth"; @@ -124,7 +127,7 @@ export async function loadTeamStatsShell( const [isManager, substituteNames] = await Promise.all([ canManageTeam(teamId, user), - getTeamSubstituteNames(teamId), + getCachedTeamSubstituteNames(teamId), ]); const [timeframe1, timeframe2, timeframe3] = await Promise.all([ @@ -181,11 +184,7 @@ export async function loadTeamStatsHeaderData( ) { const { teamId, team, dateRange, substituteNames } = shell; const [winrates, teamTsr] = await Promise.all([ - AppRuntime.runPromise( - TeamStatsService.pipe( - Effect.flatMap((svc) => svc.getTeamWinrates(teamId, dateRange)) - ) - ), + getCachedTeamWinrates(teamId, dateRange), prisma.scrim .findMany({ where: { teamId }, select: { id: true } }) .then((rows) => diff --git a/apps/web/src/data/cached/map-cache.ts b/apps/web/src/data/cached/map-cache.ts index 5a045baf8..234816c91 100644 --- a/apps/web/src/data/cached/map-cache.ts +++ b/apps/web/src/data/cached/map-cache.ts @@ -3,6 +3,7 @@ import "server-only"; import { MatchStoryService } from "@/data/map/match-story-service"; import { PlayerService } from "@/data/player"; import { AppRuntime } from "@/data/runtime"; +import { mapTag } from "@/lib/cache-tags"; import { Effect } from "effect"; import { cacheLife, cacheTag } from "next/cache"; @@ -14,7 +15,7 @@ import { cacheLife, cacheTag } from "next/cache"; export async function getCachedMostPlayedHeroes(mapId: number) { "use cache"; cacheLife("max"); - cacheTag(`map:${mapId}`); + cacheTag(mapTag(mapId)); return AppRuntime.runPromise( PlayerService.pipe(Effect.flatMap((svc) => svc.getMostPlayedHeroes(mapId))) ); @@ -23,7 +24,7 @@ export async function getCachedMostPlayedHeroes(mapId: number) { export async function getCachedMatchStory(mapId: number, mapDataId: number) { "use cache"; cacheLife("max"); - cacheTag(`map:${mapId}`); + cacheTag(mapTag(mapId)); // A story failure must never break the map page — the tab just hides. return AppRuntime.runPromise( MatchStoryService.pipe( diff --git a/apps/web/src/data/cached/scrim-cache.ts b/apps/web/src/data/cached/scrim-cache.ts new file mode 100644 index 000000000..85da9df69 --- /dev/null +++ b/apps/web/src/data/cached/scrim-cache.ts @@ -0,0 +1,73 @@ +import "server-only"; + +import { AppRuntime } from "@/data/runtime"; +import { + ScrimOverviewService, + ScrimPositionalArtifactsService, + ScrimPositionalStatsService, + ScrimService, +} from "@/data/scrim"; +import { ScrimInitiationService } from "@/data/scrim/initiation-service"; +import { scrimTag } from "@/lib/cache-tags"; +import { Effect } from "effect"; +import { cacheLife, cacheTag } from "next/cache"; + +// Scrim-level aggregates depend on the full set of maps in the scrim, so unlike +// per-map reads they change when maps are added, removed, or edited. They're +// tagged `scrim:${scrimId}` and invalidated from the scrim mutation routes. + +export async function getCachedScrim(scrimId: number) { + "use cache"; + cacheLife("max"); + cacheTag(scrimTag(scrimId)); + return AppRuntime.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(scrimId))) + ); +} + +export async function getCachedScrimOverview(scrimId: number, teamId: number) { + "use cache"; + cacheLife("max"); + cacheTag(scrimTag(scrimId)); + return AppRuntime.runPromise( + ScrimOverviewService.pipe( + Effect.flatMap((svc) => svc.getScrimOverview(scrimId, teamId)) + ) + ); +} + +export async function getCachedScrimPositionalStats(scrimId: number) { + "use cache"; + cacheLife("max"); + cacheTag(scrimTag(scrimId)); + return AppRuntime.runPromise( + ScrimPositionalStatsService.pipe( + Effect.flatMap((svc) => svc.getScrimPositionalStats(scrimId)) + ) + ); +} + +export async function getCachedScrimPositionalArtifacts( + scrimId: number, + teamId: number +) { + "use cache"; + cacheLife("max"); + cacheTag(scrimTag(scrimId)); + return AppRuntime.runPromise( + ScrimPositionalArtifactsService.pipe( + Effect.flatMap((svc) => svc.getScrimPositionalArtifacts(scrimId, teamId)) + ) + ); +} + +export async function getCachedScrimInitiation(scrimId: number) { + "use cache"; + cacheLife("max"); + cacheTag(scrimTag(scrimId)); + return AppRuntime.runPromise( + ScrimInitiationService.pipe( + Effect.flatMap((svc) => svc.getScrimInitiation(scrimId)) + ) + ); +} diff --git a/apps/web/src/data/cached/team-cache.ts b/apps/web/src/data/cached/team-cache.ts new file mode 100644 index 000000000..1d8c171a7 --- /dev/null +++ b/apps/web/src/data/cached/team-cache.ts @@ -0,0 +1,35 @@ +import "server-only"; + +import { AppRuntime } from "@/data/runtime"; +import { type TeamDateRange, TeamStatsService } from "@/data/team"; +import { getTeamSubstituteNames } from "@/data/team/substitutes"; +import { teamStatsTag } from "@/lib/cache-tags"; +import { Effect } from "effect"; +import { cacheLife, cacheTag } from "next/cache"; + +// Team stats change whenever a scrim is uploaded to (or removed from) the team, +// so they're tagged `team-stats:${teamId}` and invalidated from the scrim +// mutation routes. `cacheLife('days')` is a safety net in case an invalidation +// is ever missed; the per-request date range is passed in as part of the key, +// never read inside the cache scope. + +export async function getCachedTeamWinrates( + teamId: number, + dateRange: TeamDateRange | undefined +) { + "use cache"; + cacheLife("days"); + cacheTag(teamStatsTag(teamId)); + return AppRuntime.runPromise( + TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getTeamWinrates(teamId, dateRange)) + ) + ); +} + +export async function getCachedTeamSubstituteNames(teamId: number) { + "use cache"; + cacheLife("days"); + cacheTag(teamStatsTag(teamId)); + return getTeamSubstituteNames(teamId); +} diff --git a/apps/web/src/lib/cache-tags.ts b/apps/web/src/lib/cache-tags.ts new file mode 100644 index 000000000..17bcb2626 --- /dev/null +++ b/apps/web/src/lib/cache-tags.ts @@ -0,0 +1,29 @@ +import { revalidateTag } from "next/cache"; + +// Tag helpers for the cached data readers under `src/data/cached`. Kept in one +// place so the tag format stays in sync between the readers and the mutation +// routes that invalidate them. + +export function scrimTag(scrimId: number) { + return `scrim:${scrimId}`; +} + +export function mapTag(mapId: number) { + return `map:${mapId}`; +} + +export function teamStatsTag(teamId: number) { + return `team-stats:${teamId}`; +} + +export function revalidateScrim(scrimId: number) { + revalidateTag(scrimTag(scrimId), "max"); +} + +export function revalidateMap(mapId: number) { + revalidateTag(mapTag(mapId), "max"); +} + +export function revalidateTeamStats(teamId: number) { + revalidateTag(teamStatsTag(teamId), "max"); +} From d0a696437f064be477c0891fb7dd8b8f7a40bf99 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 04:27:42 -0400 Subject: [PATCH 17/59] Drop redundant comments on cache invalidation calls --- apps/web/src/app/api/scrim/add-map-stream/route.ts | 1 - apps/web/src/app/api/scrim/add-map/route.ts | 1 - apps/web/src/app/api/scrim/create-scrim-stream/route.ts | 1 - apps/web/src/app/api/scrim/create-scrim/route.ts | 1 - 4 files changed, 4 deletions(-) diff --git a/apps/web/src/app/api/scrim/add-map-stream/route.ts b/apps/web/src/app/api/scrim/add-map-stream/route.ts index 742208936..d76032158 100644 --- a/apps/web/src/app/api/scrim/add-map-stream/route.ts +++ b/apps/web/src/app/api/scrim/add-map-stream/route.ts @@ -113,7 +113,6 @@ export async function POST(req: NextRequest) { scrimParsingDuration.record(parseDuration); mapAddedCounter.add(1); - // A new map changes the scrim's aggregates and the team's stats. revalidateScrim(scrimId); if (scrim?.teamId) revalidateTeamStats(scrim.teamId); diff --git a/apps/web/src/app/api/scrim/add-map/route.ts b/apps/web/src/app/api/scrim/add-map/route.ts index 371b4cb18..9b3abb5d5 100644 --- a/apps/web/src/app/api/scrim/add-map/route.ts +++ b/apps/web/src/app/api/scrim/add-map/route.ts @@ -138,7 +138,6 @@ export async function POST(req: NextRequest) { scrimParsingDuration.record(parseDuration); mapAddedCounter.add(1); - // A new map changes the scrim's aggregates and the team's stats. revalidateScrim(scrimId); if (scrim?.teamId) revalidateTeamStats(scrim.teamId); diff --git a/apps/web/src/app/api/scrim/create-scrim-stream/route.ts b/apps/web/src/app/api/scrim/create-scrim-stream/route.ts index 69d1b5598..2c827af57 100644 --- a/apps/web/src/app/api/scrim/create-scrim-stream/route.ts +++ b/apps/web/src/app/api/scrim/create-scrim-stream/route.ts @@ -157,7 +157,6 @@ export async function POST(request: NextRequest) { } } - // A new scrim for the team changes its aggregated stats. if (teamId) revalidateTeamStats(teamId); emit({ type: "done", scrimId: newScrimId }); diff --git a/apps/web/src/app/api/scrim/create-scrim/route.ts b/apps/web/src/app/api/scrim/create-scrim/route.ts index dd732f731..eb8e2e785 100644 --- a/apps/web/src/app/api/scrim/create-scrim/route.ts +++ b/apps/web/src/app/api/scrim/create-scrim/route.ts @@ -326,7 +326,6 @@ export async function POST(request: NextRequest) { } }); - // A new scrim for the team changes its aggregated stats. if (teamId) revalidateTeamStats(teamId); return Response.json({ scrimId: newScrimId }, { status: 200 }); From b9a36831e8e39648e48c3f89a5f93e8fd0d9cb88 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 09:15:14 -0400 Subject: [PATCH 18/59] Use BatchSpanProcessor so telemetry never blocks prerendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/src/instrumentation-node.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/web/src/instrumentation-node.ts b/apps/web/src/instrumentation-node.ts index c0cf75290..e1d9db8c2 100644 --- a/apps/web/src/instrumentation-node.ts +++ b/apps/web/src/instrumentation-node.ts @@ -15,7 +15,6 @@ import { import { BatchSpanProcessor, NodeTracerProvider, - SimpleSpanProcessor, } from "@opentelemetry/sdk-trace-node"; import { ATTR_SERVICE_NAME, @@ -74,11 +73,10 @@ export function registerNode() { const provider = new NodeTracerProvider({ resource: RESOURCE, - spanProcessors: [ - IS_PROD - ? new BatchSpanProcessor(otlpTraceExporter) - : new SimpleSpanProcessor(otlpTraceExporter), - ], + // BatchSpanProcessor exports on a timer rather than synchronously on span + // end, so trace export never reads Date.now() inside a Cache Components + // prerender pass (which the dev server runs per request). + spanProcessors: [new BatchSpanProcessor(otlpTraceExporter)], }); registerInstrumentations({ @@ -188,9 +186,7 @@ const EffectTracingLive = NodeSdk.layer(() => ({ [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: ENVIRONMENT, }, }, - spanProcessor: IS_PROD - ? new BatchSpanProcessor(new OTLPTraceExporter(OTLP_CONFIG)) - : new SimpleSpanProcessor(new OTLPTraceExporter(OTLP_CONFIG)), + spanProcessor: new BatchSpanProcessor(new OTLPTraceExporter(OTLP_CONFIG)), metricReader: new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter(METRIC_EXPORTER_CONFIG), exportIntervalMillis: 10_000, From 4db1e85794072b840e01dcda4e5191e37fedd312 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 09:22:06 -0400 Subject: [PATCH 19/59] Hold partialPrefetching until authed routes are instant-ready --- apps/web/next.config.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 6881b833a..a99c3cde2 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -26,7 +26,10 @@ const cspHeader = ` const nextConfig: NextConfig = { cacheComponents: true, - partialPrefetching: true, + // partialPrefetching (instant navigations) is held until the authed routes + // are instant-ready — today their layouts read auth() at the top, so the + // per-route shell can't prerender and every authed nav reports as blocking. + // Re-enable once those shells stream auth-gated content behind . reactCompiler: true, typedRoutes: true, transpilePackages: ["@parsertime/transactional"], From 8889845ec3900760c35dbe4da971555bde04e7c4 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 26 Jun 2026 09:32:47 -0400 Subject: [PATCH 20/59] Stream auth-gated content on the scrim and map layouts 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. --- apps/web/next.config.ts | 5 +-- .../src/app/[team]/scrim/[scrimId]/layout.tsx | 29 +++++++++------ .../scrim/[scrimId]/map/[mapId]/layout.tsx | 37 +++++++++++-------- 3 files changed, 40 insertions(+), 31 deletions(-) diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index a99c3cde2..6881b833a 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -26,10 +26,7 @@ const cspHeader = ` const nextConfig: NextConfig = { cacheComponents: true, - // partialPrefetching (instant navigations) is held until the authed routes - // are instant-ready — today their layouts read auth() at the top, so the - // per-route shell can't prerender and every authed nav reports as blocking. - // Re-enable once those shells stream auth-gated content behind . + partialPrefetching: true, reactCompiler: true, typedRoutes: true, transpilePackages: ["@parsertime/transactional"], diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/layout.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/layout.tsx index b89711136..c6e4981dc 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/layout.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/layout.tsx @@ -1,20 +1,27 @@ import { NoAuthCard } from "@/components/auth/no-auth"; import { SelectedPlayerProvider } from "@/components/map/player-switcher"; import { isAuthedToViewScrim } from "@/lib/auth"; +import { Suspense, type ReactNode } from "react"; -export default async function ScrimDashboardLayout( +export default function ScrimDashboardLayout( props: LayoutProps<"/[team]/scrim/[scrimId]"> ) { - const params = await props.params; - - const { children } = props; - - const id = parseInt(params.scrimId); - const isAuthed = await isAuthedToViewScrim(id); - - if (!isAuthed) { - return ; - } + return ( + + {props.children} + + ); +} +async function ScrimAuthGate({ + params, + children, +}: { + params: LayoutProps<"/[team]/scrim/[scrimId]">["params"]; + children: ReactNode; +}) { + const { scrimId } = await params; + const isAuthed = await isAuthedToViewScrim(parseInt(scrimId)); + if (!isAuthed) return ; return {children}; } diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/layout.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/layout.tsx index 93599c5f2..924b6f811 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/layout.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/layout.tsx @@ -3,28 +3,33 @@ import { Footer } from "@/components/footer"; import { MobileBanner } from "@/components/map/mobile-banner"; import { SelectedPlayerProvider } from "@/components/map/player-switcher"; import { isAuthedToViewMap } from "@/lib/auth"; +import { Suspense, type ReactNode } from "react"; -export default async function MapDashboardLayout( +export default function MapDashboardLayout( props: LayoutProps<"/[team]/scrim/[scrimId]/map/[mapId]"> ) { - const params = await props.params; - - const { children } = props; - - const scrimId = parseInt(params.scrimId); - const mapId = parseInt(params.mapId); - - const isAuthed = await isAuthedToViewMap(scrimId, mapId); - - if (!isAuthed) { - return ; - } - + // The chrome (provider, banner, footer) is static so navigations to a map + // paint instantly; the authorization check and the page stream in behind it. return ( - {children} - - + + ); +} + +// Mirrors the loaded page's section rhythm (hero, tier cards, comparison +// table, logo cloud, testimonial, FAQ, CTA, footer) with matching paddings so +// the streamed content replaces this in place with no jump. +function PricingSkeleton() { + return ( + <> +
+
+
+
+ +
+ + +
+
+
+
+ +
+
+
+ {["a", "b", "c"].map((k) => ( +
+ + + + +
+ {["p", "q", "r", "s", "t", "u", "v", "w"].map((item) => ( + + ))} +
+
+ ))} +
+
+
+ +
+
+
+ {["a", "b", "c"].map((k) => ( +
+ + + +
+ {["p", "q", "r", "s", "t"].map((item) => ( + + ))} +
+
+ ))} +
+ +
+
+
+ {["a", "b", "c"].map((k) => ( +
+ + + +
+ ))} +
+ {["fa", "fb", "fc", "fd", "fe"].map((section) => ( +
+ +
+ {["r1", "r2", "r3", "r4", "r5"].map((row) => ( +
+ + {["c1", "c2", "c3"].map((col) => ( + + ))} +
+ ))} +
+
+ ))} +
+
+
+ +
+ +
+ {["a", "b", "c", "d", "e", "f", "g"].map((k) => ( + + ))} +
+
+ +
+
+
+ {["a", "b", "c", "d", "e"].map((k) => ( + + ))} +
+
+ + + +
+
+ +
+ + +
+
+
+
+ +
+
+
+
+ + + +
+
+
+ {["a", "b", "c", "d", "e", "f"].map((k) => ( +
+ + + +
+ ))} +
+
+
+
+
+ +
+
+ + + + +
+ + +
+
+
+
+ +
+
+
+
+ {["a", "b", "c"].map((k) => ( + + ))} +
+ +
+
+
+ ); } diff --git a/apps/web/src/app/privacy/loading.tsx b/apps/web/src/app/privacy/loading.tsx deleted file mode 100644 index c5771e0b9..000000000 --- a/apps/web/src/app/privacy/loading.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
- -
- - -
- - -
- - -
-
    - {["a", "b", "c"].map((k) => ( -
  • - -
    - - - -
    -
  • - ))} -
- - -
- - -
-
    - {["a", "b", "c", "d"].map((k) => ( -
  • - -
  • - ))} -
- - {["a", "b", "c", "d"].map((k) => ( -
- -
- - -
-
- ))} -
-
- ); -} diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index 8bfc2decb..78da6595a 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -1,102 +1,169 @@ +import { Skeleton } from "@/components/ui/skeleton"; import { CheckCircleIcon } from "@heroicons/react/20/solid"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; +import { Suspense } from "react"; -export default async function PrivacyPage() { - const t = await getTranslations("privacyPage"); - +// Static shell: the page frame prerenders and the locale-aware policy text +// streams into ONE boundary whose fallback mirrors the loaded layout. +export default function PrivacyPage() { return (
-

- {t("privacyPolicy.title")} -

-

- {t("privacyPolicy.description")} -

-

- {t("collectInformation.title")} -

-
-

{t("collectInformation.description")}

-
    -
  • -
  • -
  • -
  • -
  • -
  • -
-
-

- {t("useInformation.title")} -

-

{t("useInformation.description")}

-
    -
  • {t("useInformation.list1")}
  • -
  • {t("useInformation.list2")}
  • -
  • {t("useInformation.list3")}
  • -
  • {t("useInformation.list4")}
  • + }> + + +
+
+ ); +} + +async function PrivacyContent() { + const t = await getTranslations("privacyPage"); + + return ( + <> +

+ {t("privacyPolicy.title")} +

+

{t("privacyPolicy.description")}

+

+ {t("collectInformation.title")} +

+
+

{t("collectInformation.description")}

+
    +
  • +
  • +
  • +
  • +
  • +
+
+

+ {t("useInformation.title")} +

+

{t("useInformation.description")}

+
    +
  • {t("useInformation.list1")}
  • +
  • {t("useInformation.list2")}
  • +
  • {t("useInformation.list3")}
  • +
  • {t("useInformation.list4")}
  • +
-

- {t("storageSecurity.title")} -

-

{t("storageSecurity.description")}

+

+ {t("storageSecurity.title")} +

+

{t("storageSecurity.description")}

-

- {t("thirdPartyLinks.title")} -

-

{t("thirdPartyLinks.description")}

+

+ {t("thirdPartyLinks.title")} +

+

{t("thirdPartyLinks.description")}

-

- {t("changePrivacyPolicy.title")} -

-

{t("changePrivacyPolicy.description")}

+

+ {t("changePrivacyPolicy.title")} +

+

{t("changePrivacyPolicy.description")}

-

- {t("contactUs.title")} -

-

- {t.rich("contactUs.description", { - link: (chunks) => ( - - {chunks} - - ), - })} -

+

+ {t("contactUs.title")} +

+

+ {t.rich("contactUs.description", { + link: (chunks) => ( + + {chunks} + + ), + })} +

+ + ); +} + +// Mirrors the loaded content's layout (heading, intro, icon list, disc list, +// trailing sections) so the streamed text replaces it in place with no jump. +function PrivacyPolicySkeleton() { + return ( + <> + +
+ +
- + + +
+ + +
+
    + {["a", "b", "c"].map((k) => ( +
  • + +
    + + + +
    +
  • + ))} +
+ + +
+ + +
+
    + {["a", "b", "c", "d"].map((k) => ( +
  • + +
  • + ))} +
+ + {["a", "b", "c", "d"].map((k) => ( +
+ +
+ + +
+
+ ))} + ); } diff --git a/apps/web/src/app/profile/[playerName]/loading.tsx b/apps/web/src/app/profile/[playerName]/loading.tsx deleted file mode 100644 index a1bf57e98..000000000 --- a/apps/web/src/app/profile/[playerName]/loading.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
-
- -
- - -
-
-
- {["a", "b", "c", "d"].map((k) => ( -
- - - -
- ))} -
-
- -
- {["a", "b", "c", "d", "e"].map((k) => ( - - ))} -
- -
-
- {["a", "b", "c", "d"].map((k) => ( -
- - - -
- ))} -
- -
-
- - -
-
- {["a", "b"].map((k) => ( -
- - - -
- ))} -
-
- -
-
- - -
-
- {["a", "b", "c"].map((k) => ( -
- -
- - - -
-
- ))} -
-
- -
-
- - -
-
- {["a", "b", "c", "d", "e", "f", "g", "h"].map((k) => ( -
-
- - -
- - - -
- ))} -
-
- -
-
- - -
-
- {["a", "b", "c"].map((k) => ( -
- -
- - -
-
- ))} -
-
- - - -
-
- - -
-
- {["a", "b", "c", "d", "e", "f", "g", "h"].map((k) => ( - - ))} -
-
- -
-
- - - -
-
- {["a", "b", "c", "d", "e", "f"].map((k) => ( - - ))} -
-
-
-
- ); -} diff --git a/apps/web/src/app/profile/[playerName]/page.tsx b/apps/web/src/app/profile/[playerName]/page.tsx index cb4ac54a6..7f25146e8 100644 --- a/apps/web/src/app/profile/[playerName]/page.tsx +++ b/apps/web/src/app/profile/[playerName]/page.tsx @@ -16,6 +16,7 @@ import { import { SectionHeader } from "@/components/stats/team/section-header"; import { StatRibbon } from "@/components/stats/team/stat-ribbon"; import { PlayerTargetsTab } from "@/components/targets/player-targets-tab"; +import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { RankedService } from "@/data/ranked"; import { ScrimService } from "@/data/scrim"; @@ -41,6 +42,7 @@ import type { PagePropsWithLocale } from "@/types/next"; import { $Enums, Prisma, type Scrim } from "@/generated/prisma/client"; import { getTranslations } from "next-intl/server"; import Image from "next/image"; +import { Suspense } from "react"; type HeroData = { player_hero: string; @@ -60,10 +62,28 @@ const ROLE_HUE_CLASS: Record<"Tank" | "Damage" | "Support", string> = { Support: "bg-emerald-500/80", }; -export default async function ProfilePage( +// Static shell: the page frame prerenders and all request-derived content +// streams into ONE boundary whose fallback mirrors the loaded profile layout, +// so navigation shows a single stable skeleton that the content replaces in +// place. +export default function ProfilePage( props: PagePropsWithLocale<"/profile/[playerName]"> ) { - const params = await props.params; + return ( +
+ }> + + +
+ ); +} + +async function ProfileContent({ + params: paramsPromise, +}: { + params: PagePropsWithLocale<"/profile/[playerName]">["params"]; +}) { + const params = await paramsPromise; const name = decodeURIComponent(params.playerName); const t = await getTranslations("heroes"); const session = await auth(); @@ -414,7 +434,7 @@ export default async function ProfilePage( : []; return ( -
+ <> )} -
+ + ); +} + +// Mirrors the loaded profile's pending layout (header, tab bar, stat ribbon, +// and the overview sections) so the streamed content replaces this in place. +function ProfileSkeleton() { + return ( + <> +
+
+ +
+ + +
+
+
+ {["a", "b", "c", "d"].map((k) => ( +
+ + + +
+ ))} +
+
+ +
+ {["a", "b", "c", "d", "e"].map((k) => ( + + ))} +
+ +
+
+ {["a", "b", "c", "d"].map((k) => ( +
+ + + +
+ ))} +
+ +
+
+ + +
+
+ {["a", "b"].map((k) => ( +
+ + + +
+ ))} +
+
+ +
+
+ + +
+
+ {["a", "b", "c"].map((k) => ( +
+ +
+ + + +
+
+ ))} +
+
+ +
+
+ + +
+
+ {["a", "b", "c", "d", "e", "f", "g", "h"].map((k) => ( +
+
+ + +
+ + + +
+ ))} +
+
+ +
+
+ + +
+
+ {["a", "b", "c"].map((k) => ( +
+ +
+ + +
+
+ ))} +
+
+ + + +
+
+ + +
+
+ {["a", "b", "c", "d", "e", "f", "g", "h"].map((k) => ( + + ))} +
+
+ +
+
+ + + +
+
+ {["a", "b", "c", "d", "e", "f"].map((k) => ( + + ))} +
+
+
+ ); } diff --git a/apps/web/src/app/profile/loading.tsx b/apps/web/src/app/profile/loading.tsx deleted file mode 100644 index 3be675c40..000000000 --- a/apps/web/src/app/profile/loading.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
-
- -
- - -
-
-
- {["a", "b", "c", "d"].map((k) => ( -
- - - -
- ))} -
-
- -
- {["a", "b", "c", "d"].map((k) => ( - - ))} -
- -
-
- {["a", "b", "c", "d"].map((k) => ( -
- - - -
- ))} -
- -
-
- - -
-
- {["a", "b"].map((k) => ( - - ))} -
-
- -
-
- - -
-
- {["a", "b", "c"].map((k) => ( -
- -
- - - -
-
- ))} -
-
- -
-
- - -
-
    - {["a", "b", "c", "d", "e", "f", "g", "h"].map((k) => ( -
  • - - - - - -
  • - ))} -
-
- -
-
- - -
-
- {["a", "b", "c"].map((k) => ( -
-
- - -
-
- - -
-
- ))} -
-
- -
-
- - -
- -
- -
-
- - -
- {["a", "b", "c"].map((k) => ( -
- -
- {["a", "b", "c", "d"].map((j) => ( - - ))} -
-
- ))} -
- -
-
- - - -
-
- {["a", "b", "c", "d", "e", "f", "g", "h"].map((k) => ( - - ))} -
-
-
-
- ); -} diff --git a/apps/web/src/app/profile/page.tsx b/apps/web/src/app/profile/page.tsx index b1ee69d68..9b58ec00b 100644 --- a/apps/web/src/app/profile/page.tsx +++ b/apps/web/src/app/profile/page.tsx @@ -1,10 +1,22 @@ +import { Skeleton } from "@/components/ui/skeleton"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { notFound, redirect } from "next/navigation"; +import { Suspense } from "react"; -export default async function BaseProfilePage() { +export default function BaseProfilePage() { + return ( + }> + + + ); +} + +// Explicit return type: the body always redirects/404s, so TS otherwise +// infers Promise, which is not a valid JSX component type. +async function ProfileRedirect(): Promise { const session = await auth(); const user = await AppRuntime.runPromise( UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) @@ -14,3 +26,160 @@ export default async function BaseProfilePage() { if (!user.battletag) notFound(); redirect(`/profile/${user.battletag}`); } + +// This page only redirects to /profile/[battletag], so the fallback mirrors +// the destination profile page's frame — the redirect lands without a +// design swap. +function ProfileSkeleton() { + return ( +
+
+
+ +
+ + +
+
+
+ {["a", "b", "c", "d"].map((k) => ( +
+ + + +
+ ))} +
+
+ +
+ {["a", "b", "c", "d"].map((k) => ( + + ))} +
+ +
+
+ {["a", "b", "c", "d"].map((k) => ( +
+ + + +
+ ))} +
+ +
+
+ + +
+
+ {["a", "b"].map((k) => ( + + ))} +
+
+ +
+
+ + +
+
+ {["a", "b", "c"].map((k) => ( +
+ +
+ + + +
+
+ ))} +
+
+ +
+
+ + +
+
    + {["a", "b", "c", "d", "e", "f", "g", "h"].map((k) => ( +
  • + + + + + +
  • + ))} +
+
+ +
+
+ + +
+
+ {["a", "b", "c"].map((k) => ( +
+
+ + +
+
+ + +
+
+ ))} +
+
+ +
+
+ + +
+ +
+ +
+
+ + +
+ {["a", "b", "c"].map((k) => ( +
+ +
+ {["a", "b", "c", "d"].map((j) => ( + + ))} +
+
+ ))} +
+ +
+
+ + + +
+
+ {["a", "b", "c", "d", "e", "f", "g", "h"].map((k) => ( + + ))} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/query/loading.tsx b/apps/web/src/app/query/loading.tsx deleted file mode 100644 index 10befdd92..000000000 --- a/apps/web/src/app/query/loading.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
-
- - - -
-
- - - - -
-
- -
-
-
- -
- - -
-
- -
- -
- {["a", "b", "c", "d", "e"].map((k) => ( - - ))} -
-
- {["f", "g", "h", "i"].map((k) => ( - - ))} -
-
-
- -
-
- -
- -
- {["a", "b", "c"].map((k) => ( - - ))} -
- -
- - -
-
-
-
- ); -} diff --git a/apps/web/src/app/query/page.tsx b/apps/web/src/app/query/page.tsx index 15adaa665..6b88cd134 100644 --- a/apps/web/src/app/query/page.tsx +++ b/apps/web/src/app/query/page.tsx @@ -1,5 +1,6 @@ import { DashboardLayout } from "@/components/dashboard-layout"; import { QueryBuilder } from "@/components/query-builder/query-builder"; +import { Skeleton } from "@/components/ui/skeleton"; import { auth } from "@/lib/auth"; import { queryBuilder } from "@/lib/flags"; import { getFlag } from "@/lib/flags-helpers"; @@ -8,13 +9,28 @@ import { getViewableTeams, listSavedQueries } from "@/lib/query-builder/server"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound, redirect } from "next/navigation"; +import { Suspense } from "react"; export function generateMetadata(): Metadata { const t = getMetadataTranslations("queryBuilderPage.metadata"); return { title: t("title"), description: t("description") }; } -export default async function QueryPage() { +// Static shell: the dashboard chrome prerenders, and the flag/auth/DB work +// streams into ONE boundary whose fallback mirrors QueryBuilder's own frame — +// so navigation shows a single, stable skeleton that the builder replaces in +// place. +export default function QueryPage() { + return ( + + }> + + + + ); +} + +async function QueryPageContent() { const enabled = await getFlag(queryBuilder); if (!enabled) notFound(); @@ -29,32 +45,92 @@ export default async function QueryPage() { if (teams.length === 0) { return ( - -
-
-

- {t("title")} -

-

- {t("subtitle")} -

-
-
-

- {t("noAccessTitle")} -

-

- {t("noAccessBody")} -

-
+
+
+

+ {t("title")} +

+

+ {t("subtitle")} +

+
+
+

+ {t("noAccessTitle")} +

+

+ {t("noAccessBody")} +

- +
); } + return ; +} + +// Mirrors QueryBuilder's frame (header with eyebrow/title/subtitle and action +// row, the ask/build card, the output section) so the streamed builder +// replaces this in place with no jump. +function QueryBuilderSkeleton() { return ( - - - +
+
+
+ + + +
+
+ + + + +
+
+ +
+
+
+ +
+ + +
+
+ +
+ +
+ {["a", "b", "c", "d", "e"].map((k) => ( + + ))} +
+
+ {["f", "g", "h", "i"].map((k) => ( + + ))} +
+
+
+ +
+
+ +
+ +
+ {["a", "b", "c"].map((k) => ( + + ))} +
+ +
+ + +
+
+
+
); } diff --git a/apps/web/src/app/ranked/loading.tsx b/apps/web/src/app/ranked/loading.tsx deleted file mode 100644 index e21c61c25..000000000 --- a/apps/web/src/app/ranked/loading.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/* oxlint-disable react/no-array-index-key */ -import { Skeleton } from "@/components/ui/skeleton"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; - -const tabTriggerClass = - "text-muted-foreground hover:text-foreground data-[state=active]:text-foreground border-0 border-b-2 border-b-transparent data-[state=active]:border-b-primary rounded-none bg-transparent px-0 pb-3 pt-1 font-mono text-[11px] tracking-[0.16em] uppercase shadow-none data-[state=active]:shadow-none data-[state=active]:bg-transparent dark:bg-transparent dark:data-[state=active]:bg-transparent dark:data-[state=active]:border-b-primary transition-colors"; - -const TAB_LABELS = [ - "Overview", - "Heroes", - "Maps", - "Time", - "Patches", - "Groups", - "Roles", -]; - -export default function RankedLoading() { - return ( -
-
- - -
- -
-
- - - -
- -
- {Array.from({ length: 4 }).map((_, i) => ( -
- - - -
- ))} -
- - - - {TAB_LABELS.map((label) => ( - - {label} - - ))} - - - - -
- - -
-
-
-
-
- ); -} - -function SkeletonSection({ bodyHeight }: { bodyHeight: number }) { - return ( -
-
- - - -
- -
- ); -} diff --git a/apps/web/src/app/ranked/page.tsx b/apps/web/src/app/ranked/page.tsx index c1631c7fb..7d50581df 100644 --- a/apps/web/src/app/ranked/page.tsx +++ b/apps/web/src/app/ranked/page.tsx @@ -1,3 +1,4 @@ +/* oxlint-disable react/no-array-index-key */ import { DashboardContent } from "@/components/ranked/dashboard-content"; import { ImportCard } from "@/components/ranked/import-card"; import { MatchForm } from "@/components/ranked/match-form"; @@ -10,24 +11,53 @@ import { EmptyMedia, EmptyTitle, } from "@/components/ui/empty"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getOverwatchPatches } from "@/data/overwatch/patches-service"; import { RankedService } from "@/data/ranked"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; -import { getMetadataTranslations } from "@/lib/metadata-i18n"; +import { + getMetadataTranslations, + getStaticTranslations, +} from "@/lib/metadata-i18n"; import { Effect } from "effect"; import { Crosshair, Plus } from "lucide-react"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; +import { Suspense } from "react"; export function generateMetadata(): Metadata { const t = getMetadataTranslations("ranked.metadata"); return { title: t("title"), description: t("description") }; } -export default async function RankedPage() { +// Static shell: the page frame and header prerender (default-locale title via +// the cookie-free translator), and the auth-derived content streams into ONE +// boundary whose fallback mirrors the dashboard's own pending layout. +export default function RankedPage() { + const t = getStaticTranslations("ranked"); + + return ( +
+
+

+ Personal analytics +

+

+ {t("title")} +

+
+ }> + + +
+ ); +} + +async function RankedContent() { const [session, t] = await Promise.all([auth(), getTranslations("ranked")]); const email = session?.user?.email; if (!email) redirect("/sign-in"); @@ -46,46 +76,109 @@ export default async function RankedPage() { getOverwatchPatches(), ]); + if (matches.length === 0) { + return ( +
+ + + + + + {t("emptyTitle")} + {t("emptyDescription")} + + + + + {t("trackFirst")} + + } + /> + + +
+ +
+
+ ); + } + return ( -
-
-

- Personal analytics -

-

- {t("title")} -

-
- {matches.length === 0 ? ( -
- - - - - - {t("emptyTitle")} - {t("emptyDescription")} - - - - - {t("trackFirst")} - - } - /> - - -
- +
+ +
+ ); +} + +const tabTriggerClass = + "text-muted-foreground hover:text-foreground data-[state=active]:text-foreground border-0 border-b-2 border-b-transparent data-[state=active]:border-b-primary rounded-none bg-transparent px-0 pb-3 pt-1 font-mono text-[11px] tracking-[0.16em] uppercase shadow-none data-[state=active]:shadow-none data-[state=active]:bg-transparent dark:bg-transparent dark:data-[state=active]:bg-transparent dark:data-[state=active]:border-b-primary transition-colors"; + +const TAB_KEYS = [ + "overview", + "heroes", + "maps", + "time", + "patches", + "groups", + "roles", +]; + +// Mirrors DashboardContent's pending layout (controls row, 4-cell stat +// ribbon, tab bar, overview sections) so the streamed content replaces this +// in place with no jump. +function RankedDashboardSkeleton() { + const t = getStaticTranslations("ranked.tabs"); + + return ( +
+
+ + + +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
-
- ) : ( -
- -
- )} + ))} +
+ + + + {TAB_KEYS.map((key) => ( + + {t(key)} + + ))} + + + + +
+ + +
+
+
); } + +function SkeletonSection({ bodyHeight }: { bodyHeight: number }) { + return ( +
+
+ + + +
+ +
+ ); +} diff --git a/apps/web/src/app/reports/[id]/loading.tsx b/apps/web/src/app/reports/[id]/loading.tsx deleted file mode 100644 index 995e288ca..000000000 --- a/apps/web/src/app/reports/[id]/loading.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
- - -
-
- {["a", "b", "c"].map((k) => ( -
- - - -
- ))} - - {["d", "e"].map((k) => ( -
- - - -
- ))} -
-
- ); -} diff --git a/apps/web/src/app/reports/[id]/page.tsx b/apps/web/src/app/reports/[id]/page.tsx index 2a0e5397e..a023c864f 100644 --- a/apps/web/src/app/reports/[id]/page.tsx +++ b/apps/web/src/app/reports/[id]/page.tsx @@ -1,4 +1,5 @@ import { MessageResponse } from "@/components/ai-elements/message"; +import { Skeleton } from "@/components/ui/skeleton"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; @@ -6,6 +7,7 @@ import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import type { Metadata } from "next"; import { notFound, redirect } from "next/navigation"; +import { Suspense } from "react"; export async function generateMetadata({ params, @@ -38,11 +40,24 @@ export async function generateMetadata({ }; } -export default async function ReportPage({ +// Static shell: the page frame prerenders and the report (title included — +// it comes from the database) streams into ONE boundary whose fallback +// mirrors the loaded header + prose layout. +export default function ReportPage({ params, }: { params: Promise<{ id: string }>; }) { + return ( +
+ }> + + +
+ ); +} + +async function ReportContent({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const session = await auth(); @@ -64,7 +79,7 @@ export default async function ReportPage({ if (report.userId !== userData.id) notFound(); return ( -
+ <>

{report.title}

@@ -79,6 +94,35 @@ export default async function ReportPage({

{report.content}
-
+ + ); +} + +// Mirrors the loaded state: title + byline header, then prose paragraphs. +function ReportSkeleton() { + return ( + <> +
+ + +
+
+ {["a", "b", "c"].map((k) => ( +
+ + + +
+ ))} + + {["d", "e"].map((k) => ( +
+ + + +
+ ))} +
+ ); } diff --git a/apps/web/src/app/reports/loading.tsx b/apps/web/src/app/reports/loading.tsx deleted file mode 100644 index 3930b64a8..000000000 --- a/apps/web/src/app/reports/loading.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
- - -
- -
- - -
- -
-
- {["a", "b", "c", "d", "e", "f", "g", "h"].map((k) => ( -
-
- - -
- -
- ))} -
-
- -
- -
- - -
-
-
- ); -} diff --git a/apps/web/src/app/reports/page.tsx b/apps/web/src/app/reports/page.tsx index f86a6bd33..e3755f2b7 100644 --- a/apps/web/src/app/reports/page.tsx +++ b/apps/web/src/app/reports/page.tsx @@ -1,13 +1,29 @@ import { DirectionalTransition } from "@/components/directional-transition"; +import { Skeleton } from "@/components/ui/skeleton"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; +import { getStaticTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import { redirect } from "next/navigation"; +import { Suspense } from "react"; import { ReportsList } from "./reports-list"; -export default async function ReportsPage() { +// Static shell: the auth-derived report list streams into ONE boundary whose +// fallback mirrors ReportsList's own layout, so navigation shows a single, +// stable loading state that the content replaces in place. +export default function ReportsPage() { + return ( + + }> + + + + ); +} + +async function ReportsContent() { const session = await auth(); if (!session?.user?.email) redirect("/sign-in"); @@ -22,9 +38,39 @@ export default async function ReportsPage() { include: { user: { select: { name: true } } }, }); + return ; +} + +// Mirrors ReportsList's frame (header, search row, report rows) so the +// streamed content replaces this in place with no jump. +function ReportsListSkeleton() { + const t = getStaticTranslations("reportsPage.list"); + return ( - - - +
+
+

{t("title")}

+

{t("description")}

+
+ +
+ + +
+ +
+
+ {["a", "b", "c", "d", "e", "f", "g", "h"].map((k) => ( +
+
+ + +
+ +
+ ))} +
+
+
); } diff --git a/apps/web/src/app/scouting/loading.tsx b/apps/web/src/app/scouting/loading.tsx deleted file mode 100644 index c7ab2c7cc..000000000 --- a/apps/web/src/app/scouting/loading.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
-
- - - -
- -
-
- ); -} diff --git a/apps/web/src/app/scouting/page.tsx b/apps/web/src/app/scouting/page.tsx index 4f08f27b2..9d4cc42a2 100644 --- a/apps/web/src/app/scouting/page.tsx +++ b/apps/web/src/app/scouting/page.tsx @@ -1,4 +1,5 @@ import { TeamSearch } from "@/components/scouting/team-search"; +import { Skeleton } from "@/components/ui/skeleton"; import { AppRuntime } from "@/data/runtime"; import { ScoutingService } from "@/data/scouting"; import { Effect } from "effect"; @@ -8,6 +9,7 @@ import { getMetadataTranslations } from "@/lib/metadata-i18n"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; +import { Suspense } from "react"; export function generateMetadata(): Metadata { const t = getMetadataTranslations("scoutingPage.metadata"); @@ -18,7 +20,24 @@ export function generateMetadata(): Metadata { }; } -export default async function ScoutingPage() { +// Static shell: the page frame prerenders and the flag-gated content streams +// into ONE boundary whose fallback mirrors the heading + search layout, so +// navigation shows a single stable skeleton the real content replaces in +// place. The heading stays in the content child because the whole page is +// behind the scouting flag (notFound) and the title is locale-dependent. +export default function ScoutingPage() { + return ( +
+
+ }> + + +
+
+ ); +} + +async function ScoutingContent() { const scoutingEnabled = await getFlag(scoutingTool); if (!scoutingEnabled) notFound(); @@ -28,19 +47,32 @@ export default async function ScoutingPage() { ); return ( -
-
-
-

- {t("searchEyebrow")} -

-

{t("title")}

-

- {t("subtitle")} -

-
- + <> +
+

+ {t("searchEyebrow")} +

+

{t("title")}

+

+ {t("subtitle")} +

-
+ + + ); +} + +// Mirrors the loaded layout: eyebrow/title/subtitle heading block (space-y-2, +// centered) followed by the h-14 search input. +function ScoutingSkeleton() { + return ( + <> +
+ + + +
+ + ); } diff --git a/apps/web/src/app/scouting/player/[slug]/loading.tsx b/apps/web/src/app/scouting/player/[slug]/loading.tsx deleted file mode 100644 index 0bc5d3a47..000000000 --- a/apps/web/src/app/scouting/player/[slug]/loading.tsx +++ /dev/null @@ -1,135 +0,0 @@ -/* oxlint-disable react/no-array-index-key */ -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; - -export default function ScoutingPlayerLoading() { - return ( -
-
- - -
-
- - -
-
- - - -
-
- -
- {Array.from({ length: 4 }).map((_, i) => ( - - - - - - - - - - ))} -
-
- - {/* Hero Pool skeleton */} - - - - - -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
-
- {Array.from({ length: 5 }).map((_, i) => ( -
- - - - -
- ))} -
-
-
- - {/* Scrim Overview skeleton */} - - - - - - -
- {Array.from({ length: 8 }).map((_, i) => ( -
- - -
- ))} -
-
-
- - {/* Performance Radar skeleton */} - - - - - - - - - - - {/* Hero Z-Scores skeleton */} - - - - - - - {Array.from({ length: 3 }).map((_, i) => ( -
- - - - -
- ))} -
-
- - {/* Map Winrates skeleton */} - - - - - - - - - - {/* Tournament History skeleton */} - - - - - - {Array.from({ length: 3 }).map((_, i) => ( - - ))} - - -
- ); -} diff --git a/apps/web/src/app/scouting/player/[slug]/page.tsx b/apps/web/src/app/scouting/player/[slug]/page.tsx index f9835d72c..0ece43d88 100644 --- a/apps/web/src/app/scouting/player/[slug]/page.tsx +++ b/apps/web/src/app/scouting/player/[slug]/page.tsx @@ -3,16 +3,19 @@ import { ScoutingPlayerMapWinrates } from "@/components/scouting/scouting-player import { ScoutingPlayerRead } from "@/components/scouting/scouting-player-read"; import { ScoutingPlayerScrimProfile } from "@/components/scouting/scouting-player-scrim-profile"; import { ScoutingPlayerTournaments } from "@/components/scouting/scouting-player-tournaments"; +import { Skeleton } from "@/components/ui/skeleton"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { ScoutingService, ScoutingAnalyticsService } from "@/data/player"; import { scoutingTool } from "@/lib/flags"; import { getFlag } from "@/lib/flags-helpers"; +import { getStaticTranslations } from "@/lib/metadata-i18n"; import { ArrowLeft } from "lucide-react"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; import { notFound } from "next/navigation"; +import { Suspense } from "react"; export async function generateMetadata(props: { params: Promise<{ slug: string }>; @@ -35,14 +38,32 @@ export async function generateMetadata(props: { }; } -export default async function ScoutingPlayerPage( +// Static shell: the page frame prerenders and the profile streams into ONE +// boundary whose fallback mirrors the loaded sections' flat layout. +export default function ScoutingPlayerPage( props: PageProps<"/scouting/player/[slug]"> ) { + return ( +
+
+ }> + + +
+
+ ); +} + +async function ScoutingPlayerContent({ + params, +}: { + params: Promise<{ slug: string }>; +}) { const scoutingEnabled = await getFlag(scoutingTool); if (!scoutingEnabled) notFound(); - const params = await props.params; - const slug = decodeURIComponent(params.slug); + const { slug: rawSlug } = await params; + const slug = decodeURIComponent(rawSlug); const t = await getTranslations("scoutingPage.player.profile"); const profile = await AppRuntime.runPromise( @@ -59,38 +80,152 @@ export default async function ScoutingPlayerPage( ); return ( -
-
-
- -
+ <> +
+ +
- + - {analytics.scrimData ? ( - - ) : null} + {analytics.scrimData ? ( + + ) : null} - + - -
+ + + ); +} + +function SectionHeaderSkeleton() { + return ( +
+ +
); } + +// Mirrors the loaded sections' pending layout: back link + SectionHeader + +// StatRibbon header, the scouting read (insight lists + hero pool), map +// winrates, and tournament history — same paddings and flat design language. +function ScoutingPlayerSkeleton() { + const t = getStaticTranslations("scoutingPage.player.profile"); + + return ( + <> +
+ +
+ +
+ +
+ {Array.from({ length: 2 }, (_, col) => ( +
+ +
+ {Array.from({ length: 3 }, (_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+ ))} +
+
+
+ +
+ {Array.from({ length: 4 }, (_, i) => ( + + ))} +
+
+
+ +
+ {Array.from({ length: 4 }, (_, i) => ( +
+
+ + +
+ +
+ ))} +
+
+
+
+ +
+ + {Array.from({ length: 2 }, (_, i) => ( +
+ + +
+ ))} +
+ +
+ +
+ {Array.from({ length: 3 }, (_, i) => ( + + ))} +
+
+ + ); +} diff --git a/apps/web/src/app/scouting/player/loading.tsx b/apps/web/src/app/scouting/player/loading.tsx deleted file mode 100644 index fe1b7bb11..000000000 --- a/apps/web/src/app/scouting/player/loading.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
-
- - - -
-
- - -
-
-
- ); -} diff --git a/apps/web/src/app/scouting/player/page.tsx b/apps/web/src/app/scouting/player/page.tsx index b0fa756e5..df69e8bd4 100644 --- a/apps/web/src/app/scouting/player/page.tsx +++ b/apps/web/src/app/scouting/player/page.tsx @@ -1,13 +1,17 @@ import { PlayerSearch } from "@/components/scouting/player-search"; +import { Skeleton } from "@/components/ui/skeleton"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { ScoutingService } from "@/data/player"; import { scoutingTool } from "@/lib/flags"; import { getFlag } from "@/lib/flags-helpers"; -import { getMetadataTranslations } from "@/lib/metadata-i18n"; +import { + getMetadataTranslations, + getStaticTranslations, +} from "@/lib/metadata-i18n"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; +import { Suspense } from "react"; export function generateMetadata(): Metadata { const t = getMetadataTranslations("scoutingPage.player.metadata"); @@ -18,14 +22,8 @@ export function generateMetadata(): Metadata { }; } -export default async function ScoutPlayerPage() { - const scoutingEnabled = await getFlag(scoutingTool); - if (!scoutingEnabled) notFound(); - - const t = await getTranslations("scoutingPage.player"); - const players = await AppRuntime.runPromise( - ScoutingService.pipe(Effect.flatMap((svc) => svc.getScoutingPlayers())) - ); +export default function ScoutPlayerPage() { + const t = getStaticTranslations("scoutingPage.player"); return (
@@ -39,7 +37,33 @@ export default async function ScoutPlayerPage() { {t("subtitle")}

- + }> + + +
+
+ ); +} + +async function ScoutPlayerContent() { + const scoutingEnabled = await getFlag(scoutingTool); + if (!scoutingEnabled) notFound(); + + const players = await AppRuntime.runPromise( + ScoutingService.pipe(Effect.flatMap((svc) => svc.getScoutingPlayers())) + ); + + return ; +} + +// Mirrors PlayerSearch's resting layout (search input + centered helper line) +// so the streamed content replaces this in place with no jump. +function PlayerSearchSkeleton() { + return ( +
+ +
+
); diff --git a/apps/web/src/app/scouting/team/[teamAbbr]/loading.tsx b/apps/web/src/app/scouting/team/[teamAbbr]/loading.tsx deleted file mode 100644 index e0ba59083..000000000 --- a/apps/web/src/app/scouting/team/[teamAbbr]/loading.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
-
- - -
- -
-
- -
- - -
-
-
- {["a", "b", "c", "d"].map((k) => ( -
- - - -
- ))} -
-
- -
-
- - - -
-
- {["a", "b", "c", "d"].map((k) => ( -
- -
- - - - -
-
- ))} -
-
- -
-
- - -
-
- -
- {["a", "b", "c", "d", "e", "f", "g", "h"].map((k) => ( - - ))} -
-
-
- -
- - {["a", "b", "c", "d", "e", "f"].map((k) => ( -
- - - - -
- ))} -
-
-
- -
-
- - -
-
- -
- - {["a", "b", "c", "d", "e", "f"].map((k) => ( -
- - - - -
- ))} -
-
-
- -
- - {["a", "b", "c", "d", "e", "f"].map((k) => ( -
- - - - -
- ))} -
-
-
- -
- - {["a", "b", "c", "d"].map((k) => ( -
- - - - -
- ))} -
-
-
- -
-
- - -
-
-
- - -
-
- - {["a", "b", "c", "d", "e", "f"].map((k) => ( -
- - - - - -
- ))} -
-
-
- {["a", "b"].map((k) => ( -
-
- - -
-
- {["a", "b", "c", "d", "e", "f"].map((j) => ( -
- - - -
- ))} -
-
- ))} -
-
- -
-
- - -
-
- -
- - - -
- - -
-
-
- - -
-
- {["a", "b"].map((k) => ( -
-
-
- - - -
- -
- - -
- ))} -
-
- {["a", "b", "c"].map((k) => ( -
-
- - -
-
- {["a", "b"].map((j) => ( -
- -
- {["a", "b", "c"].map((m) => ( -
- - - -
- ))} -
-
- ))} -
-
- ))} -
-
-
- ); -} diff --git a/apps/web/src/app/scouting/team/[teamAbbr]/page.tsx b/apps/web/src/app/scouting/team/[teamAbbr]/page.tsx index b20bf6966..12ca6f309 100644 --- a/apps/web/src/app/scouting/team/[teamAbbr]/page.tsx +++ b/apps/web/src/app/scouting/team/[teamAbbr]/page.tsx @@ -6,6 +6,7 @@ import { ScoutingPlayerMatchups } from "@/components/scouting/scouting-player-ma import { ScoutingReport } from "@/components/scouting/scouting-report"; import { ScoutingTeamHeader } from "@/components/scouting/scouting-team-header"; import { ScoutingTeamOverview } from "@/components/scouting/scouting-team-overview"; +import { Skeleton } from "@/components/ui/skeleton"; import { HeroBanIntelligenceService, MapIntelligenceService, @@ -24,12 +25,14 @@ import { resolveDataAvailability } from "@/lib/data-availability"; import { faceitScouting, scoutingTool } from "@/lib/flags"; import { getFlag } from "@/lib/flags-helpers"; import { generateInsights } from "@/lib/insights"; +import { getStaticTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import { ArrowLeft } from "lucide-react"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; import { notFound } from "next/navigation"; +import { Suspense } from "react"; type UserTeamOption = { id: number; name: string }; @@ -99,17 +102,41 @@ function resolveScoutForTeamId( return parsed; } -export default async function ScoutingTeamPage( +// Static shell: the page frame prerenders and the request-derived report +// streams into ONE boundary whose fallback mirrors the loaded sections, so +// navigation shows a single stable skeleton instead of a cascade. +export default function ScoutingTeamPage( props: PageProps<"/scouting/team/[teamAbbr]"> & { searchParams: Promise<{ scoutFor?: string }>; } ) { + return ( +
+
+ }> + + +
+
+ ); +} + +async function ScoutingTeamContent({ + params: paramsPromise, + searchParams: searchParamsPromise, +}: { + params: PageProps<"/scouting/team/[teamAbbr]">["params"]; + searchParams: Promise<{ scoutFor?: string }>; +}) { const scoutingEnabled = await getFlag(scoutingTool); if (!scoutingEnabled) notFound(); const [params, searchParams] = await Promise.all([ - props.params, - props.searchParams, + paramsPromise, + searchParamsPromise, ]); const teamAbbr = decodeURIComponent(params.teamAbbr); const t = await getTranslations("scoutingPage.team"); @@ -206,60 +233,317 @@ export default async function ScoutingTeamPage( }); return ( -
-
-
- -
+ <> +
+ +
+ + + + + + {faceitLink ? : null} + + + + + + + + + + ); +} + +// Mirrors the loaded sections (back-link row, header stat ribbon, report, +// overview, map performance, hero bans, matchups) so the streamed content +// replaces this in place. The back link is static, so it renders for real. +function ScoutingTeamSkeleton() { + const t = getStaticTranslations("scoutingPage.team"); - - - - - {faceitLink ? : null} - - - - - - - - + return ( + <> +
+ +
-
+ +
+
+ +
+ + +
+
+
+ {["a", "b", "c", "d"].map((k) => ( +
+ + + +
+ ))} +
+
+ +
+
+ + + +
+
+ {["a", "b", "c", "d"].map((k) => ( +
+ +
+ + + + +
+
+ ))} +
+
+ +
+
+ + +
+
+ +
+ {["a", "b", "c", "d", "e", "f", "g", "h"].map((k) => ( + + ))} +
+
+
+ +
+ + {["a", "b", "c", "d", "e", "f"].map((k) => ( +
+ + + + +
+ ))} +
+
+
+ +
+
+ + +
+
+ +
+ + {["a", "b", "c", "d", "e", "f"].map((k) => ( +
+ + + + +
+ ))} +
+
+
+ +
+ + {["a", "b", "c", "d", "e", "f"].map((k) => ( +
+ + + + +
+ ))} +
+
+
+ +
+ + {["a", "b", "c", "d"].map((k) => ( +
+ + + + +
+ ))} +
+
+
+ +
+
+ + +
+
+
+ + +
+
+ + {["a", "b", "c", "d", "e", "f"].map((k) => ( +
+ + + + + +
+ ))} +
+
+
+ {["a", "b"].map((k) => ( +
+
+ + +
+
+ {["a", "b", "c", "d", "e", "f"].map((j) => ( +
+ + + +
+ ))} +
+
+ ))} +
+
+ +
+
+ + +
+
+ +
+ + + +
+ + +
+
+
+ + +
+
+ {["a", "b"].map((k) => ( +
+
+
+ + + +
+ +
+ + +
+ ))} +
+
+ {["a", "b", "c"].map((k) => ( +
+
+ + +
+
+ {["a", "b"].map((j) => ( +
+ +
+ {["a", "b", "c"].map((m) => ( +
+ + + +
+ ))} +
+
+ ))} +
+
+ ))} +
+ ); } diff --git a/apps/web/src/app/settings/accounts/loading.tsx b/apps/web/src/app/settings/accounts/loading.tsx deleted file mode 100644 index ec5569cad..000000000 --- a/apps/web/src/app/settings/accounts/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SettingsAccountsSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/settings/admin/analytics/loading.tsx b/apps/web/src/app/settings/admin/analytics/loading.tsx deleted file mode 100644 index 9412c3bec..000000000 --- a/apps/web/src/app/settings/admin/analytics/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SettingsAdminAnalyticsSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/settings/admin/audit-logs/loading.tsx b/apps/web/src/app/settings/admin/audit-logs/loading.tsx deleted file mode 100644 index 45324a471..000000000 --- a/apps/web/src/app/settings/admin/audit-logs/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SettingsAdminAuditLogsSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/settings/admin/impersonate-user/loading.tsx b/apps/web/src/app/settings/admin/impersonate-user/loading.tsx deleted file mode 100644 index 6dc7595c9..000000000 --- a/apps/web/src/app/settings/admin/impersonate-user/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SettingsAdminImpersonateUserSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/settings/admin/loading.tsx b/apps/web/src/app/settings/admin/loading.tsx deleted file mode 100644 index a93a24ffb..000000000 --- a/apps/web/src/app/settings/admin/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SettingsAdminSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/settings/billing/loading.tsx b/apps/web/src/app/settings/billing/loading.tsx deleted file mode 100644 index ca33ed943..000000000 --- a/apps/web/src/app/settings/billing/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SettingsBillingSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/settings/loading.tsx b/apps/web/src/app/settings/loading.tsx deleted file mode 100644 index e46f364b6..000000000 --- a/apps/web/src/app/settings/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SettingsProfileSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/stats/[playerName]/loading.tsx b/apps/web/src/app/stats/[playerName]/loading.tsx deleted file mode 100644 index 3be818125..000000000 --- a/apps/web/src/app/stats/[playerName]/loading.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; -import { Skeleton } from "@/components/ui/skeleton"; -import { getStaticTranslations } from "@/lib/metadata-i18n"; - -export default function StatsLoading() { - const t = getStaticTranslations("statsPage.playerStats"); - - return ( -
-
- -
- -
-
- -
-
- -
- -
- - - {t("mostPlayed.title")} - - - - - - - - {t("bestPerformance.title")} - - - - - - - - {t("avgHeroDmgDealtPer10.title")} - - - - - - - - {t("avgDeathPer10.title")} - - - - - - - - {t("timeSpent.title")} - - - - - - - - {t("finalBlowsByMethod.title")} - - - - - - - - {t("mapWinrates.title")} - - - - - - - - {t("winrateMapType.title")} - - - - - - - - {t("heroesDiedToMost.title")} - - - - - - - - {t("heroesElimMost.title")} - - - - - - - - -
- - -
-
-
- - - -
-
-
-
- ); -} diff --git a/apps/web/src/app/stats/[playerName]/page.tsx b/apps/web/src/app/stats/[playerName]/page.tsx index d655a5420..e01b62026 100644 --- a/apps/web/src/app/stats/[playerName]/page.tsx +++ b/apps/web/src/app/stats/[playerName]/page.tsx @@ -1,9 +1,12 @@ +import { SectionHeader } from "@/components/section-header"; +import { StatPanel } from "@/components/player/stat-panel"; import { RangePicker, type Timeframe, } from "@/components/stats/player/range-picker"; import { Card } from "@/components/ui/card"; import { Link } from "@/components/ui/link"; +import { Skeleton } from "@/components/ui/skeleton"; import { ScrimService } from "@/data/scrim"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; @@ -12,12 +15,16 @@ import { auth, getViewableScrimIds } from "@/lib/auth"; import { Permission } from "@/lib/permissions"; import prisma from "@/lib/prisma"; import { defaultLocale } from "@/i18n/config"; -import { getMetadataTranslations } from "@/lib/metadata-i18n"; +import { + getMetadataTranslations, + getStaticTranslations, +} from "@/lib/metadata-i18n"; import type { PagePropsWithLocale } from "@/types/next"; import type { Kill, PlayerStat, Scrim } from "@/generated/prisma/client"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; +import { Suspense } from "react"; export async function generateMetadata( props: PagePropsWithLocale<"/stats/[playerName]"> @@ -51,10 +58,28 @@ export async function generateMetadata( }; } -export default async function PlayerStats( +// Static shell: the page frame prerenders and the request-derived content +// (params, auth, permissions, DB reads) streams into ONE boundary whose +// fallback mirrors the profile's own pending layout. The player-name heading +// is request-time (it comes from params), so it lives in the content child. +export default function PlayerStats( props: PagePropsWithLocale<"/stats/[playerName]"> ) { - const params = await props.params; + return ( +
+ }> + + +
+ ); +} + +async function PlayerStatsContent({ + params: paramsPromise, +}: { + params: Promise<{ playerName: string }>; +}) { + const params = await paramsPromise; const t = await getTranslations("statsPage.playerStats"); const name = decodeURIComponent(params.playerName); @@ -183,7 +208,7 @@ export default async function PlayerStats( allPlayerDeaths = result.allPlayerDeaths; } catch { return ( -
+ <>

{name}

@@ -204,12 +229,12 @@ export default async function PlayerStats(

-
+ ); } return ( -
+ <>

{name}

@@ -223,6 +248,121 @@ export default async function PlayerStats( mapWinrates={mapWinrates} deaths={allPlayerDeaths} /> + + ); +} + +// Mirrors the loaded layout: heading block, RangePicker toolbar row, then the +// PlayerProfile sections (SectionHeader + StatPanel shapes) so the streamed +// content replaces this in place with no jump. +function PlayerStatsSkeleton() { + const t = getStaticTranslations("statsPage.playerStats"); + + return ( + <> +
+ +
+ +
+
+ + +
+ +
+
+ + +
+ {Array.from({ length: 4 }, (_, i) => ( +
+ + +
+ ))} +
+
+
+ +
+ + +
+ +
+
+
+ +
+ + +
+ + +
+
+
+ + + +
+
+ +
+ + + + +
+ + + +
+
+ +
+ + +
+ + +
+
+
+ +
+ + +
+ + + +
+
+
+
+
+ + ); +} + +function ChartCellSkeleton({ title }: { title: string }) { + return ( +
+

+ {title} +

+
+ +
); } diff --git a/apps/web/src/app/stats/hero/[heroName]/loading.tsx b/apps/web/src/app/stats/hero/[heroName]/loading.tsx deleted file mode 100644 index e06cf7f84..000000000 --- a/apps/web/src/app/stats/hero/[heroName]/loading.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; -import { Skeleton } from "@/components/ui/skeleton"; -import { getStaticTranslations } from "@/lib/metadata-i18n"; - -export default function StatsLoading() { - const t = getStaticTranslations("statsPage.heroStats"); - - return ( -
-
- -
- -
-
- -
- -
- - - {t("title")} - - - - - - - - {t("bestPerformance.title")} - - - - - - - - {t("finalBlowsByMethod")} - - - - - - - - - {t("avgHeroDmgDealtPer10.title")} - - - - - - - - - - {t("avgDeathPer10.title")} - - - - - - - - - {t("heroesDiedToMost.title")} - - - - - - - - {t("heroesElimMost.title")} - - - - - - - - -
- - -
-
-
- - - -
-
-
-
- ); -} diff --git a/apps/web/src/app/stats/hero/[heroName]/page.tsx b/apps/web/src/app/stats/hero/[heroName]/page.tsx index 34112c8d1..76e4bdb38 100644 --- a/apps/web/src/app/stats/hero/[heroName]/page.tsx +++ b/apps/web/src/app/stats/hero/[heroName]/page.tsx @@ -1,16 +1,22 @@ +import { StatPanel } from "@/components/player/stat-panel"; +import { SectionHeader } from "@/components/section-header"; import { RangePicker, type Timeframe, } from "@/components/stats/hero/range-picker"; import { Card } from "@/components/ui/card"; import { Link } from "@/components/ui/link"; +import { Skeleton } from "@/components/ui/skeleton"; import { HeroService } from "@/data/hero"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth, getViewableScrimIds } from "@/lib/auth"; import { defaultLocale } from "@/i18n/config"; -import { getMetadataTranslations } from "@/lib/metadata-i18n"; +import { + getMetadataTranslations, + getStaticTranslations, +} from "@/lib/metadata-i18n"; import { Permission } from "@/lib/permissions"; import prisma from "@/lib/prisma"; import { toHero, translateHeroName } from "@/lib/utils"; @@ -20,6 +26,7 @@ import type { Kill, PlayerStat, Scrim } from "@/generated/prisma/client"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; +import { Suspense } from "react"; export async function generateMetadata( props: PagePropsWithLocale<"/stats/hero/[heroName]"> @@ -50,7 +57,22 @@ export async function generateMetadata( }; } -export default async function HeroStats( +// Static shell: the page frame prerenders, and all request-time work (params, +// auth, DB reads) streams into ONE boundary whose fallback mirrors the loaded +// hero profile's pending layout. +export default function HeroStats( + props: PagePropsWithLocale<"/stats/hero/[heroName]"> +) { + return ( +
+ }> + + +
+ ); +} + +async function HeroStatsContent( props: PagePropsWithLocale<"/stats/hero/[heroName]"> ) { const params = await props.params; @@ -176,7 +198,7 @@ export default async function HeroStats( ); } catch { return ( -
+ <>

{translatedHeroName} @@ -196,12 +218,12 @@ export default async function HeroStats(

-
+ ); } return ( -
+ <>

{translatedHeroName} @@ -216,6 +238,116 @@ export default async function HeroStats( deaths={allHeroDeaths} hero={hero as HeroName} /> + + ); +} + +// Mirrors the loaded state: heading, RangePicker's timeframe select, and the +// HeroProfile section stack (SectionHeader + StatPanel, 250px chart cells) so +// the streamed content replaces this in place. +function HeroStatsSkeleton() { + const t = getStaticTranslations("statsPage.heroStats"); + + return ( + <> +
+ +
+ +
+
+ +
+ +
+
+
+

+ {t("sections.overview")} +

+ +
+ +
+
+ +
+ + +
+
+
+ {Array.from({ length: 4 }, (_, i) => ( +
+ + +
+ ))} +
+
+
+
+ +
+ + +
+ + +
+
+
+ +
+ + +
+ + +
+
+
+ + + +
+
+ +
+ + +
+ + +
+
+
+ +
+ + +
+
+
+
+
+
+ + ); +} + +function ChartCellSkeleton() { + return ( +
+ +
); } diff --git a/apps/web/src/app/stats/hero/loading.tsx b/apps/web/src/app/stats/hero/loading.tsx deleted file mode 100644 index 1dd8eef8c..000000000 --- a/apps/web/src/app/stats/hero/loading.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -const tankKeys = [ - "a", - "b", - "c", - "d", - "e", - "f", - "g", - "h", - "i", - "j", - "k", - "l", - "m", - "n", -]; -const damageKeys = [ - "a", - "b", - "c", - "d", - "e", - "f", - "g", - "h", - "i", - "j", - "k", - "l", - "m", - "n", - "o", - "p", - "q", - "r", - "s", - "t", - "u", - "v", - "w", - "x", -]; -const supportKeys = [ - "a", - "b", - "c", - "d", - "e", - "f", - "g", - "h", - "i", - "j", - "k", - "l", - "m", - "n", -]; - -export default function Loading() { - return ( -
-
- - -
- -
-
- -
-
- {tankKeys.map((k) => ( -
- - -
- ))} -
-
-
- -
- -
-
- {damageKeys.map((k) => ( -
- - -
- ))} -
-
-
- -
- -
-
- {supportKeys.map((k) => ( -
- - -
- ))} -
-
-
-
-
- ); -} diff --git a/apps/web/src/app/stats/hero/page.tsx b/apps/web/src/app/stats/hero/page.tsx index 1cdcf5581..a0e5c00b4 100644 --- a/apps/web/src/app/stats/hero/page.tsx +++ b/apps/web/src/app/stats/hero/page.tsx @@ -1,12 +1,17 @@ import { StatPanel } from "@/components/player/stat-panel"; import { Link } from "@/components/ui/link"; +import { Skeleton } from "@/components/ui/skeleton"; import { defaultLocale } from "@/i18n/config"; -import { getMetadataTranslations } from "@/lib/metadata-i18n"; +import { + getMetadataTranslations, + getStaticTranslations, +} from "@/lib/metadata-i18n"; import { getHeroNames, toHero } from "@/lib/utils"; import { type HeroName, roleHeroMapping } from "@/types/heroes"; import type { Metadata, Route } from "next"; import { getTranslations } from "next-intl/server"; import Image from "next/image"; +import { Suspense } from "react"; export function generateMetadata(): Metadata { const t = getMetadataTranslations("statsPage.heroStatsMetadata"); @@ -31,7 +36,31 @@ export function generateMetadata(): Metadata { }; } -export default async function HeroSelect() { +// Static shell: the frame and heading prerender (default-locale text via the +// cookie-free translator); the locale-aware roster streams into ONE boundary +// whose fallback mirrors the three role panels. +export default function HeroSelect() { + const t = getStaticTranslations("statsPage.heroStats"); + + return ( +
+
+

+ {t("pickerTitle")} +

+

+ {t("description")} +

+
+ + }> + + +
+ ); +} + +async function HeroRosterContent() { const t = await getTranslations("statsPage.heroStats"); const heroNames = await getHeroNames(); @@ -46,57 +75,79 @@ export default async function HeroSelect() { ]; return ( -
-
-

- {t("pickerTitle")} -

-

- {t("description")} -

-
- -
- {groups.map((group) => ( -
+ {groups.map((group) => ( +
+

-

- {group.title} -

- -
- {group.heroes.map((hero) => { - const heroLabel = heroNames.get(toHero(hero)) ?? hero; - return ( - - {t("altText", - - {heroLabel} - - - ); - })} -
-
-
- ))} -
+ {group.title} +

+ +
+ {group.heroes.map((hero) => { + const heroLabel = heroNames.get(toHero(hero)) ?? hero; + return ( + + {t("altText", + + {heroLabel} + + + ); + })} +
+
+ + ))} +
+ ); +} + +// Mirrors the roster's pending layout: three role panels with one skeleton +// cell per hero, so the streamed content replaces it in place with no jump. +function HeroRosterSkeleton() { + const roles = [ + { id: "tank-roster", heroes: roleHeroMapping.Tank }, + { id: "damage-roster", heroes: roleHeroMapping.Damage }, + { id: "support-roster", heroes: roleHeroMapping.Support }, + ]; + + return ( +
+ {roles.map((role) => ( +
+ + +
+ {role.heroes.map((hero) => ( +
+ + +
+ ))} +
+
+
+ ))}
); } diff --git a/apps/web/src/app/stats/loading.tsx b/apps/web/src/app/stats/loading.tsx deleted file mode 100644 index b93463dce..000000000 --- a/apps/web/src/app/stats/loading.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
- -
- -
-
- - - -
-
- -
-
- -
-
-
- {["a", "b", "c", "d"].map((k) => ( -
- - -
- ))} -
-
-
- -
-
- -
-
-
- {["a", "b", "c", "d", "e", "f", "g", "h"].map((k) => ( -
- -
-
- - - -
- {["a", "b", "c"].map((r) => ( -
- - - -
- ))} -
- -
- ))} -
-
-
-
- ); -} diff --git a/apps/web/src/app/stats/map/loading.tsx b/apps/web/src/app/stats/map/loading.tsx deleted file mode 100644 index 4b33b7366..000000000 --- a/apps/web/src/app/stats/map/loading.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
-
- - -
-
- {["a", "b", "c"].map((k) => ( -
- - -
- ))} -
-
- -
- -
- {["a", "b", "c", "d"].map((k) => ( - - ))} -
-
- - -
-
- -
- {["a", "b", "c", "d", "e", "f"].map((k) => ( - - ))} -
- -
-
- {["a", "b", "c", "d", "e", "f", "g", "h"].map((k) => ( - - ))} -
-
    - {["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"].map((k) => ( -
  • - -
    - -
    - - -
    -
    - - - - - - -
  • - ))} -
-
-
- ); -} diff --git a/apps/web/src/app/stats/map/page.tsx b/apps/web/src/app/stats/map/page.tsx index b7caf0d27..3e6e3bc26 100644 --- a/apps/web/src/app/stats/map/page.tsx +++ b/apps/web/src/app/stats/map/page.tsx @@ -45,18 +45,70 @@ async function MapHeroTrendsContent() { function MapHeroTrendsFallback() { return (
-
+
- - + +
+
+ {["a", "b", "c"].map((k) => ( +
+ + +
+ ))} +
+
+ +
+ +
+ {["a", "b", "c", "d"].map((k) => ( + + ))} +
+
+ +
-
- {["a", "b", "c", "d", "e", "f"].map((key) => ( - + +
+ {["a", "b", "c", "d", "e", "f"].map((k) => ( + ))}
+ +
+
+ {["a", "b", "c", "d", "e", "f", "g", "h"].map((k) => ( + + ))} +
+
    + {["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"].map((k) => ( +
  • + +
    + +
    + + +
    +
    + + + + + + +
  • + ))} +
+
); } diff --git a/apps/web/src/app/stats/page.tsx b/apps/web/src/app/stats/page.tsx index 4dc59abfb..1d702c646 100644 --- a/apps/web/src/app/stats/page.tsx +++ b/apps/web/src/app/stats/page.tsx @@ -7,7 +7,9 @@ import { } from "@/components/stats/leaderboard-card"; import { Searchbar } from "@/components/stats/searchbar"; import { Link } from "@/components/ui/link"; +import { Skeleton } from "@/components/ui/skeleton"; import { getApproximateCounts } from "@/lib/approximate-count"; +import { getStaticTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import { format, @@ -17,8 +19,32 @@ import { } from "@/lib/utils"; import type { Route } from "next"; import { getTranslations } from "next-intl/server"; +import { Suspense } from "react"; -export default async function StatsPage() { +// Static shell: the page frame, title (default-locale via the cookie-free +// translator), and searchbar prerender; the DB-derived sections stream into +// ONE boundary whose fallback mirrors their loaded layout. +export default function StatsPage() { + const t = getStaticTranslations("statsPage"); + + return ( +
+
+

{t("title")}

+
+ +
+ +
+ + }> + + +
+ ); +} + +async function StatsContent() { const t = await getTranslations("statsPage"); // Large tables use approximate counts (pg_class.reltuples) to avoid scanning @@ -180,15 +206,7 @@ export default async function StatsPage() { } return ( -
-
-

{t("title")}

-
- -
- -
- + <>
@@ -291,6 +309,60 @@ export default async function StatsPage() {
-
+ + ); +} + +// Mirrors the loaded sections: SectionHeader eyebrow, the four-block global +// stat panel, and the eight-card leaderboard grid. +function StatsSkeleton() { + return ( + <> +
+
+ +
+
+
+ {["a", "b", "c", "d"].map((k) => ( +
+ + +
+ ))} +
+
+
+ +
+
+ +
+
+
+ {["a", "b", "c", "d", "e", "f", "g", "h"].map((k) => ( +
+ +
+
+ + + +
+ {["a", "b", "c"].map((r) => ( +
+ + + +
+ ))} +
+ +
+ ))} +
+
+
+ ); } diff --git a/apps/web/src/app/stats/team/[teamId]/charts/loading.tsx b/apps/web/src/app/stats/team/[teamId]/charts/loading.tsx deleted file mode 100644 index 45bd45c60..000000000 --- a/apps/web/src/app/stats/team/[teamId]/charts/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ChartsSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/stats/team/[teamId]/heroes/loading.tsx b/apps/web/src/app/stats/team/[teamId]/heroes/loading.tsx deleted file mode 100644 index 429841e8d..000000000 --- a/apps/web/src/app/stats/team/[teamId]/heroes/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { HeroesSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/stats/team/[teamId]/loading.tsx b/apps/web/src/app/stats/team/[teamId]/loading.tsx deleted file mode 100644 index b192e319f..000000000 --- a/apps/web/src/app/stats/team/[teamId]/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { OverviewSkeleton } from "./loading-skeleton"; - -export default function TeamStatsLoading() { - return ; -} diff --git a/apps/web/src/app/stats/team/[teamId]/maps/loading.tsx b/apps/web/src/app/stats/team/[teamId]/maps/loading.tsx deleted file mode 100644 index a3551d678..000000000 --- a/apps/web/src/app/stats/team/[teamId]/maps/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { MapsSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/stats/team/[teamId]/performance/loading.tsx b/apps/web/src/app/stats/team/[teamId]/performance/loading.tsx deleted file mode 100644 index 0699b6a22..000000000 --- a/apps/web/src/app/stats/team/[teamId]/performance/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { PerformanceSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/stats/team/[teamId]/positional/loading.tsx b/apps/web/src/app/stats/team/[teamId]/positional/loading.tsx deleted file mode 100644 index 35a0b43ae..000000000 --- a/apps/web/src/app/stats/team/[teamId]/positional/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { PositionalSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/stats/team/[teamId]/simulator/loading.tsx b/apps/web/src/app/stats/team/[teamId]/simulator/loading.tsx deleted file mode 100644 index b15a02a90..000000000 --- a/apps/web/src/app/stats/team/[teamId]/simulator/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SimulatorSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/stats/team/[teamId]/swaps/loading.tsx b/apps/web/src/app/stats/team/[teamId]/swaps/loading.tsx deleted file mode 100644 index 4a113bbbe..000000000 --- a/apps/web/src/app/stats/team/[teamId]/swaps/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SwapsSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/stats/team/[teamId]/teamfights/loading.tsx b/apps/web/src/app/stats/team/[teamId]/teamfights/loading.tsx deleted file mode 100644 index b72c2cc8e..000000000 --- a/apps/web/src/app/stats/team/[teamId]/teamfights/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TeamfightsSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/stats/team/[teamId]/tendencies/loading.tsx b/apps/web/src/app/stats/team/[teamId]/tendencies/loading.tsx deleted file mode 100644 index f74fb7b16..000000000 --- a/apps/web/src/app/stats/team/[teamId]/tendencies/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TendenciesSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/stats/team/[teamId]/trends/loading.tsx b/apps/web/src/app/stats/team/[teamId]/trends/loading.tsx deleted file mode 100644 index 7deb8fc27..000000000 --- a/apps/web/src/app/stats/team/[teamId]/trends/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TrendsSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/stats/team/[teamId]/ultimates/loading.tsx b/apps/web/src/app/stats/team/[teamId]/ultimates/loading.tsx deleted file mode 100644 index 657184955..000000000 --- a/apps/web/src/app/stats/team/[teamId]/ultimates/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { UltimatesSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/stats/team/[teamId]/winrates/loading.tsx b/apps/web/src/app/stats/team/[teamId]/winrates/loading.tsx deleted file mode 100644 index 94dca8fc1..000000000 --- a/apps/web/src/app/stats/team/[teamId]/winrates/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { WinratesSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/stats/team/loading.tsx b/apps/web/src/app/stats/team/loading.tsx deleted file mode 100644 index bee5641e3..000000000 --- a/apps/web/src/app/stats/team/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TeamSelectorSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/stats/team/page.tsx b/apps/web/src/app/stats/team/page.tsx index 7d0bfb1bf..e4c536120 100644 --- a/apps/web/src/app/stats/team/page.tsx +++ b/apps/web/src/app/stats/team/page.tsx @@ -1,12 +1,12 @@ import { TeamSelector } from "@/components/stats/team/selector"; +import { Skeleton } from "@/components/ui/skeleton"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { Effect } from "effect"; import { redirect } from "next/navigation"; -import { Suspense } from "react"; -import { TeamSelectorSkeleton } from "./loading-skeleton"; +import { type ReactNode, Suspense } from "react"; const SURFACES: { label: string; summary: string }[] = [ { @@ -41,30 +41,10 @@ const SURFACES: { label: string; summary: string }[] = [ }, ]; +// Static shell: everything on this page is static copy except the team list, +// so only the selection section streams — its fallback renders the same +// section with skeletons in the two data slots (selector, team count). export default function TeamStatsPage() { - return ( - }> - - - ); -} - -async function TeamStatsPageContent() { - const session = await auth(); - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user.email))) - ); - if (!user) redirect("/sign-in"); - - const teams = await prisma.team.findMany({ - where: { - users: { - some: { id: user.id }, - }, - }, - orderBy: { createdAt: "asc" }, - }); - return (
@@ -81,81 +61,16 @@ async function TeamStatsPageContent() {
-
-
-

- Selection · Start here -

-

- Which team to read? -

-

- Only teams you're a member of. -

- -
- -
-
- -
-
-
-
- Your teams -
-
- {teams.length} -
-
-
-
- Surfaces -
-
6
-
-
-
- Refresh -
-
As scrims upload
-
-
- -
-

- What you're reading -

-

- Aggregations across every scrim a team has uploaded. Player - performance rolls up into role splits, hero pools, win rate - trends, and a fight-by-fight breakdown of how the team wins or - loses team fights. -

-
- -
- - -
-
-
+ } + teamCount={} + /> + } + > + +
@@ -191,6 +106,112 @@ async function TeamStatsPageContent() { ); } +async function SelectionSectionContent() { + const session = await auth(); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user.email))) + ); + if (!user) redirect("/sign-in"); + + const teams = await prisma.team.findMany({ + where: { + users: { + some: { id: user.id }, + }, + }, + orderBy: { createdAt: "asc" }, + }); + + return ( + } + teamCount={teams.length} + /> + ); +} + +function SelectionSection({ + selector, + teamCount, +}: { + selector: ReactNode; + teamCount: ReactNode; +}) { + return ( +
+
+

+ Selection · Start here +

+

+ Which team to read? +

+

+ Only teams you're a member of. +

+ +
{selector}
+
+ +
+
+
+
+ Your teams +
+
{teamCount}
+
+
+
+ Surfaces +
+
6
+
+
+
+ Refresh +
+
As scrims upload
+
+
+ +
+

+ What you're reading +

+

+ Aggregations across every scrim a team has uploaded. Player + performance rolls up into role splits, hero pools, win rate trends, + and a fight-by-fight breakdown of how the team wins or loses team + fights. +

+
+ +
+ + +
+
+
+ ); +} + function BulletGroup({ label, items, diff --git a/apps/web/src/app/team/[teamId]/(app)/availability/loading.tsx b/apps/web/src/app/team/[teamId]/(app)/availability/loading.tsx deleted file mode 100644 index ed19d43d9..000000000 --- a/apps/web/src/app/team/[teamId]/(app)/availability/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { AvailabilityIndexSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/team/[teamId]/(app)/availability/settings/loading.tsx b/apps/web/src/app/team/[teamId]/(app)/availability/settings/loading.tsx deleted file mode 100644 index 79f064079..000000000 --- a/apps/web/src/app/team/[teamId]/(app)/availability/settings/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { AvailabilitySettingsSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/team/[teamId]/(app)/loading.tsx b/apps/web/src/app/team/[teamId]/(app)/loading.tsx deleted file mode 100644 index f5c417607..000000000 --- a/apps/web/src/app/team/[teamId]/(app)/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TeamDetailSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/team/[teamId]/(app)/targets/loading.tsx b/apps/web/src/app/team/[teamId]/(app)/targets/loading.tsx deleted file mode 100644 index 7ba5e9e69..000000000 --- a/apps/web/src/app/team/[teamId]/(app)/targets/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TeamTargetsSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/team/[teamId]/availability/[scheduleId]/loading.tsx b/apps/web/src/app/team/[teamId]/availability/[scheduleId]/loading.tsx deleted file mode 100644 index f5ce23ae6..000000000 --- a/apps/web/src/app/team/[teamId]/availability/[scheduleId]/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { AvailabilityFillSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/team/join/[token]/loading.tsx b/apps/web/src/app/team/join/[token]/loading.tsx deleted file mode 100644 index be8ef124e..000000000 --- a/apps/web/src/app/team/join/[token]/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { JoinTokenSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/team/loading.tsx b/apps/web/src/app/team/loading.tsx deleted file mode 100644 index 4eea1a7e4..000000000 --- a/apps/web/src/app/team/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TeamPageSkeleton } from "./loading-skeleton"; - -export default function Loading() { - return ; -} diff --git a/apps/web/src/app/team/page.tsx b/apps/web/src/app/team/page.tsx index b7fd6b9ad..13c4dd353 100644 --- a/apps/web/src/app/team/page.tsx +++ b/apps/web/src/app/team/page.tsx @@ -2,6 +2,7 @@ import { TeamSearch } from "@/components/admin/team-search"; import { DashboardLayout } from "@/components/dashboard-layout"; import { EmptyTeamView } from "@/components/team/empty-team-view"; import { UserTeamsList } from "@/components/team/user-teams-list"; +import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; @@ -15,7 +16,6 @@ import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; import { Suspense } from "react"; -import { TeamPageSkeleton } from "./loading-skeleton"; export function generateMetadata(): Metadata { const t = getMetadataTranslations("teamPage.metadata"); @@ -40,11 +40,22 @@ export function generateMetadata(): Metadata { }; } +// Static shell: DashboardLayout's chrome and the page frame prerender, and the +// auth-derived teams content streams into ONE boundary whose fallback mirrors +// the loaded heading/tabs/grid layout — so navigation shows a single stable +// skeleton instead of a route loading.tsx plus in-page skeleton cascade. The +// heading text depends on the user's role, so it lives in the content child. export default function TeamPage() { return ( - }> - - + +
+
+ }> + + +
+
+
); } @@ -68,36 +79,61 @@ async function TeamPageContent() { userData?.role === $Enums.UserRole.ADMIN || userData?.role === $Enums.UserRole.MANAGER; - const content = ( -
-
-
-

- {hasPerms ? t("manageTeams") : t("viewTeams")} -

-
- - {hasPerms && ( - - {t("teams")} - {t("admin")} - + return ( + <> +
+

+ {hasPerms ? t("manageTeams") : t("viewTeams")} +

+
+ + {hasPerms && ( + + {t("teams")} + {t("admin")} + + )} + + + {userTeams.length > 0 ? ( + + ) : ( + )} + + + + + + + ); +} - - {userTeams.length > 0 ? ( - - ) : ( - - )} - - - - -
+// Mirrors the loaded content's pending layout (role-dependent heading row, +// tab list, UserTeamsList's search input and card grid) so the streamed +// content replaces this in place. +function TeamListSkeleton() { + return ( + <> +
+ +
+
+
+ + +
+
+ +
+ {["a", "b", "c", "d"].map((k) => ( +
+ +
+ ))} +
+
-
+ ); - - return {content}; } diff --git a/apps/web/src/app/terms-of-service/loading.tsx b/apps/web/src/app/terms-of-service/loading.tsx deleted file mode 100644 index 4e55d2e90..000000000 --- a/apps/web/src/app/terms-of-service/loading.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
- -
- - -
- - -
- - -
- - -
- - -
-
- {["a", "b", "c"].map((k) => ( - - ))} -
- - -
- - -
-
- {["a", "b", "c", "d"].map((k) => ( - - ))} -
- - -
- - -
-
- {["a", "b", "c"].map((k) => ( - - ))} -
- - -
- - -
-
- {["a", "b", "c", "d", "e", "f"].map((k) => ( - - ))} -
- - -
- - -
-
- {["a", "b", "c"].map((k) => ( - - ))} -
- - -
- - - -
- - -
- - - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
-
-
- ); -} diff --git a/apps/web/src/app/terms-of-service/page.tsx b/apps/web/src/app/terms-of-service/page.tsx index e63ab9ada..1e94ace8d 100644 --- a/apps/web/src/app/terms-of-service/page.tsx +++ b/apps/web/src/app/terms-of-service/page.tsx @@ -1,116 +1,244 @@ import { Link } from "@/components/ui/link"; +import { Skeleton } from "@/components/ui/skeleton"; import { getTranslations } from "next-intl/server"; +import { Suspense } from "react"; -export default async function TermsPage() { - const t = await getTranslations("termsPage"); - +// Static shell: the page frame prerenders and the locale-dependent legal text +// streams into ONE boundary whose fallback mirrors the document's section +// layout. The title stays in the content child because it must render in the +// request locale. +export default function TermsPage() { return (
-

- {t("termsOfService.title")} -

-

- {t("termsOfService.description")} -

- -

- {t("acceptance.title")} -

-

{t("acceptance.description")}

- -

- {t("openSource.title")} -

-

{t("openSource.description")}

-
    -
  • {t("openSource.list1")}
  • -
  • {t("openSource.list2")}
  • -
  • {t("openSource.list3")}
  • -
- -

- {t("useOfService.title")} -

-

{t("useOfService.description")}

-
    -
  • {t("useOfService.list1")}
  • -
  • {t("useOfService.list2")}
  • -
  • {t("useOfService.list3")}
  • -
  • {t("useOfService.list4")}
  • -
- -

- {t("userAccounts.title")} -

-

{t("userAccounts.description")}

-
    -
  • {t("userAccounts.list1")}
  • -
  • {t("userAccounts.list2")}
  • -
  • {t("userAccounts.list3")}
  • -
- -

- {t("prohibitedUses.title")} -

-

{t("prohibitedUses.description")}

-
    -
  • {t("prohibitedUses.list1")}
  • -
  • {t("prohibitedUses.list2")}
  • -
  • {t("prohibitedUses.list3")}
  • -
  • {t("prohibitedUses.list4")}
  • -
  • {t("prohibitedUses.list5")}
  • -
  • {t("prohibitedUses.list6")}
  • -
- -

- {t("abuseAndMisuse.title")} -

-

{t("abuseAndMisuse.description")}

-
    -
  • {t("abuseAndMisuse.list1")}
  • -
  • {t("abuseAndMisuse.list2")}
  • -
  • {t("abuseAndMisuse.list3")}
  • -
- -

- {t("disclaimers.title")} -

-

{t("disclaimers.description")}

- -

- {t("limitationOfLiability.title")} -

-

{t("limitationOfLiability.description")}

- -

- {t("termination.title")} -

-

{t("termination.description")}

- -

- {t("governingLaw.title")} -

-

{t("governingLaw.description")}

- -

- {t("changesToTerms.title")} -

-

{t("changesToTerms.description")}

- -

- {t("contactUs.title")} -

-

- {t.rich("contactUs.description", { - link: (chunks) => ( - - {chunks} - - ), - })} -

+ }> + +
); } + +async function TermsContent() { + const t = await getTranslations("termsPage"); + + return ( + <> +

+ {t("termsOfService.title")} +

+

+ {t("termsOfService.description")} +

+ +

+ {t("acceptance.title")} +

+

{t("acceptance.description")}

+ +

+ {t("openSource.title")} +

+

{t("openSource.description")}

+
    +
  • {t("openSource.list1")}
  • +
  • {t("openSource.list2")}
  • +
  • {t("openSource.list3")}
  • +
+ +

+ {t("useOfService.title")} +

+

{t("useOfService.description")}

+
    +
  • {t("useOfService.list1")}
  • +
  • {t("useOfService.list2")}
  • +
  • {t("useOfService.list3")}
  • +
  • {t("useOfService.list4")}
  • +
+ +

+ {t("userAccounts.title")} +

+

{t("userAccounts.description")}

+
    +
  • {t("userAccounts.list1")}
  • +
  • {t("userAccounts.list2")}
  • +
  • {t("userAccounts.list3")}
  • +
+ +

+ {t("prohibitedUses.title")} +

+

{t("prohibitedUses.description")}

+
    +
  • {t("prohibitedUses.list1")}
  • +
  • {t("prohibitedUses.list2")}
  • +
  • {t("prohibitedUses.list3")}
  • +
  • {t("prohibitedUses.list4")}
  • +
  • {t("prohibitedUses.list5")}
  • +
  • {t("prohibitedUses.list6")}
  • +
+ +

+ {t("abuseAndMisuse.title")} +

+

{t("abuseAndMisuse.description")}

+
    +
  • {t("abuseAndMisuse.list1")}
  • +
  • {t("abuseAndMisuse.list2")}
  • +
  • {t("abuseAndMisuse.list3")}
  • +
+ +

+ {t("disclaimers.title")} +

+

{t("disclaimers.description")}

+ +

+ {t("limitationOfLiability.title")} +

+

{t("limitationOfLiability.description")}

+ +

+ {t("termination.title")} +

+

{t("termination.description")}

+ +

+ {t("governingLaw.title")} +

+

{t("governingLaw.description")}

+ +

+ {t("changesToTerms.title")} +

+

{t("changesToTerms.description")}

+ +

+ {t("contactUs.title")} +

+

+ {t.rich("contactUs.description", { + link: (chunks) => ( + + {chunks} + + ), + })} +

+ + ); +} + +// Mirrors the document's section layout (title, intro, then one heading + +// paragraph block per section with lists of 3/4/3/6/3 items) so the streamed +// content replaces this in place with no jump. +function TermsSkeleton() { + return ( + <> + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ {["a", "b", "c"].map((k) => ( + + ))} +
+ + +
+ + +
+
+ {["a", "b", "c", "d"].map((k) => ( + + ))} +
+ + +
+ + +
+
+ {["a", "b", "c"].map((k) => ( + + ))} +
+ + +
+ + +
+
+ {["a", "b", "c", "d", "e", "f"].map((k) => ( + + ))} +
+ + +
+ + +
+
+ {["a", "b", "c"].map((k) => ( + + ))} +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + ); +} diff --git a/apps/web/src/app/tournaments/[id]/loading.tsx b/apps/web/src/app/tournaments/[id]/loading.tsx deleted file mode 100644 index a22070a49..000000000 --- a/apps/web/src/app/tournaments/[id]/loading.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
-
- -
-
- - -
- -
-
- -
- -
-
-
- -
- - {["a", "b", "c", "d", "e", "f"].map((k) => ( - - ))} -
-
- -
- -
- {["a", "b", "c"].map((k) => ( -
- -
- {["x", "y", "z"].map((m) => ( - - ))} -
-
- ))} -
-
- -
- -
- {["a", "b", "c"].map((k) => ( -
- -
- {["x", "y"].map((m) => ( - - ))} -
-
- ))} -
-
-
-
-
- ); -} diff --git a/apps/web/src/app/tournaments/[id]/match/[matchId]/loading.tsx b/apps/web/src/app/tournaments/[id]/match/[matchId]/loading.tsx deleted file mode 100644 index 25ee328cf..000000000 --- a/apps/web/src/app/tournaments/[id]/match/[matchId]/loading.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
- -
-
- - -
- -
-
- -
-
-
- - - -
-
- -
- -
- {["a", "b", "c"].map((k) => ( -
-
- - -
- - -
-
-
- ))} -
-
- -
-
- - - -
-
-
-
- ); -} diff --git a/apps/web/src/app/tournaments/[id]/match/[matchId]/page.tsx b/apps/web/src/app/tournaments/[id]/match/[matchId]/page.tsx index 079feb0de..421e5a41e 100644 --- a/apps/web/src/app/tournaments/[id]/match/[matchId]/page.tsx +++ b/apps/web/src/app/tournaments/[id]/match/[matchId]/page.tsx @@ -1,9 +1,9 @@ import { DashboardLayout } from "@/components/dashboard-layout"; -import { PageContentSkeleton } from "@/components/skeletons/page-content-skeleton"; import { MatchMapsPanel } from "@/components/tournament/match/match-maps-panel"; import { TeamPanel } from "@/components/tournament/match/team-panel"; import { TournamentAddMapCard } from "@/components/tournament/match/tournament-add-map-card"; import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { TournamentService } from "@/data/tournament"; @@ -43,13 +43,20 @@ export async function generateMetadata(props: { }; } +// Static shell: the dashboard chrome and page container prerender, and the +// match content (flag, params, DB reads, auth) streams into ONE boundary +// whose fallback mirrors the loaded header + panel grid. export default function TournamentMatchPage(props: { params: Promise<{ id: string; matchId: string }>; }) { return ( - }> - - + +
+ }> + + +
+
); } @@ -97,79 +104,133 @@ async function TournamentMatchPageContent({ const bestOf = match.round.bestOf ?? match.tournament.bestOf; return ( - -
-
- - - -
-
-

- {match.tournament.name} — {match.round.roundName} -

- - {match.status} - -
-

Best of {bestOf}

+ <> +
+ + + +
+
+

+ {match.tournament.name} — {match.round.roundName} +

+ + {match.status} +
+

Best of {bestOf}

+
-
-
- -
+
+
+ +
-
- + + {canUpload && !isCompleted && ( + - {canUpload && !isCompleted && ( - - )} + )} +
+ +
+ +
+
+ + ); +} + +// Mirrors the loaded match layout (back-arrow header row, then the +// team / maps / team panel grid) so the streamed content replaces this in +// place with no jump. +function MatchSkeleton() { + return ( + <> +
+ +
+
+ +
+ +
+
-
- +
+
+
+ + + +
+
+ +
+ +
+ {["a", "b", "c"].map((k) => ( +
+
+ + +
+ + +
+
+
+ ))} +
+
+ +
+
+ + +
- + ); } diff --git a/apps/web/src/app/tournaments/[id]/page.tsx b/apps/web/src/app/tournaments/[id]/page.tsx index 19e83c3f7..0a4156f65 100644 --- a/apps/web/src/app/tournaments/[id]/page.tsx +++ b/apps/web/src/app/tournaments/[id]/page.tsx @@ -1,11 +1,11 @@ import { DashboardLayout } from "@/components/dashboard-layout"; -import { PageContentSkeleton } from "@/components/skeletons/page-content-skeleton"; import type { BracketMatchData } from "@/components/tournament/bracket/bracket-match-card"; import { BracketView } from "@/components/tournament/bracket/bracket-view"; import { DoubleBracketView } from "@/components/tournament/bracket/double-bracket-view"; import { RoundRobinSEView } from "@/components/tournament/round-robin/round-robin-se-view"; import { TournamentActions } from "@/components/tournament/tournament-actions"; import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; import { AppRuntime } from "@/data/runtime"; import { TournamentService } from "@/data/tournament"; import { auth, canViewTournament, getCurrentUser } from "@/lib/auth"; @@ -42,13 +42,55 @@ export async function generateMetadata(props: { }; } +// Static shell: DashboardLayout and the page frame prerender, and all +// request-time work (flag, params, auth, bracket data) streams into ONE +// boundary whose fallback mirrors the loaded header row and bracket area. export default function TournamentDetailPage(props: { params: Promise<{ id: string }>; }) { return ( - }> - - + +
+ }> + + +
+
+ ); +} + +function TournamentDetailSkeleton() { + return ( + <> +
+
+ +
+
+ + +
+ +
+
+ +
+ +
+
+ {["a", "b", "c"].map((k) => ( +
+ +
+ {["x", "y"].map((m) => ( + + ))} +
+
+ ))} +
+
+ ); } @@ -203,59 +245,50 @@ async function TournamentDetailPageContent({ }; return ( - -
-
-
- - - -
-
-

- {data.name} -

- - {data.status} - -
-

- {data.format.replace(/_/g, " ")} ·{" "} - {data.playoffBestOf && data.playoffBestOf !== data.bestOf - ? `Bo${data.bestOf} / Bo${data.playoffBestOf}` - : `Bo${data.bestOf}`}{" "} - · {data.teams.length} teams -

+ <> +
+
+ + + +
+
+

{data.name}

+ {data.status}
+

+ {data.format.replace(/_/g, " ")} ·{" "} + {data.playoffBestOf && data.playoffBestOf !== data.bestOf + ? `Bo${data.bestOf} / Bo${data.playoffBestOf}` + : `Bo${data.bestOf}`}{" "} + · {data.teams.length} teams +

-
+ +
-
- {data.format === "ROUND_ROBIN_SE" ? ( - - ) : data.format === "DOUBLE_ELIMINATION" ? ( - - ) : ( - - )} -
+
+ {data.format === "ROUND_ROBIN_SE" ? ( + + ) : data.format === "DOUBLE_ELIMINATION" ? ( + + ) : ( + + )}
- + ); } diff --git a/apps/web/src/app/tournaments/[id]/stats/[teamId]/loading.tsx b/apps/web/src/app/tournaments/[id]/stats/[teamId]/loading.tsx deleted file mode 100644 index 3725dc2e9..000000000 --- a/apps/web/src/app/tournaments/[id]/stats/[teamId]/loading.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
-
- - -
- - - -
-
-
- -
- {["a", "b", "c", "d", "e", "f", "g", "h", "i"].map((k) => ( - - ))} -
- -
-
- - -
-
- {["a", "b", "c", "d"].map((k) => ( -
- - - -
- ))} -
-
- -
-
-
- - -
-
- {["a", "b", "c", "d", "e"].map((k) => ( - - ))} -
-
-
-
- - -
- -
-
- -
-
-
- - -
-
- {["a", "b", "c", "d", "e"].map((k) => ( - - ))} -
-
-
-
- - -
-
- {["a", "b"].map((k) => ( - - ))} -
-
-
- -
-
- - -
- -
-
- ); -} diff --git a/apps/web/src/app/tournaments/[id]/stats/[teamId]/page.tsx b/apps/web/src/app/tournaments/[id]/stats/[teamId]/page.tsx index 043c0de55..e050b1de2 100644 --- a/apps/web/src/app/tournaments/[id]/stats/[teamId]/page.tsx +++ b/apps/web/src/app/tournaments/[id]/stats/[teamId]/page.tsx @@ -1,5 +1,4 @@ import { RecentActivityCalendar } from "@/components/profile/recent-activity-calendar"; -import { PageContentSkeleton } from "@/components/skeletons/page-content-skeleton"; import { BestRoleTriosCard } from "@/components/stats/team/best-role-trios-card"; import { HeroBanImpactCard } from "@/components/stats/team/hero-ban-impact-card"; import { HeroOurBansCard } from "@/components/stats/team/hero-our-bans-card"; @@ -31,6 +30,7 @@ import { UltimateEconomyCard } from "@/components/stats/team/ultimate-economy-ca import { WinLossStreaksCard } from "@/components/stats/team/win-loss-streaks-card"; import { WinProbabilityInsights } from "@/components/stats/team/win-probability-insights"; import { WinrateOverTimeChart } from "@/components/stats/team/winrate-over-time-chart"; +import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TournamentTeamStatsService, @@ -78,7 +78,7 @@ export default function TournamentTeamStatsPage(props: { params: Promise<{ id: string; teamId: string }>; }) { return ( - }> + }> ); @@ -477,3 +477,99 @@ async function TournamentTeamStatsPageContent({
); } + +// Mirrors the loaded page frame: team header, tab list, then the overview +// tab's quick stats, roster/activity, and map sections. +function TeamStatsPageSkeleton() { + return ( +
+
+
+ + +
+ + + +
+
+
+ +
+ {["a", "b", "c", "d", "e", "f", "g", "h", "i"].map((k) => ( + + ))} +
+ +
+
+ + +
+
+ {["a", "b", "c", "d"].map((k) => ( +
+ + + +
+ ))} +
+
+ +
+
+
+ + +
+
+ {["a", "b", "c", "d", "e"].map((k) => ( + + ))} +
+
+
+
+ + +
+ +
+
+ +
+
+
+ + +
+
+ {["a", "b", "c", "d", "e"].map((k) => ( + + ))} +
+
+
+
+ + +
+
+ {["a", "b"].map((k) => ( + + ))} +
+
+
+ +
+
+ + +
+ +
+
+ ); +} diff --git a/apps/web/src/app/tournaments/create/loading.tsx b/apps/web/src/app/tournaments/create/loading.tsx deleted file mode 100644 index 99c4d0d0f..000000000 --- a/apps/web/src/app/tournaments/create/loading.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
- - - -
- ); -} diff --git a/apps/web/src/app/tournaments/create/page.tsx b/apps/web/src/app/tournaments/create/page.tsx index 8ca738c39..3b1112208 100644 --- a/apps/web/src/app/tournaments/create/page.tsx +++ b/apps/web/src/app/tournaments/create/page.tsx @@ -1,6 +1,6 @@ import { DashboardLayout } from "@/components/dashboard-layout"; -import { PageContentSkeleton } from "@/components/skeletons/page-content-skeleton"; import { CreateTournamentButton } from "@/components/tournament/create-tournament-button"; +import { Skeleton } from "@/components/ui/skeleton"; import { tournament } from "@/lib/flags"; import { getFlag } from "@/lib/flags-helpers"; import { getMetadataTranslations } from "@/lib/metadata-i18n"; @@ -13,11 +13,16 @@ export function generateMetadata(): Metadata { return { title: t("title"), description: t("description") }; } +// Static shell: the dashboard chrome prerenders and the flag-gated content +// streams into ONE boundary whose fallback mirrors the loaded layout. The +// heading stays inside the content because the flag can notFound() the page. export default function CreateTournamentPage() { return ( - }> - - + + }> + + + ); } @@ -26,14 +31,23 @@ async function CreateTournamentPageContent() { if (!tournamentEnabled) notFound(); return ( - -
-

Create Tournament

-

- Use the button below to create a new tournament. -

- -
-
+
+

Create Tournament

+

+ Use the button below to create a new tournament. +

+ +
+ ); +} + +// Mirrors the loaded layout: heading, description line, and the create button. +function CreateTournamentSkeleton() { + return ( +
+ + + +
); } diff --git a/apps/web/src/app/tournaments/loading.tsx b/apps/web/src/app/tournaments/loading.tsx deleted file mode 100644 index 9c68fe5eb..000000000 --- a/apps/web/src/app/tournaments/loading.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
- - -
-
- {["a", "b", "c", "d", "e", "f"].map((k) => ( -
-
-
- - -
- -
-
-
- - -
-
-
- ))} -
-
- ); -} diff --git a/apps/web/src/app/tournaments/page.tsx b/apps/web/src/app/tournaments/page.tsx index 070073bed..f3413a3a9 100644 --- a/apps/web/src/app/tournaments/page.tsx +++ b/apps/web/src/app/tournaments/page.tsx @@ -1,7 +1,7 @@ import { DashboardLayout } from "@/components/dashboard-layout"; -import { PageContentSkeleton } from "@/components/skeletons/page-content-skeleton"; import { CreateTournamentButton } from "@/components/tournament/create-tournament-button"; import { TournamentCard } from "@/components/tournament/tournament-card"; +import { Skeleton } from "@/components/ui/skeleton"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { TournamentService } from "@/data/tournament"; @@ -18,15 +18,26 @@ export function generateMetadata(): Metadata { return { title: t("title"), description: t("description") }; } +// Static shell: the layout chrome, heading, and create button prerender, and +// the flag/auth-gated tournament list streams into ONE boundary whose +// fallback mirrors the loaded card grid. export default function TournamentsPage() { return ( - }> - - + +
+
+

Tournaments

+ +
+ }> + + +
+
); } -async function TournamentsPageContent() { +async function TournamentsContent() { const tournamentEnabled = await getFlag(tournament); if (!tournamentEnabled) notFound(); @@ -39,41 +50,59 @@ async function TournamentsPageContent() { ) ); + if (tournaments.length === 0) { + return ( +
+

No tournaments yet

+

Create your first tournament to get started.

+
+ ); + } + return ( - -
-
-

Tournaments

- -
+
+ {tournaments.map((t) => ( + team.name)} + matchCount={t._count.matches} + createdAt={t.createdAt} + /> + ))} +
+ ); +} - {tournaments.length === 0 ? ( -
-

No tournaments yet

-

- Create your first tournament to get started. -

+// Mirrors the loaded TournamentCard grid (card header with name + status +// badge, description line, team/match meta) so the streamed list replaces +// this in place. +function TournamentListSkeleton() { + return ( +
+ {["a", "b", "c", "d", "e", "f"].map((k) => ( +
+
+
+ + +
+
- ) : ( -
- {tournaments.map((t) => ( - team.name)} - matchCount={t._count.matches} - createdAt={t.createdAt} - /> - ))} +
+
+ + +
- )} -
- +
+ ))} +
); } diff --git a/apps/web/src/components/home/v3/landing-page.tsx b/apps/web/src/components/home/v3/landing-page.tsx index 8d9551661..d336dd84e 100644 --- a/apps/web/src/components/home/v3/landing-page.tsx +++ b/apps/web/src/components/home/v3/landing-page.tsx @@ -121,251 +121,252 @@ export async function V3LandingPage() { - - Skip to content - -
- - - + + Skip to content + +
+ + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - -
+ + + +
-
- -
-
-
- {footerNavigation.social.map((item) => ( - - {item.name} - - ))} +
+ +
+
+
+ {footerNavigation.social.map((item) => ( + + {item.name} + + ))} +
+

+ © 2024–{year} {t("footer.copyright")} +

-

- © 2024–{year}{" "} - {t("footer.copyright")} -

-
-
+
); From 49b85861b9fbf28e72c82ae4142152e9db50014f Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Sat, 4 Jul 2026 04:12:19 -0400 Subject: [PATCH 56/59] Mount the scrim page chrome once; gate auth inside the content boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app/[team]/scrim/[scrimId]/edit/page.tsx | 7 +- .../src/app/[team]/scrim/[scrimId]/layout.tsx | 44 +- .../app/[team]/scrim/[scrimId]/loading.tsx | 44 -- .../scrim/[scrimId]/map/[mapId]/page.tsx | 7 + .../map/[mapId]/player/[playerId]/page.tsx | 11 +- .../src/app/[team]/scrim/[scrimId]/page.tsx | 448 ++++++++++-------- apps/web/src/components/dashboard-layout.tsx | 25 +- 7 files changed, 305 insertions(+), 281 deletions(-) delete mode 100644 apps/web/src/app/[team]/scrim/[scrimId]/loading.tsx diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/edit/page.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/edit/page.tsx index 58c1bc917..6ec0f5ac6 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/edit/page.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/edit/page.tsx @@ -1,3 +1,4 @@ +import { NoAuthCard } from "@/components/auth/no-auth"; import { DashboardLayout } from "@/components/dashboard-layout"; import { DangerZone } from "@/components/scrim/danger-zone"; import { EditScrimForm } from "@/components/scrim/edit-scrim-form"; @@ -8,7 +9,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { ScoutingService } from "@/data/scouting"; import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { auth, isAuthedToViewScrim } from "@/lib/auth"; import { scoutingTool } from "@/lib/flags"; import { getFlag } from "@/lib/flags-helpers"; import { resolveMapDataId } from "@/lib/map-data-resolver"; @@ -64,6 +65,10 @@ async function EditScrimContent({ params: PageProps<"/[team]/scrim/[scrimId]/edit">["params"]; }) { const { team, scrimId } = await params; + // The route's access gate — the [scrimId] layout no longer gates the + // subtree (a layout gate adds a chrome-less loading phase above every + // child route). + if (!(await isAuthedToViewScrim(parseInt(scrimId)))) return ; const scrim = await AppRuntime.runPromise( ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(parseInt(scrimId)))) ); diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/layout.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/layout.tsx index 029c4797f..82d8df2e2 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/layout.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/layout.tsx @@ -1,43 +1,13 @@ -import { NoAuthCard } from "@/components/auth/no-auth"; import { SelectedPlayerProvider } from "@/components/map/player-switcher"; -import { Skeleton } from "@/components/ui/skeleton"; -import { isAuthedToViewScrim } from "@/lib/auth"; -import { Suspense, type ReactNode } from "react"; +// No auth gate here: gating the subtree in the layout forces a chrome-less +// fallback ABOVE every child route's own loading design (an extra skeleton +// phase on every scrim/map navigation). Each child route enforces access +// itself as the first step of its Suspense-wrapped content component +// (isAuthedToViewScrim / isAuthedToViewMap → ), so the check +// resolves inside the route's single loading state instead. export default function ScrimDashboardLayout( props: LayoutProps<"/[team]/scrim/[scrimId]"> ) { - // The auth check gates the whole subtree (scrim page and nested map routes), - // so its fallback must contain the suspension here — otherwise it bubbles to - // the root boot skeleton. A neutral content skeleton keeps the area from - // dropping to a blank while the check resolves on first entry; each child - // route's own loading.tsx takes over once auth passes. - return ( - }> - {props.children} - - ); -} - -function ScrimGateSkeleton() { - return ( -
- - - -
- ); -} - -async function ScrimAuthGate({ - params, - children, -}: { - params: LayoutProps<"/[team]/scrim/[scrimId]">["params"]; - children: ReactNode; -}) { - const { scrimId } = await params; - const isAuthed = await isAuthedToViewScrim(parseInt(scrimId)); - if (!isAuthed) return ; - return {children}; + return {props.children}; } diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/loading.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/loading.tsx deleted file mode 100644 index edff4f56a..000000000 --- a/apps/web/src/app/[team]/scrim/[scrimId]/loading.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { DashboardLayout } from "@/components/dashboard-layout"; -import { DirectionalTransition } from "@/components/directional-transition"; -import { Link } from "@/components/ui/link"; -import { Skeleton } from "@/components/ui/skeleton"; -import { getStaticTranslations } from "@/lib/metadata-i18n"; - -export default function ScrimLoading() { - const t = getStaticTranslations("scrimPage"); - - return ( - - -
- - - - - - - - -
-

- {t("maps.title")} -

-
- {Array.from({ length: 8 }).map((_, index) => ( - - ))} -
-
-
-
-
- ); -} diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx index 3357a35e6..b901c3458 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx @@ -1,4 +1,5 @@ import { AppHeader } from "@/components/app-header"; +import { NoAuthCard } from "@/components/auth/no-auth"; import { DirectionalTransition } from "@/components/directional-transition"; import { ActiveMapTab } from "@/components/map/active-map-tab"; import { HeroBans } from "@/components/map/hero-bans"; @@ -109,6 +110,12 @@ async function MapPageContent({ const params = await paramsPromise; const searchParams = await searchParamsPromise; const id = parseInt(params.mapId); + // The route's access gate — the [scrimId] layout no longer gates the + // subtree (a layout gate adds a chrome-less loading phase above every + // child route). + if (!(await isAuthedToViewMap(parseInt(params.scrimId), id))) { + return ; + } const mapDataId = await resolveScrimMapDataId(parseInt(params.scrimId), id); const session = await auth(); const user = await AppRuntime.runPromise( diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx index f4ef0b519..0ff0dedac 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx @@ -1,4 +1,5 @@ import { AppHeader } from "@/components/app-header"; +import { NoAuthCard } from "@/components/auth/no-auth"; import { PlayerCharts } from "@/components/charts/player/player-charts"; import { DirectionalTransition } from "@/components/directional-transition"; import { PlayerSwitcher } from "@/components/map/player-switcher"; @@ -14,7 +15,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { defaultLocale } from "@/i18n/config"; -import { auth } from "@/lib/auth"; +import { auth, isAuthedToViewMap } from "@/lib/auth"; import { resolveMapDataId } from "@/lib/map-data-resolver"; import { getMetadataTranslations, @@ -82,8 +83,14 @@ async function PlayerDashboardContent({ params: PagePropsWithLocale<"/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]">["params"]; }) { const params = await paramsPromise; - const t = await getTranslations("mapPage.player.dashboard"); const id = parseInt(params.mapId); + // The route's access gate — the [scrimId] layout no longer gates the + // subtree (a layout gate adds a chrome-less loading phase above every + // child route). + if (!(await isAuthedToViewMap(parseInt(params.scrimId), id))) { + return ; + } + const t = await getTranslations("mapPage.player.dashboard"); const mapDataId = await resolveMapDataId(id); const playerName = decodeURIComponent(params.playerId); diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx index 3c1c8115d..635ccc2cd 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx @@ -1,4 +1,5 @@ import { ScrimFeedbackBanner } from "@/components/team-ops/scrim-feedback-banner"; +import { NoAuthCard } from "@/components/auth/no-auth"; import { DashboardLayout } from "@/components/dashboard-layout"; import { DirectionalTransition } from "@/components/directional-transition"; import { AddMapCard } from "@/components/map/add-map"; @@ -16,6 +17,7 @@ import { } from "@/components/scrim/scrim-overview-section"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Link } from "@/components/ui/link"; +import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, @@ -36,7 +38,10 @@ import { defaultLocale } from "@/i18n/config"; import { auth, canManageTeam, isAuthedToViewScrim } from "@/lib/auth"; import { mapComparison, overviewCard, positionalData } from "@/lib/flags"; import { getFlag } from "@/lib/flags-helpers"; -import { getMetadataTranslations } from "@/lib/metadata-i18n"; +import { + getMetadataTranslations, + getStaticTranslations, +} from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import type { PagePropsWithLocale } from "@/types/next"; import { $Enums } from "@/generated/prisma/browser"; @@ -91,12 +96,54 @@ export async function generateMetadata( }; } -export default async function ScrimDashboardPage( +// Static shell: the chrome and page frame prerender and mount ONCE. (The old +// route-level loading.tsx and layout auth gate each rendered their own +// DashboardLayout, so every fallback→content swap remounted the chrome and +// re-suspended the header — the multi-skeleton flash.) Everything +// request-derived streams into the single boundary below; the auth gate is +// the first step of ScrimContent so it resolves inside the same skeleton. +export default function ScrimDashboardPage( props: PagePropsWithLocale<"/[team]/scrim/[scrimId]"> ) { - const params = await props.params; + return ( + + +
+ }> + + +
+
+
+ ); +} + +// Thunk, not a started promise: the read must begin inside the streamed +// header (after `connection()`), never during the static shell render. +function guestModeSource( + params: PagePropsWithLocale<"/[team]/scrim/[scrimId]">["params"] +) { + return async () => { + const { scrimId } = await params; + const id = parseInt(scrimId); + if (!Number.isSafeInteger(id) || id <= 0) return false; + const row = await prisma.scrim.findFirst({ + where: { id }, + select: { guestMode: true }, + }); + return row?.guestMode ?? false; + }; +} + +async function ScrimContent({ + params: paramsPromise, +}: { + params: PagePropsWithLocale<"/[team]/scrim/[scrimId]">["params"]; +}) { + const params = await paramsPromise; const id = parseInt(params.scrimId); if (!Number.isSafeInteger(id) || id <= 0) notFound(); + if (!(await isAuthedToViewScrim(id))) return ; const session = await auth(); const t = await getTranslations("scrimPage"); @@ -108,7 +155,6 @@ export default async function ScrimDashboardPage( mapDataRows, user, feedbackScrim, - visibilityRow, mapComparisonEnabled, overviewCardEnabled, showPositional, @@ -147,10 +193,6 @@ export default async function ScrimDashboardPage( opponentTeam: { select: { name: true } }, }, }), - prisma.scrim.findFirst({ - where: { id }, - select: { guestMode: true }, - }), getFlag(mapComparison), getFlag(overviewCard), getFlag(positionalData), @@ -158,7 +200,6 @@ export default async function ScrimDashboardPage( if (!scrim) notFound(); const teamId = scrim.teamId; - const visibility = visibilityRow ?? { guestMode: false }; const mapTeamNames = new Map(); for (const row of mapDataRows) { @@ -291,207 +332,226 @@ export default async function ScrimDashboardPage( } return ( - - -
- - -
-
-

- {scrim?.name ?? t("newScrim")} -

- {hasPerms && ( - - - - - - - {t("edit")} - - )} -
- {showOverview && ( -
- - -
- )} + + {t("edit")} + + )} +
+ {showOverview && ( +
+ +
+ )} +
-
- +
+ + + {t("meta.mapCount", { count: maps.length })} + {scrim.opponentTeamAbbr && ( + <> - {t("meta.mapCount", { count: maps.length })} - {scrim.opponentTeamAbbr && ( - <> - - - - - - View OWCS scouting report - - - )} -
+ + + + + View OWCS scouting report + + + )} +
- {feedbackScrim?.opponentTeamId != null && - feedbackScrim.opponentTeam && - !feedbackScrim.feedback && - canManage && ( -
- -
- )} + {feedbackScrim?.opponentTeamId != null && + feedbackScrim.opponentTeam && + !feedbackScrim.feedback && + canManage && ( +
+ +
+ )} - {showOverview ? ( -
- - - - } - /> -
- ) : showOverviewUnavailable ? ( -
- -
- ) : ( - showPositional && - positionalStats && ( -
- -
- ) - )} + {showOverview ? ( +
+ + + + } + /> +
+ ) : showOverviewUnavailable ? ( +
+ +
+ ) : ( + showPositional && + positionalStats && ( +
+ +
+ ) + )} - {hasPerms && ( -
- -
- )} + {hasPerms && ( +
+ +
+ )} -
-

- {t("maps.title")} -

+
+

+ {t("maps.title")} +

- {maps.length > 0 ? ( -
- {maps.map((map) => { - const meta = mapMetaById.get(map.id); - return ( - - ); - })} -
- ) : ( - -
- - {mapComparisonEnabled && teamId && ( - + ) : ( + + )} - - +
+ {mapComparisonEnabled && teamId && ( + + )} + + ); +} + +// Mirrors the loaded page's frame (back-link nav, title, meta line, overview +// band, add-map card, maps heading + grid) so content replaces it in place. +function ScrimOverviewSkeleton() { + const t = getStaticTranslations("scrimPage"); + + return ( + <> + + + + + +
+

+ {t("maps.title")} +

+
+ {Array.from({ length: 8 }).map((_, index) => ( + + ))} +
+
+ ); } diff --git a/apps/web/src/components/dashboard-layout.tsx b/apps/web/src/components/dashboard-layout.tsx index c8e24ee3c..6b45ff138 100644 --- a/apps/web/src/components/dashboard-layout.tsx +++ b/apps/web/src/components/dashboard-layout.tsx @@ -14,9 +14,17 @@ import { Suspense, type ReactNode } from "react"; export function DashboardLayout({ children, guestMode, + guestModeSource, }: { children: ReactNode; guestMode?: boolean; + /** + * Late-resolved guest-mode read for routes whose guest state depends on + * route params (e.g. guest-shared scrims). It runs inside the streamed + * header — after `connection()` — so the caller's shell stays static; a + * plain `guestMode` boolean takes precedence when provided. + */ + guestModeSource?: () => Promise; }) { // The page chrome (skip link, layout structure, Footer) is static so any // route using this layout paints instantly; the auth-derived header and the @@ -31,7 +39,10 @@ export function DashboardLayout({
}> - +
}> @@ -56,12 +67,20 @@ function DashboardContentSkeleton() { ); } -async function AuthedAppHeader({ guestMode }: { guestMode?: boolean }) { +async function AuthedAppHeader({ + guestMode, + guestModeSource, +}: { + guestMode?: boolean; + guestModeSource?: () => Promise; +}) { // This header is per-user (auth-derived) and always streams behind its // Suspense boundary, so mark it request-time up front. Without this, PPR // attempts to prerender it and trips on `Date.now()` inside the Effect // runtime before the auth read defers it. await connection(); + const resolvedGuestMode = + guestMode ?? (guestModeSource ? await guestModeSource() : undefined); const session = await auth(); const user = await AppRuntime.runPromise( UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) @@ -72,7 +91,7 @@ async function AuthedAppHeader({ guestMode }: { guestMode?: boolean }) { switcher={session && } session={session} user={user} - guestMode={guestMode} + guestMode={resolvedGuestMode} /> ); } From 4c368a1182c07c3218f18c450fb28072272a1da6 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Sat, 4 Jul 2026 04:12:51 -0400 Subject: [PATCH 57/59] Document the chrome-mounts-once and content-boundary auth-gate rules --- apps/web/AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 791fc095c..e51c11ee3 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -23,6 +23,7 @@ Before committing, ensure that changes are formatted, linted, typechecked, and p - `loading.tsx` and Suspense fallbacks must be synchronous and cookie-free. For translated text in them use `getStaticTranslations` (`@/lib/metadata-i18n`) — `getTranslations` reads the LOCALE cookie and forces the shell dynamic. Same for client components that prerender: `useTranslations` works because the intl provider carries the default-locale catalog (plus explicit `timeZone`) — never remove those props or next-intl "environment-falls-back" into `cookies()`. - A client provider above `{children}` must not read request data (`useSearchParams`, cookies): hold state and sync via a Suspense-wrapped child (see `TeamSwitcherProvider`). - Prefer ONE content boundary per page whose fallback mirrors the real content's own pending layout (see `/dashboard`'s `ScrimListSkeleton` mirroring `ScrimPagination`), instead of stacking route `loading.tsx` + layout skeleton + component skeletons — that's what causes multi-skeleton flashing. + - Page chrome (`DashboardLayout`/`AppHeader`) must render exactly once, in the page's static shell — never in both a `loading.tsx` and the page (the fallback→content swap remounts it and re-suspends the header), and never below a layout-level auth gate. Auth gates are the FIRST step of the page's Suspense-wrapped content component (`isAuthedToViewScrim`/`isAuthedToViewMap` → ``, see the `[scrimId]` routes), so they resolve inside the page's single skeleton. Chrome that needs param-derived request data takes a thunk resolved inside the streamed header (`DashboardLayout`'s `guestModeSource`). These surface only at request time on real `[param]` routes (not `next build`); check Vercel runtime logs after deploying cache-touching changes. From a978e15978e877faaa9cc7c6d6542f1a4d9f8b59 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Sat, 4 Jul 2026 05:18:57 -0400 Subject: [PATCH 58/59] Make map tab switches optimistic with cached revisits and a dot-matrix pending overlay --- apps/web/messages/en.json | 1 + apps/web/messages/ko.json | 1 + apps/web/messages/zh.json | 1 + .../scrim/[scrimId]/map/[mapId]/page.tsx | 5 +- .../{stats/team => }/dot-matrix-loader.tsx | 0 apps/web/src/components/map/map-tabs.tsx | 141 ++++++++++++++---- .../stats/team/team-stats-content.tsx | 2 +- 7 files changed, 116 insertions(+), 35 deletions(-) rename apps/web/src/components/{stats/team => }/dot-matrix-loader.tsx (100%) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a9cb55039..999e4165e 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2187,6 +2187,7 @@ "back": "Back to scrim overview", "dashboard": "Dashboard", "tabsLabel": "Map sections", + "loadingTab": "Loading tab data", "mobileBanner": { "message": "For the best experience, we recommend using a larger screen.", "dismiss": "Dismiss" diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json index cf327dbdd..b76691a54 100644 --- a/apps/web/messages/ko.json +++ b/apps/web/messages/ko.json @@ -2187,6 +2187,7 @@ "back": "스크림 개요로 돌아가기", "dashboard": "대시보드", "tabsLabel": "맵 섹션", + "loadingTab": "탭 데이터 불러오는 중", "mobileBanner": { "message": "최상의 경험을 위해 더 큰 화면 사용을 권장합니다.", "dismiss": "닫기" diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index 0110755a8..45e2ad077 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -2187,6 +2187,7 @@ "back": "返回比赛概览", "dashboard": "仪表板", "tabsLabel": "地图分区", + "loadingTab": "正在加载标签页数据", "mobileBanner": { "message": "为了获得最佳体验,建议使用更大的屏幕。", "dismiss": "关闭" diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx index b901c3458..ee3a2a31c 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx @@ -241,8 +241,11 @@ async function MapPageContent({
+ {/* No key on this boundary: switching tabs re-suspends it inside + MapTabs' transition, so the previous tab stays visible (dimmed, + under the dot-matrix loader) instead of flashing the skeleton. + The skeleton only shows on the initial stream. */} diff --git a/apps/web/src/components/stats/team/dot-matrix-loader.tsx b/apps/web/src/components/dot-matrix-loader.tsx similarity index 100% rename from apps/web/src/components/stats/team/dot-matrix-loader.tsx rename to apps/web/src/components/dot-matrix-loader.tsx diff --git a/apps/web/src/components/map/map-tabs.tsx b/apps/web/src/components/map/map-tabs.tsx index 8bfb0cfc0..7b4236785 100644 --- a/apps/web/src/components/map/map-tabs.tsx +++ b/apps/web/src/components/map/map-tabs.tsx @@ -1,10 +1,20 @@ "use client"; +import { DotMatrixLoader } from "@/components/dot-matrix-loader"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { parseAsFloat, parseAsString, useQueryState } from "nuqs"; -import { useCallback, useTransition, type ReactNode } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useOptimistic, + useRef, + useTransition, + type ReactNode, +} from "react"; type TabDef = { value: string; @@ -22,6 +32,14 @@ type MapTabsProps = { children: ReactNode; }; +// Lets content rendered inside the tabs (killfeed, timelines) switch tabs +// through the same transition-wrapped setter as a tab-strip click, so the +// optimistic indicator, cached-content display, and server re-render are +// wired up identically. +const MapTabsSwitchContext = createContext<((tab: string) => void) | null>( + null +); + export function MapTabs({ tabs, activeTab, children }: MapTabsProps) { const t = useTranslations("mapPage"); // Drive the shallow:false navigation through a transition so React keeps the @@ -35,37 +53,84 @@ export function MapTabs({ tabs, activeTab, children }: MapTabsProps) { .withDefault("overview") .withOptions({ shallow: false, startTransition }) ); + // The indicator is optimistic local state, not the nuqs value: it must move + // the moment a tab is clicked, and it settles back to the server-resolved + // tab when the transition commits. + const [optimisticTab, setOptimisticTab] = useOptimistic(activeTab); + + // Client-side cache of every visited tab's server-rendered content. Map and + // scrim data is static, so switching back to a visited tab renders the + // cached tree instantly (no loader) while the refresh streams in behind it. + const visitedTabs = useRef(new Map()); + useEffect(() => { + visitedTabs.current.set(activeTab, children); + }, [activeTab, children]); + + const switchTab = useCallback( + (value: string) => { + startTransition(() => { + setOptimisticTab(value); + void setTab(value); + }); + }, + [setTab, setOptimisticTab] + ); + + // While a switch is in flight, `children` still holds the PREVIOUS tab's + // content. Show the cached tree for the target tab when we have one; + // otherwise keep the stale content dimmed under the loader until the new + // tab streams in. + const isSettled = optimisticTab === activeTab; + const cachedContent = isSettled + ? undefined + : visitedTabs.current.get(optimisticTab); + const showStaleOverlay = isPending && !isSettled && cachedContent == null; return ( - void setTab(value)} - className={cn("space-y-4", isPending && "[&_[role=tabpanel]]:opacity-60")} - > - - {tabs.map((tab) => - tab.hidden ? null : ( - - {tab.shortLabel ? ( - <> - {tab.label} - {tab.shortLabel} - - ) : ( - tab.label - )} - - ) - )} - - - {children} - - + + + + {tabs.map((tab) => + tab.hidden ? null : ( + + {tab.shortLabel ? ( + <> + {tab.label} + {tab.shortLabel} + + ) : ( + tab.label + )} + + ) + )} + + +
+ {cachedContent ?? children} +
+ {showStaleOverlay ? ( +
+ +
+ ) : null} +
+
+
); } @@ -74,14 +139,24 @@ export function useReplayTimeParam() { } export function useGoToReplay() { - const [, setTab] = useQueryState("tab", parseAsString); + const switchTab = useContext(MapTabsSwitchContext); + // Fallback for use outside MapTabs; shallow:false so the server actually + // re-renders the active tab (the default shallow update only touches the URL). + const [, setTab] = useQueryState( + "tab", + parseAsString.withOptions({ shallow: false }) + ); const [, setTime] = useQueryState("t", parseAsFloat); return useCallback( (matchTime: number) => { void setTime(matchTime); - void setTab("replay"); + if (switchTab) { + switchTab("replay"); + } else { + void setTab("replay"); + } }, - [setTab, setTime] + [switchTab, setTab, setTime] ); } diff --git a/apps/web/src/components/stats/team/team-stats-content.tsx b/apps/web/src/components/stats/team/team-stats-content.tsx index ec94ae578..01f5e65ed 100644 --- a/apps/web/src/components/stats/team/team-stats-content.tsx +++ b/apps/web/src/components/stats/team/team-stats-content.tsx @@ -3,7 +3,7 @@ import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; import type { ReactNode } from "react"; -import { DotMatrixLoader } from "./dot-matrix-loader"; +import { DotMatrixLoader } from "@/components/dot-matrix-loader"; import { useRangeTransition } from "./range-transition-context"; /** From cdeca621fadc20c0e4205c1c4cdb77334d8ec993 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Sun, 5 Jul 2026 03:29:40 -0400 Subject: [PATCH 59/59] Runtime-prefetch map tabs by caching the whole tab render path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/AGENTS.md | 6 + .../scrim/[scrimId]/map/[mapId]/page.tsx | 104 ++++++++---------- .../src/app/[team]/scrim/[scrimId]/page.tsx | 14 +-- apps/web/src/app/api/vod/route.ts | 4 + apps/web/src/app/demo/page.tsx | 27 +++-- .../src/components/charts/map/map-charts.tsx | 27 ++++- .../web/src/components/map/active-map-tab.tsx | 57 +++++++--- .../src/components/map/compare-players.tsx | 25 ++++- .../src/components/map/default-overview.tsx | 20 ++-- .../components/map/heatmap/heatmap-tab.tsx | 26 ++++- apps/web/src/components/map/killfeed.tsx | 42 +++---- apps/web/src/components/map/map-events.tsx | 13 ++- apps/web/src/components/map/map-tabs.tsx | 36 +++++- apps/web/src/components/map/player-card.tsx | 32 ++++-- .../src/components/map/replay/replay-tab.tsx | 23 +++- .../src/components/map/routes/routes-tab.tsx | 26 ++++- .../map/tempo/tempo-chart-server.tsx | 7 +- apps/web/src/components/player/all-heroes.tsx | 16 ++- .../src/components/player/specific-hero.tsx | 28 ++++- apps/web/src/data/cached/map-cache.ts | 72 +++++++++++- apps/web/src/data/cached/map-viewer.ts | 52 +++++++++ apps/web/src/hooks/use-predictive-prefetch.ts | 43 +++++++- apps/web/src/lib/map-data-resolver.ts | 15 ++- apps/web/src/lib/metadata-i18n.ts | 28 ++++- 24 files changed, 579 insertions(+), 164 deletions(-) create mode 100644 apps/web/src/data/cached/map-viewer.ts diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index e51c11ee3..9aa15676f 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -25,6 +25,12 @@ Before committing, ensure that changes are formatted, linted, typechecked, and p - Prefer ONE content boundary per page whose fallback mirrors the real content's own pending layout (see `/dashboard`'s `ScrimListSkeleton` mirroring `ScrimPagination`), instead of stacking route `loading.tsx` + layout skeleton + component skeletons — that's what causes multi-skeleton flashing. - Page chrome (`DashboardLayout`/`AppHeader`) must render exactly once, in the page's static shell — never in both a `loading.tsx` and the page (the fallback→content swap remounts it and re-suspends the header), and never below a layout-level auth gate. Auth gates are the FIRST step of the page's Suspense-wrapped content component (`isAuthedToViewScrim`/`isAuthedToViewMap` → ``, see the `[scrimId]` routes), so they resolve inside the page's single skeleton. Chrome that needs param-derived request data takes a thunk resolved inside the streamed header (`DashboardLayout`'s `guestModeSource`). +5. **Runtime prefetching (`prefetch = 'allow-runtime'`) requires the whole render path to be cache-safe.** The map page prefetches `?tab=` URLs on hover/trajectory (`MapTabs` + `prefetchRoute` in `use-predictive-prefetch.ts`); the server resolves the target tab ahead of the click only as far as the caching structure allows. Rules that make it work: + - Per-viewer request work (auth gate, session, user row, colorblind palette) lives in ONE `"use cache: private"` scope (`getMapViewerContext`) — an uncached session/DB read aborts the runtime prerender at the surrounding Suspense boundary, and in `generateMetadata` it fails the whole prefetch with E1370 ("couldn't prerender metadata"). `cacheLife` stale must be ≥ 30s for prefetching. + - Tab/server components on the prefetch path are `"use cache"` components: locale and feature flags come in as PROPS (request APIs are forbidden in public cache scopes) — translate with `getLocaleTranslations(locale, ns)` (`@/lib/metadata-i18n`), never `getTranslations`. Cached output embedding presigned URLs must cap `expire` below the URL's lifetime (see HeatmapTab/RoutesTab). + - `router.prefetch` can't issue runtime prefetches directly (Next internals); the standard prefetch is enough — the server's `allow-runtime` hint upgrades it. `typedRoutes` narrows `prefetch` to one arg, so use `prefetchRoute`. + - Testing gotcha: Next disables ALL prefetching for bot user agents — headless-Chrome UAs ("HeadlessChrome") silently no-op; pass a real UA when verifying with agent-browser, and use a production build (`next start`), never dev. + These surface only at request time on real `[param]` routes (not `next build`); check Vercel runtime logs after deploying cache-touching changes. ## Agent skills diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx index ee3a2a31c..ff34d6307 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx @@ -10,48 +10,53 @@ import { PlayerSwitcher } from "@/components/map/player-switcher"; import { ReplayCode } from "@/components/scrim/replay-code"; import { StatsViewBeacon } from "@/components/usage/stats-view-beacon"; import { + getCachedHeroBans, + getCachedMapDetails, + getCachedMapRow, getCachedMatchStory, getCachedMostPlayedHeroes, + getCachedScrimVisibility, } from "@/data/cached/map-cache"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { defaultLocale } from "@/i18n/config"; -import { auth, isAuthedToViewMap } from "@/lib/auth"; -import { positionalData, tempoChart } from "@/lib/flags"; +import { getMapViewerContext } from "@/data/cached/map-viewer"; +import { defaultLocale, type Locale } from "@/i18n/config"; +import { coachingCanvas, positionalData, tempoChart } from "@/lib/flags"; import { getFlag } from "@/lib/flags-helpers"; import { resolveScrimMapDataId } from "@/lib/map-data-resolver"; import { getMetadataTranslations } from "@/lib/metadata-i18n"; -import prisma from "@/lib/prisma"; -import { getColorblindMode } from "@/lib/server-utils"; import { translateMapName } from "@/lib/utils"; import type { PagePropsWithLocale, SearchParams } from "@/types/next"; -import { Effect } from "effect"; import type { Metadata, Route } from "next"; -import { getTranslations } from "next-intl/server"; +import { getLocale, getTranslations } from "next-intl/server"; import Link from "next/link"; import { Suspense, ViewTransition } from "react"; +// Opt into runtime prefetching: MapTabs prefetches `?tab=` URLs with +// kind:"full" on hover/trajectory, and the server can prerender the target +// tab ahead of the click because the render path below is fully cached +// (public caches for map data, "use cache: private" for the viewer context). +export const prefetch = "allow-runtime"; + export async function generateMetadata( props: PagePropsWithLocale<"/[team]/scrim/[scrimId]/map/[mapId]"> ): Promise { const params = await props.params; const scrimId = parseInt(params.scrimId); const mapId = parseInt(decodeURIComponent(params.mapId)); + // The gate goes through the private-cached viewer context: an uncached + // session read here fails the route's runtime prefetches with "couldn't + // prerender metadata" (E1370), and a slow read pushes the streamed + // chunk to the tail of the stream. const canViewMap = Number.isSafeInteger(scrimId) && Number.isSafeInteger(mapId) && - (await isAuthedToViewMap(scrimId, mapId)); + (await getMapViewerContext(scrimId, mapId)).canView; const t = getMetadataTranslations("mapPage.mapMetadata"); const mapName = canViewMap - ? await prisma.matchStart.findFirst({ - where: { - MapDataId: await resolveScrimMapDataId(scrimId, mapId), - }, - select: { - map_name: true, - }, - }) + ? await getCachedMapDetails( + mapId, + await resolveScrimMapDataId(scrimId, mapId) + ) : null; const translatedMapName = await translateMapName(mapName?.map_name ?? "Map"); @@ -110,62 +115,48 @@ async function MapPageContent({ const params = await paramsPromise; const searchParams = await searchParamsPromise; const id = parseInt(params.mapId); + const scrimId = parseInt(params.scrimId); + if (!Number.isSafeInteger(id) || !Number.isSafeInteger(scrimId)) { + return <NoAuthCard />; + } // The route's access gate — the [scrimId] layout no longer gates the // subtree (a layout gate adds a chrome-less loading phase above every - // child route). - if (!(await isAuthedToViewMap(parseInt(params.scrimId), id))) { + // child route). The viewer context is "use cache: private" so runtime + // prefetches can execute it (uncached session reads would abort them). + const viewer = await getMapViewerContext(scrimId, id); + if (!viewer.canView) { return <NoAuthCard />; } - const mapDataId = await resolveScrimMapDataId(parseInt(params.scrimId), id); - const session = await auth(); - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) - ); + const mapDataId = await resolveScrimMapDataId(scrimId, id); const t = await getTranslations("mapPage"); + // The active tab components are cached ("use cache"), so the locale is + // passed in as a prop/cache key rather than read from the cookie inside. + const locale = (await getLocale()) as Locale; // Tournament context for back navigation const fromTournament = searchParams.from === "tournament"; const tournamentId = searchParams.tournamentId as string | undefined; const matchId = searchParams.matchId as string | undefined; - const { team1, team2 } = await getColorblindMode(user?.id ?? ""); - const [ mostPlayedHeroes, mapDetails, map, visibility, heroBans, - noteContent, tempoChartEnabled, positionalDataEnabled, + coachingCanvasEnabled, matchStory, ] = await Promise.all([ getCachedMostPlayedHeroes(id), - prisma.matchStart.findFirst({ - where: { MapDataId: mapDataId }, - select: { map_name: true, team_1_name: true }, - }), - prisma.map.findFirst({ - where: { id }, - select: { replayCode: true, vod: true }, - }), - prisma.scrim.findFirst({ - where: { id: parseInt(params.scrimId) }, - select: { guestMode: true }, - }), - prisma.heroBan.findMany({ - where: { MapDataId: mapDataId }, - }), - prisma.note.findFirst({ - where: { - scrimId: parseInt(params.scrimId), - MapDataId: mapDataId, - }, - select: { content: true }, - }), + getCachedMapDetails(id, mapDataId), + getCachedMapRow(scrimId, id), + getCachedScrimVisibility(scrimId), + getCachedHeroBans(id, mapDataId), getFlag(tempoChart), getFlag(positionalData), + getFlag(coachingCanvas), getCachedMatchStory(id, mapDataId), ]); @@ -207,8 +198,8 @@ async function MapPageContent({ <div className="flex-col md:flex"> <AppHeader switcher={<PlayerSwitcher mostPlayedHeroes={mostPlayedHeroes} />} - session={session} - user={user} + session={viewer.session} + user={viewer.user} guestMode={visibility?.guestMode ?? false} /> <div className="flex-1 space-y-4 px-6 pt-6 pb-12 md:px-8"> @@ -256,13 +247,14 @@ async function MapPageContent({ activeTab={activeTab} id={id} mapDataId={mapDataId} - scrimId={parseInt(params.scrimId)} - team1Color={team1} - team2Color={team2} + scrimId={scrimId} + locale={locale} + team1Color={viewer.team1Color} + team2Color={viewer.team2Color} tempoChartEnabled={tempoChartEnabled} positionalDataEnabled={positionalDataEnabled} + coachingCanvasEnabled={coachingCanvasEnabled} matchStory={matchStory} - noteContent={noteContent?.content ?? ""} vod={map?.vod ?? ""} /> </Suspense> diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx index 635ccc2cd..b69ff727c 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/page.tsx @@ -62,16 +62,10 @@ export async function generateMetadata( scrimId > 0 && (await isAuthedToViewScrim(scrimId)); - const scrim = canViewScrim - ? await prisma.scrim.findFirst({ - where: { - id: scrimId, - }, - select: { - name: true, - }, - }) - : null; + // Cached read: with streamed metadata under PPR, the <title> chunk flushes + // whenever generateMetadata resolves — a raw DB read here pushes the title + // deep into the stream, which reads as "the tab never shows a title". + const scrim = canViewScrim ? await getCachedScrim(scrimId) : null; const scrimName = scrim?.name ?? t("scrim"); diff --git a/apps/web/src/app/api/vod/route.ts b/apps/web/src/app/api/vod/route.ts index d7a910952..e5b83ee9a 100644 --- a/apps/web/src/app/api/vod/route.ts +++ b/apps/web/src/app/api/vod/route.ts @@ -2,6 +2,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth, canEditScrim } from "@/lib/auth"; +import { revalidateMap } from "@/lib/cache-tags"; import { Logger } from "@/lib/logger"; import { parseVodUrl } from "@/lib/vods"; import { NextResponse } from "next/server"; @@ -68,5 +69,8 @@ export async function POST(req: Request) { data: { vod: vodUrl }, }); + // The map page frame reads the VOD through a cached reader (getCachedMapRow). + revalidateMap(mapId); + return new NextResponse(JSON.stringify(updatedVod), { status: 200 }); } diff --git a/apps/web/src/app/demo/page.tsx b/apps/web/src/app/demo/page.tsx index 00b8f34f9..22a052e5b 100644 --- a/apps/web/src/app/demo/page.tsx +++ b/apps/web/src/app/demo/page.tsx @@ -18,13 +18,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { PlayerService } from "@/data/player"; -import { defaultLocale } from "@/i18n/config"; +import { defaultLocale, type Locale } from "@/i18n/config"; import { resolveMapDataId } from "@/lib/map-data-resolver"; import { getMetadataTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import { toTitleCase, translateMapName } from "@/lib/utils"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; +import { getLocale, getTranslations } from "next-intl/server"; import Link from "next/link"; import { Suspense } from "react"; @@ -115,6 +115,9 @@ function DemoPageSkeleton() { async function DemoPageContent() { const t = await getTranslations("mapPage"); + // The tab components are cached ("use cache"), so the locale is resolved + // here at request time and passed in as a prop. + const locale = (await getLocale()) as Locale; const id = DEMO_MAP_ID; const mapDataId = await resolveMapDataId(id); @@ -195,6 +198,7 @@ async function DemoPageContent() { <TabsContent value="overview" className="space-y-4"> <DefaultOverview id={id} + locale={locale} team1Color={team1} team2Color={team2} positionalDataOverride @@ -203,15 +207,17 @@ async function DemoPageContent() { <TabsContent value="killfeed" className="space-y-4"> <Killfeed id={id} + locale={locale} team1Color={team1} team2Color={team2} - positionalDataOverride - coachingCanvasOverride + positionalDataEnabled + coachingCanvasEnabled /> </TabsContent> <TabsContent value="charts" className="space-y-4"> <MapCharts id={id} + locale={locale} team1Color={team1} team2Color={team2} tempoChartEnabled @@ -219,19 +225,24 @@ async function DemoPageContent() { </TabsContent> <TabsContent value="heatmap" className="space-y-4"> <PremiumHighlight> - <HeatmapTab id={mapDataId} /> + <HeatmapTab id={mapDataId} mapId={id} locale={locale} /> </PremiumHighlight> </TabsContent> <TabsContent value="replay" className="space-y-4"> <PremiumHighlight> - <ReplayTab id={mapDataId} /> + <ReplayTab id={mapDataId} mapId={id} locale={locale} /> </PremiumHighlight> </TabsContent> <TabsContent value="events" className="space-y-4"> - <MapEvents id={id} team1Color={team1} team2Color={team2} /> + <MapEvents + id={id} + locale={locale} + team1Color={team1} + team2Color={team2} + /> </TabsContent> <TabsContent value="compare" className="space-y-4"> - <ComparePlayers id={id} /> + <ComparePlayers id={id} locale={locale} /> </TabsContent> </Tabs> </div> diff --git a/apps/web/src/components/charts/map/map-charts.tsx b/apps/web/src/components/charts/map/map-charts.tsx index e43ab4559..816547e39 100644 --- a/apps/web/src/components/charts/map/map-charts.tsx +++ b/apps/web/src/components/charts/map/map-charts.tsx @@ -11,14 +11,17 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { getMapUltAdvantage } from "@/data/map/ult-advantage"; +import type { Locale } from "@/i18n/config"; +import { mapTag } from "@/lib/cache-tags"; +import { getLocaleTranslations } from "@/lib/metadata-i18n"; import { resolveMapDataId } from "@/lib/map-data-resolver"; import prisma from "@/lib/prisma"; import type { Kill } from "@/generated/prisma/client"; import { InfoCircledIcon } from "@radix-ui/react-icons"; -import { getTranslations } from "next-intl/server"; +import { cacheLife, cacheTag } from "next/cache"; -async function ChartTooltip() { - const t = await getTranslations("mapPage.charts"); +async function ChartTooltip({ locale }: { locale: Locale }) { + const t = await getLocaleTranslations(locale, "mapPage.charts"); return ( <Tooltip> <TooltipTrigger asChild> @@ -73,6 +76,7 @@ async function groupKillsByInterval(id: number, maxInterval: number) { type ChartSectionProps = { id: string; + locale: Locale; eyebrow: string; title: string; description: string; @@ -81,6 +85,7 @@ type ChartSectionProps = { function ChartSection({ id, + locale, eyebrow, title, description, @@ -99,7 +104,7 @@ function ChartSection({ > {title} </h3> - <ChartTooltip /> + <ChartTooltip locale={locale} /> </div> </div> <div className="-ml-2">{children}</div> @@ -112,16 +117,22 @@ function ChartSection({ export async function MapCharts({ id, + locale, team1Color, team2Color, tempoChartEnabled, }: { id: number; + locale: Locale; team1Color: string; team2Color: string; tempoChartEnabled: boolean; }) { - const t = await getTranslations("mapPage.charts"); + "use cache"; + cacheLife("hours"); + cacheTag(mapTag(id)); + + const t = await getLocaleTranslations(locale, "mapPage.charts"); const mapDataId = await resolveMapDataId(id); const teams = await prisma.matchStart.findFirst({ where: { @@ -183,12 +194,14 @@ export async function MapCharts({ <> <ChartSection id="tempo" + locale={locale} eyebrow={t("tempo.eyebrow")} title={t("tempo.title")} description={t("tempo.description")} > <TempoChartServer id={id} + locale={locale} team1Color={team1Color} team2Color={team2Color} /> @@ -200,6 +213,7 @@ export async function MapCharts({ <ChartSection id="kills-by-fight" + locale={locale} eyebrow={t("killsByFight.eyebrow")} title={t("killsByFight.title")} description={t("killsByFight.description")} @@ -213,6 +227,7 @@ export async function MapCharts({ <ChartSection id="ult-advantage" + locale={locale} eyebrow={t("ultAdvantage.eyebrow")} title={t("ultAdvantage.title")} description={t("ultAdvantage.description", { team: team1Name })} @@ -234,6 +249,7 @@ export async function MapCharts({ <div className="grid gap-6 lg:grid-cols-2 lg:gap-8"> <ChartSection id="final-blows-by-role" + locale={locale} eyebrow={t("finalBlowsByRole.eyebrow")} title={t("finalBlowsByRole.title")} description={t("finalBlowsByRole.description")} @@ -247,6 +263,7 @@ export async function MapCharts({ <ChartSection id="damage-by-round" + locale={locale} eyebrow={t("dmgByRound.eyebrow")} title={t("dmgByRound.title")} description={t("dmgByRound.description")} diff --git a/apps/web/src/components/map/active-map-tab.tsx b/apps/web/src/components/map/active-map-tab.tsx index 3221eff22..7a7853bca 100644 --- a/apps/web/src/components/map/active-map-tab.tsx +++ b/apps/web/src/components/map/active-map-tab.tsx @@ -10,11 +10,11 @@ import { ReplayTab } from "@/components/map/replay/replay-tab"; import { RoutesTab } from "@/components/map/routes/routes-tab"; import { TipTap } from "@/components/tiptap/tiptap"; import { VodOverview } from "@/components/vods/vod-overview"; +import { getCachedFightInitiation } from "@/data/cached/map-cache"; import type { MatchStoryResult } from "@/data/map/match-story-service"; -import { - getFightInitiationForMapData, - type MapInitiationResult, -} from "@/lib/fight-initiation"; +import type { Locale } from "@/i18n/config"; +import type { MapInitiationResult } from "@/lib/fight-initiation"; +import prisma from "@/lib/prisma"; type ActiveMapTabProps = { /** The tab currently selected via the `?tab=` search param. */ @@ -22,12 +22,17 @@ type ActiveMapTabProps = { id: number; mapDataId: number; scrimId: number; + /** + * Viewer locale, threaded in because the tab components are cached + * ("use cache") and can't read the LOCALE cookie themselves. + */ + locale: Locale; team1Color: string; team2Color: string; tempoChartEnabled: boolean; positionalDataEnabled: boolean; + coachingCanvasEnabled: boolean; matchStory: MatchStoryResult | null; - noteContent: string; vod: string; }; @@ -36,29 +41,43 @@ type ActiveMapTabProps = { * server component is rendered (and only it fetches its data), so the map page * no longer pays for every tab's queries on each load. Switching tabs is a * shallow:false navigation that re-renders this with a new `activeTab`. + * + * The tab components are cached server components — flags and locale come in + * as props (request APIs are forbidden inside "use cache"), which also lets + * runtime prefetches resolve a tab's content before the user clicks it. The + * notes tab is the exception: its content is user-editable, so it reads fresh. */ export async function ActiveMapTab({ activeTab, id, mapDataId, scrimId, + locale, team1Color, team2Color, tempoChartEnabled, positionalDataEnabled, + coachingCanvasEnabled, matchStory, - noteContent, vod, }: ActiveMapTabProps) { switch (activeTab) { case "killfeed": return ( - <Killfeed id={id} team1Color={team1Color} team2Color={team2Color} /> + <Killfeed + id={id} + locale={locale} + team1Color={team1Color} + team2Color={team2Color} + positionalDataEnabled={positionalDataEnabled} + coachingCanvasEnabled={coachingCanvasEnabled} + /> ); case "charts": return ( <MapCharts id={id} + locale={locale} team1Color={team1Color} team2Color={team2Color} tempoChartEnabled={tempoChartEnabled} @@ -74,15 +93,16 @@ export async function ActiveMapTab({ /> ) : null; case "heatmap": - return <HeatmapTab id={mapDataId} />; + return <HeatmapTab id={mapDataId} mapId={id} locale={locale} />; case "replay": - return <ReplayTab id={mapDataId} />; + return <ReplayTab id={mapDataId} mapId={id} locale={locale} />; case "routes": - return <RoutesTab id={mapDataId} />; + return <RoutesTab id={mapDataId} mapId={id} locale={locale} />; case "events": return ( <MapEvents id={id} + locale={locale} team1Color={team1Color} team2Color={team2Color} includePositional={positionalDataEnabled} @@ -91,7 +111,8 @@ export async function ActiveMapTab({ case "initiation": { // Only computed when the initiation tab is open — it is one of the // heavier reads and is rarely the landing tab. - const fightInitiation = await getFightInitiationForMapData( + const fightInitiation = await getCachedFightInitiation( + id, mapDataId ).catch( () => @@ -105,15 +126,22 @@ export async function ActiveMapTab({ return <FightInitiationInspector result={fightInitiation} />; } case "compare": - return <ComparePlayers id={id} />; - case "notes": + return <ComparePlayers id={id} locale={locale} />; + case "notes": { + // Notes are user-editable, so this stays an uncached request-time read; + // the notes tab streams in rather than resolving in a runtime prefetch. + const note = await prisma.note.findFirst({ + where: { scrimId, MapDataId: mapDataId }, + select: { content: true }, + }); return ( <TipTap - noteContent={noteContent} + noteContent={note?.content ?? ""} mapDataId={mapDataId} scrimId={scrimId} /> ); + } case "vods": return <VodOverview vod={vod} mapId={id} />; case "overview": @@ -121,6 +149,7 @@ export async function ActiveMapTab({ return ( <DefaultOverview id={id} + locale={locale} team1Color={team1Color} team2Color={team2Color} positionalDataOverride={positionalDataEnabled} diff --git a/apps/web/src/components/map/compare-players.tsx b/apps/web/src/components/map/compare-players.tsx index fcbd2cf45..82d301b51 100644 --- a/apps/web/src/components/map/compare-players.tsx +++ b/apps/web/src/components/map/compare-players.tsx @@ -1,9 +1,12 @@ import { PlayerCard } from "@/components/map/player-card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { Locale } from "@/i18n/config"; +import { mapTag } from "@/lib/cache-tags"; import { resolveMapDataId } from "@/lib/map-data-resolver"; +import { getLocaleTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import { type HeroName, heroRoleMapping } from "@/types/heroes"; -import { getTranslations } from "next-intl/server"; +import { cacheLife, cacheTag } from "next/cache"; type PlayerToSort = { player_name: string; @@ -37,10 +40,12 @@ function uniquePlayers(players: PlayerToSort[]) { function TeamColumn({ id, + locale, teamLabel, players, }: { id: number; + locale: Locale; teamLabel: string; players: string[]; }) { @@ -61,7 +66,7 @@ function TeamColumn({ </TabsList> {players.map((player, index) => ( <TabsContent key={player} value={index.toString()}> - <PlayerCard id={id} playerName={player} /> + <PlayerCard id={id} playerName={player} locale={locale} /> </TabsContent> ))} </Tabs> @@ -69,8 +74,18 @@ function TeamColumn({ ); } -export async function ComparePlayers({ id }: { id: number }) { - const t = await getTranslations("mapPage.compare"); +export async function ComparePlayers({ + id, + locale, +}: { + id: number; + locale: Locale; +}) { + "use cache"; + cacheLife("hours"); + cacheTag(mapTag(id)); + + const t = await getLocaleTranslations(locale, "mapPage.compare"); const mapDataId = await resolveMapDataId(id); const teamNames = await prisma.matchStart.findFirst({ @@ -104,11 +119,13 @@ export async function ComparePlayers({ id }: { id: number }) { <div className="grid gap-8 lg:grid-cols-2"> <TeamColumn id={id} + locale={locale} teamLabel={teamNames?.team_1_name ?? t("team1")} players={team1PlayersUnique} /> <TeamColumn id={id} + locale={locale} teamLabel={teamNames?.team_2_name ?? t("team2")} players={team2PlayersUnique} /> diff --git a/apps/web/src/components/map/default-overview.tsx b/apps/web/src/components/map/default-overview.tsx index 6a46ab42d..af7337c12 100644 --- a/apps/web/src/components/map/default-overview.tsx +++ b/apps/web/src/components/map/default-overview.tsx @@ -14,8 +14,6 @@ import { buildPlayerUltComparisons, } from "@/data/scrim/ult-helpers"; import type { PlayerUltSummary, UltEfficiency } from "@/data/scrim/types"; -import { positionalData } from "@/lib/flags"; -import { getFlag } from "@/lib/flags-helpers"; import { Separator } from "@/components/ui/separator"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { filterUtilityRoundStartSwaps } from "@/data/team/hero-swap-service"; @@ -44,19 +42,28 @@ import { type SubroleName, } from "@/types/heroes"; import { $Enums, type Kill } from "@/generated/prisma/browser"; -import { getTranslations } from "next-intl/server"; +import type { Locale } from "@/i18n/config"; +import { mapTag } from "@/lib/cache-tags"; +import { getLocaleTranslations } from "@/lib/metadata-i18n"; +import { cacheLife, cacheTag } from "next/cache"; export async function DefaultOverview({ id, + locale, team1Color: team1, team2Color: team2, positionalDataOverride, }: { id: number; + locale: Locale; team1Color: string; team2Color: string; - positionalDataOverride?: boolean; + positionalDataOverride: boolean; }) { + "use cache"; + cacheLife("hours"); + cacheTag(mapTag(id)); + const mapDataId = await resolveMapDataId(id); const [finalRound, matchDetails, finalRoundStats, playerStats, fights] = await Promise.all([ @@ -140,7 +147,7 @@ export async function DefaultOverview({ }), ]); - const t = await getTranslations("mapPage.overview"); + const t = await getLocaleTranslations(locale, "mapPage.overview"); const mapType = matchDetails ? matchDetails.map_type : $Enums.MapType.Control; const payloadMapScore = calculatePayloadMapScore({ @@ -255,8 +262,7 @@ export async function DefaultOverview({ const team1Name = matchDetails?.team_1_name ?? t("team1"); const team2Name = matchDetails?.team_2_name ?? t("team2"); - const positionalEnabled = - positionalDataOverride ?? (await getFlag(positionalData)); + const positionalEnabled = positionalDataOverride; const [abilityTimingAnalysis, rotationDeathAnalysis, killfeedCalibration] = await Promise.all([ diff --git a/apps/web/src/components/map/heatmap/heatmap-tab.tsx b/apps/web/src/components/map/heatmap/heatmap-tab.tsx index 84ce1c1b8..e46df1649 100644 --- a/apps/web/src/components/map/heatmap/heatmap-tab.tsx +++ b/apps/web/src/components/map/heatmap/heatmap-tab.tsx @@ -1,16 +1,36 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { HeatmapService } from "@/data/map"; -import { getTranslations } from "next-intl/server"; +import type { Locale } from "@/i18n/config"; +import { mapTag } from "@/lib/cache-tags"; +import { getLocaleTranslations } from "@/lib/metadata-i18n"; +import { cacheLife, cacheTag } from "next/cache"; import { HeatmapCanvas } from "./heatmap-canvas"; import { HeatmapControlTabs } from "./heatmap-control-tabs"; -export async function HeatmapTab({ id }: { id: number }) { +export async function HeatmapTab({ + id, + mapId, + locale, +}: { + /** The MapData id used to load heatmap data. */ + id: number; + /** The Map id, used only for cache tagging/invalidation. */ + mapId: number; + locale: Locale; +}) { + "use cache"; + // The rendered output embeds a presigned image URL that expires in 3600s + // (loadCalibration) — cap the entry's absolute age well under that so a + // cached render can never serve a dead URL. + cacheLife({ stale: 300, revalidate: 900, expire: 1800 }); + cacheTag(mapTag(mapId)); + const [data, t] = await Promise.all([ AppRuntime.runPromise( HeatmapService.pipe(Effect.flatMap((svc) => svc.getHeatmapData(id))) ), - getTranslations("mapPage.heatmap"), + getLocaleTranslations(locale, "mapPage.heatmap"), ]); const labels = { diff --git a/apps/web/src/components/map/killfeed.tsx b/apps/web/src/components/map/killfeed.tsx index 363e4c3b4..28e18f468 100644 --- a/apps/web/src/components/map/killfeed.tsx +++ b/apps/web/src/components/map/killfeed.tsx @@ -8,36 +8,36 @@ import { KillfeedCalibrationService, serializeCalibrationData, } from "@/data/map"; -import { coachingCanvas, positionalData } from "@/lib/flags"; -import { getFlag } from "@/lib/flags-helpers"; +import type { Locale } from "@/i18n/config"; +import { mapTag } from "@/lib/cache-tags"; +import { getLocaleTranslations } from "@/lib/metadata-i18n"; import { resolveMapDataId } from "@/lib/map-data-resolver"; import prisma from "@/lib/prisma"; import { removeDuplicateRows, toTimestamp } from "@/lib/utils"; import { groupKillsIntoFights } from "@/lib/server-utils"; -import { getTranslations } from "next-intl/server"; +import { cacheLife, cacheTag } from "next/cache"; export async function Killfeed({ id, + locale, team1Color, team2Color, - positionalDataOverride, - coachingCanvasOverride, + positionalDataEnabled, + coachingCanvasEnabled, }: { id: number; + locale: Locale; team1Color: string; team2Color: string; - positionalDataOverride?: boolean; - coachingCanvasOverride?: boolean; + positionalDataEnabled: boolean; + coachingCanvasEnabled: boolean; }) { + "use cache"; + cacheLife("hours"); + cacheTag(mapTag(id)); + const mapDataId = await resolveMapDataId(id); - const [ - roundEndRows, - playerTeams, - fights, - ultimateData, - positionalEnabled, - canvasEnabled, - ] = await Promise.all([ + const [roundEndRows, playerTeams, fights, ultimateData] = await Promise.all([ prisma.roundEnd.findMany({ where: { MapDataId: mapDataId }, orderBy: { match_time: "asc" }, @@ -47,15 +47,9 @@ export async function Killfeed({ AppRuntime.runPromise( KillfeedService.pipe(Effect.flatMap((svc) => svc.getUltimateSpans(id))) ), - positionalDataOverride != null - ? Promise.resolve(positionalDataOverride) - : getFlag(positionalData), - coachingCanvasOverride != null - ? Promise.resolve(coachingCanvasOverride) - : getFlag(coachingCanvas), ]); - const calibrationData = positionalEnabled + const calibrationData = positionalDataEnabled ? serializeCalibrationData( await AppRuntime.runPromise( KillfeedCalibrationService.pipe( @@ -68,7 +62,7 @@ export async function Killfeed({ const roundEnds = removeDuplicateRows(roundEndRows); const finalRound = roundEnds.at(-1) ?? null; - const t = await getTranslations("mapPage.killfeed"); + const t = await getLocaleTranslations(locale, "mapPage.killfeed"); const team1Name = playerTeams?.team_1_name ?? t("team1"); const team2Name = playerTeams?.team_2_name ?? t("team2"); @@ -163,7 +157,7 @@ export async function Killfeed({ team1Color={team1Color} team2Color={team2Color} calibrationData={calibrationData} - canvasImportEnabled={canvasEnabled && positionalEnabled} + canvasImportEnabled={coachingCanvasEnabled && positionalDataEnabled} mapDataId={mapDataId} /> </div> diff --git a/apps/web/src/components/map/map-events.tsx b/apps/web/src/components/map/map-events.tsx index bf928fe6d..c0cd91207 100644 --- a/apps/web/src/components/map/map-events.tsx +++ b/apps/web/src/components/map/map-events.tsx @@ -1,20 +1,29 @@ import { EventsTimeline } from "@/components/map/events/events-timeline"; +import type { Locale } from "@/i18n/config"; +import { mapTag } from "@/lib/cache-tags"; import { getMapEventsData } from "@/lib/get-map-events"; -import { getTranslations } from "next-intl/server"; +import { getLocaleTranslations } from "@/lib/metadata-i18n"; +import { cacheLife, cacheTag } from "next/cache"; export async function MapEvents({ id, + locale, team1Color, team2Color, includePositional = false, }: { id: number; + locale: Locale; team1Color: string; team2Color: string; includePositional?: boolean; }) { + "use cache"; + cacheLife("hours"); + cacheTag(mapTag(id)); + const data = await getMapEventsData(id, includePositional); - const t = await getTranslations("mapPage.events"); + const t = await getLocaleTranslations(locale, "mapPage.events"); if (!data) { return ( diff --git a/apps/web/src/components/map/map-tabs.tsx b/apps/web/src/components/map/map-tabs.tsx index 7b4236785..29c376930 100644 --- a/apps/web/src/components/map/map-tabs.tsx +++ b/apps/web/src/components/map/map-tabs.tsx @@ -2,8 +2,14 @@ import { DotMatrixLoader } from "@/components/dot-matrix-loader"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + prefetchRoute, + usePredictivePrefetch, +} from "@/hooks/use-predictive-prefetch"; import { cn } from "@/lib/utils"; +import type { Route } from "next"; import { useTranslations } from "next-intl"; +import { usePathname, useRouter } from "next/navigation"; import { parseAsFloat, parseAsString, useQueryState } from "nuqs"; import { createContext, @@ -76,6 +82,31 @@ export function MapTabs({ tabs, activeTab, children }: MapTabsProps) { [setTab, setOptimisticTab] ); + // Prefetch tabs before the click. The map page sets + // `prefetch = 'allow-runtime'`, so a kind:"full" prefetch lets the server + // prerender the target tab's (cached) content ahead of the navigation. + // Hover/focus warms the hovered trigger; the trajectory hook below warms + // triggers the cursor is heading toward. + const pathname = usePathname(); + const router = useRouter(); + const listRef = useRef<HTMLDivElement>(null); + usePredictivePrefetch(listRef, { prefetchKind: "full" }); + const tabHref = useCallback( + (value: string) => `${pathname}?tab=${value}` as Route, + [pathname] + ); + const prefetchedTabs = useRef(new Set<string>()); + const prefetchTab = useCallback( + (value: string) => { + // Visited tabs render instantly from the client-side cache; skip them. + if (value === activeTab || visitedTabs.current.has(value)) return; + if (prefetchedTabs.current.has(value)) return; + prefetchedTabs.current.add(value); + prefetchRoute(router, tabHref(value), "full"); + }, + [activeTab, router, tabHref] + ); + // While a switch is in flight, `children` still holds the PREVIOUS tab's // content. Show the cached tree for the target tab when we have one; // otherwise keep the stale content dimmed under the loader until the new @@ -93,13 +124,16 @@ export function MapTabs({ tabs, activeTab, children }: MapTabsProps) { onValueChange={switchTab} className="space-y-4" > - <TabsList aria-label={t("tabsLabel")}> + <TabsList ref={listRef} aria-label={t("tabsLabel")}> {tabs.map((tab) => tab.hidden ? null : ( <TabsTrigger key={tab.value} value={tab.value} className={tab.className} + data-prefetch-href={tabHref(tab.value)} + onPointerEnter={() => prefetchTab(tab.value)} + onFocus={() => prefetchTab(tab.value)} > {tab.shortLabel ? ( <> diff --git a/apps/web/src/components/map/player-card.tsx b/apps/web/src/components/map/player-card.tsx index c6d6c8e62..ffdc48ba0 100644 --- a/apps/web/src/components/map/player-card.tsx +++ b/apps/web/src/components/map/player-card.tsx @@ -4,14 +4,17 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ScrimService } from "@/data/scrim"; import { AppRuntime } from "@/data/runtime"; import { Effect } from "effect"; -import { getHeroNames, toHero } from "@/lib/utils"; -import { getTranslations } from "next-intl/server"; +import type { Locale } from "@/i18n/config"; +import { getLocaleTranslations } from "@/lib/metadata-i18n"; +import { toHero } from "@/lib/utils"; -type Props = { id: number; playerName: string }; +type Props = { id: number; playerName: string; locale: Locale }; -export async function PlayerCard({ playerName, id }: Props) { - const t = await getTranslations("mapPage.compare.playerCard"); - const heroNames = await getHeroNames(); +export async function PlayerCard({ playerName, id, locale }: Props) { + const [t, heroesT] = await Promise.all([ + getLocaleTranslations(locale, "mapPage.compare.playerCard"), + getLocaleTranslations(locale, "heroes"), + ]); const playerStatsByFinalRound = await AppRuntime.runPromise( ScrimService.pipe( @@ -30,7 +33,13 @@ export async function PlayerCard({ playerName, id }: Props) { if (heroesPlayed.length === 0) return null; if (heroesPlayed.length === 1) { - return <SpecificHero playerStats={playerStats} showTable={false} />; + return ( + <SpecificHero + playerStats={playerStats} + showTable={false} + locale={locale} + /> + ); } return ( @@ -39,12 +48,16 @@ export async function PlayerCard({ playerName, id }: Props) { <TabsTrigger value="all-heroes">{t("allHeroes.title")}</TabsTrigger> {heroesPlayed.map((hero) => ( <TabsTrigger key={hero} value={hero}> - {heroNames.get(toHero(hero)) ?? hero} + {heroesT.has(toHero(hero)) ? heroesT(toHero(hero)) : hero} </TabsTrigger> ))} </TabsList> <TabsContent value="all-heroes" className="space-y-4"> - <AllHeroes playerStats={playerStats} showTable={false} /> + <AllHeroes + playerStats={playerStats} + showTable={false} + locale={locale} + /> </TabsContent> {heroesPlayed.map((hero) => ( <TabsContent key={hero} value={hero} className="space-y-4"> @@ -53,6 +66,7 @@ export async function PlayerCard({ playerName, id }: Props) { (stat) => stat.player_hero === hero )} showTable={false} + locale={locale} /> </TabsContent> ))} diff --git a/apps/web/src/components/map/replay/replay-tab.tsx b/apps/web/src/components/map/replay/replay-tab.tsx index b01d30cd4..c31eaa38b 100644 --- a/apps/web/src/components/map/replay/replay-tab.tsx +++ b/apps/web/src/components/map/replay/replay-tab.tsx @@ -1,16 +1,33 @@ import { ReplayService } from "@/data/map"; import { AppRuntime } from "@/data/runtime"; +import type { Locale } from "@/i18n/config"; +import { mapTag } from "@/lib/cache-tags"; +import { getLocaleTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import { Effect } from "effect"; -import { getTranslations } from "next-intl/server"; +import { cacheLife, cacheTag } from "next/cache"; import { ReplayViewer } from "./replay-viewer"; -export async function ReplayTab({ id }: { id: number }) { +export async function ReplayTab({ + id, + mapId, + locale, +}: { + /** The MapData id used to load replay data. */ + id: number; + /** The Map id, used only for cache tagging/invalidation. */ + mapId: number; + locale: Locale; +}) { + "use cache"; + cacheLife("hours"); + cacheTag(mapTag(mapId)); + const [data, t] = await Promise.all([ AppRuntime.runPromise( ReplayService.pipe(Effect.flatMap((svc) => svc.getReplayData(id))) ), - getTranslations("mapPage.replay"), + getLocaleTranslations(locale, "mapPage.replay"), ]); if (data.type === "no_calibration") { diff --git a/apps/web/src/components/map/routes/routes-tab.tsx b/apps/web/src/components/map/routes/routes-tab.tsx index 2b0dd7b05..ccf6599a1 100644 --- a/apps/web/src/components/map/routes/routes-tab.tsx +++ b/apps/web/src/components/map/routes/routes-tab.tsx @@ -1,19 +1,39 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { RouteMiningService } from "@/data/map/route-mining-service"; +import type { Locale } from "@/i18n/config"; +import { mapTag } from "@/lib/cache-tags"; import { loadCalibration } from "@/lib/map-calibration/load-calibration"; +import { getLocaleTranslations } from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; -import { getTranslations } from "next-intl/server"; +import { cacheLife, cacheTag } from "next/cache"; import { RoutesView } from "./routes-view"; import { RoutesControlTabs } from "./routes-control-tabs"; import { RoutesEmptyState } from "./empty-state"; -export async function RoutesTab({ id }: { id: number }) { +export async function RoutesTab({ + id, + mapId, + locale, +}: { + /** The MapData id used to load route analysis. */ + id: number; + /** The Map id, used only for cache tagging/invalidation. */ + mapId: number; + locale: Locale; +}) { + "use cache"; + // The rendered output embeds a presigned image URL that expires in 3600s + // (loadCalibration) — cap the entry's absolute age well under that so a + // cached render can never serve a dead URL. + cacheLife({ stale: 300, revalidate: 900, expire: 1800 }); + cacheTag(mapTag(mapId)); + const [result, t] = await Promise.all([ AppRuntime.runPromise( RouteMiningService.pipe(Effect.flatMap((svc) => svc.getRouteAnalysis(id))) ), - getTranslations("mapPage.routes"), + getLocaleTranslations(locale, "mapPage.routes"), ]); if (result === null) { diff --git a/apps/web/src/components/map/tempo/tempo-chart-server.tsx b/apps/web/src/components/map/tempo/tempo-chart-server.tsx index 881f303a7..5aa8f1dc4 100644 --- a/apps/web/src/components/map/tempo/tempo-chart-server.tsx +++ b/apps/web/src/components/map/tempo/tempo-chart-server.tsx @@ -1,17 +1,20 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { TempoService } from "@/data/map"; -import { getTranslations } from "next-intl/server"; +import type { Locale } from "@/i18n/config"; +import { getLocaleTranslations } from "@/lib/metadata-i18n"; import { TempoChart } from "./tempo-chart"; type TempoChartServerProps = { id: number; + locale: Locale; team1Color: string; team2Color: string; }; export async function TempoChartServer({ id, + locale, team1Color, team2Color, }: TempoChartServerProps) { @@ -19,7 +22,7 @@ export async function TempoChartServer({ AppRuntime.runPromise( TempoService.pipe(Effect.flatMap((svc) => svc.getTempoChartData(id))) ), - getTranslations("mapPage.events.tempo"), + getLocaleTranslations(locale, "mapPage.events.tempo"), ]); if (!data) { diff --git a/apps/web/src/components/player/all-heroes.tsx b/apps/web/src/components/player/all-heroes.tsx index d610b2dea..7cb22e928 100644 --- a/apps/web/src/components/player/all-heroes.tsx +++ b/apps/web/src/components/player/all-heroes.tsx @@ -5,6 +5,8 @@ import { } from "@/components/player/stat-panel"; import { StatsTable } from "@/components/player/stats-table"; import { CardIcon } from "@/components/ui/card-icon"; +import type { Locale } from "@/i18n/config"; +import { getLocaleTranslations } from "@/lib/metadata-i18n"; import { cn, round, toMins } from "@/lib/utils"; import { type HeroName, heroRoleMapping } from "@/types/heroes"; import type { PlayerStat } from "@/generated/prisma/client"; @@ -13,11 +15,23 @@ import { getTranslations } from "next-intl/server"; export async function AllHeroes({ playerStats, showTable = true, + locale, }: { playerStats: PlayerStat[]; showTable?: boolean; + /** + * Required when rendered inside a "use cache" scope (e.g. via the map + * compare tab) — `getTranslations` reads the LOCALE cookie, which is + * forbidden there. Uncached request-time call sites may omit it. + */ + locale?: Locale; }) { - const t = await getTranslations("mapPage.compare.playerCard.allHeroes"); + const t = locale + ? await getLocaleTranslations( + locale, + "mapPage.compare.playerCard.allHeroes" + ) + : await getTranslations("mapPage.compare.playerCard.allHeroes"); const hero = playerStats[0].player_hero as HeroName; const role = heroRoleMapping[hero]; diff --git a/apps/web/src/components/player/specific-hero.tsx b/apps/web/src/components/player/specific-hero.tsx index d4f0a5f2a..1341234fb 100644 --- a/apps/web/src/components/player/specific-hero.tsx +++ b/apps/web/src/components/player/specific-hero.tsx @@ -12,7 +12,9 @@ import { getMultipleStatComparisons, type StatCardComparison, } from "@/lib/stat-card-helpers"; +import type { Locale } from "@/i18n/config"; import type { ValidStatColumn } from "@/lib/stat-percentiles"; +import { getLocaleTranslations } from "@/lib/metadata-i18n"; import { cn, getHeroNames, round, toHero, toMins } from "@/lib/utils"; import { type HeroName, heroRoleMapping } from "@/types/heroes"; import type { PlayerStat } from "@/generated/prisma/client"; @@ -21,17 +23,37 @@ import { getTranslations } from "next-intl/server"; export async function SpecificHero({ playerStats, showTable = true, + locale, }: { playerStats: PlayerStat[]; showTable?: boolean; + /** + * Required when rendered inside a "use cache" scope (e.g. via the map + * compare tab) — `getTranslations`/`getHeroNames` read the LOCALE cookie, + * which is forbidden there. Uncached request-time call sites may omit it. + */ + locale?: Locale; }) { - const t = await getTranslations("mapPage.compare.playerCard.specificHero"); - const heroNames = await getHeroNames(); + const t = locale + ? await getLocaleTranslations( + locale, + "mapPage.compare.playerCard.specificHero" + ) + : await getTranslations("mapPage.compare.playerCard.specificHero"); const hero = playerStats[0].player_hero as HeroName; const playerStat = playerStats[0]; const role = heroRoleMapping[hero]; - const heroDisplayName = heroNames.get(toHero(hero)) ?? hero; + + const heroKey = toHero(hero); + let heroDisplayName: string; + if (locale) { + const heroesT = await getLocaleTranslations(locale, "heroes"); + heroDisplayName = heroesT.has(heroKey) ? heroesT(heroKey) : hero; + } else { + const heroNames = await getHeroNames(); + heroDisplayName = heroNames.get(heroKey) ?? hero; + } const statsToCompare: { stat: ValidStatColumn; value: number }[] = [ { stat: "eliminations", value: playerStat.eliminations }, diff --git a/apps/web/src/data/cached/map-cache.ts b/apps/web/src/data/cached/map-cache.ts index 234816c91..e905c4ba3 100644 --- a/apps/web/src/data/cached/map-cache.ts +++ b/apps/web/src/data/cached/map-cache.ts @@ -3,7 +3,9 @@ import "server-only"; import { MatchStoryService } from "@/data/map/match-story-service"; import { PlayerService } from "@/data/player"; import { AppRuntime } from "@/data/runtime"; -import { mapTag } from "@/lib/cache-tags"; +import { mapTag, scrimTag } from "@/lib/cache-tags"; +import { getFightInitiationForMapData } from "@/lib/fight-initiation"; +import prisma from "@/lib/prisma"; import { Effect } from "effect"; import { cacheLife, cacheTag } from "next/cache"; @@ -33,3 +35,71 @@ export async function getCachedMatchStory(mapId: number, mapDataId: number) { ) ); } + +/** Map name + team name shown in the map page frame. Immutable after upload. */ +export async function getCachedMapDetails(mapId: number, mapDataId: number) { + "use cache"; + cacheLife("max"); + cacheTag(mapTag(mapId)); + return prisma.matchStart.findFirst({ + where: { MapDataId: mapDataId }, + select: { map_name: true, team_1_name: true }, + orderBy: { id: "asc" }, + }); +} + +/** + * Replay code + VOD link for the map page frame. Both are user-editable: + * replayCode edits go through update-scrim-options (revalidates the scrim + * tag) and VOD edits through /api/vod (revalidates the map tag). + */ +export async function getCachedMapRow(scrimId: number, mapId: number) { + "use cache"; + cacheLife("max"); + cacheTag(mapTag(mapId)); + cacheTag(scrimTag(scrimId)); + return prisma.map.findFirst({ + where: { id: mapId }, + select: { replayCode: true, vod: true }, + }); +} + +/** Hero bans for the map header. Immutable after upload. */ +export async function getCachedHeroBans(mapId: number, mapDataId: number) { + "use cache"; + cacheLife("max"); + cacheTag(mapTag(mapId)); + return prisma.heroBan.findMany({ + where: { MapDataId: mapDataId }, + orderBy: { id: "asc" }, + }); +} + +/** + * Guest-mode visibility for the map page frame. Toggled via + * update-scrim-options, which revalidates the scrim tag. + */ +export async function getCachedScrimVisibility(scrimId: number) { + "use cache"; + cacheLife("max"); + cacheTag(scrimTag(scrimId)); + return prisma.scrim.findFirst({ + where: { id: scrimId }, + select: { guestMode: true }, + }); +} + +/** + * Fight-initiation analysis for the initiation tab. Derived purely from + * immutable match events; failures degrade to "not available" so a bad log + * never breaks the tab (and errors are not cached). + */ +export async function getCachedFightInitiation( + mapId: number, + mapDataId: number +) { + "use cache"; + cacheLife("hours"); + cacheTag(mapTag(mapId)); + return getFightInitiationForMapData(mapDataId); +} diff --git a/apps/web/src/data/cached/map-viewer.ts b/apps/web/src/data/cached/map-viewer.ts new file mode 100644 index 000000000..eba56f7e8 --- /dev/null +++ b/apps/web/src/data/cached/map-viewer.ts @@ -0,0 +1,52 @@ +import "server-only"; + +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { auth, isAuthedToViewMap, type Session } from "@/lib/auth"; +import { getColorblindMode } from "@/lib/server-utils"; +import type { User } from "@/generated/prisma/browser"; +import { Effect } from "effect"; +import { cacheLife } from "next/cache"; + +export type MapViewerContext = + | { canView: false } + | { + canView: true; + session: Session | null; + user: User | null; + team1Color: string; + team2Color: string; + }; + +/** + * The per-viewer request work for a map page render — access gate, session, + * user row, and colorblind palette — in one `"use cache: private"` scope. + * + * Private caching serves two purposes: it lets Next's runtime prefetch + * (`prefetch = 'allow-runtime'` on the map page) execute this and everything + * behind it ahead of a tab click (uncached session/DB reads would otherwise + * abort the prefetch at the surrounding Suspense boundary), and it lets the + * browser reuse the result across tab navigations within the stale window. + * Results live only in the browser's memory, never on the server. + */ +export async function getMapViewerContext( + scrimId: number, + mapId: number +): Promise<MapViewerContext> { + "use cache: private"; + // stale >= 30s is required for runtime prefetching; "minutes" keeps access + // revocation lag bounded at the profile's 5-minute client stale window. + cacheLife("minutes"); + + if (!(await isAuthedToViewMap(scrimId, mapId))) { + return { canView: false }; + } + + const session = await auth(); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); + const { team1, team2 } = await getColorblindMode(user?.id ?? ""); + + return { canView: true, session, user, team1Color: team1, team2Color: team2 }; +} diff --git a/apps/web/src/hooks/use-predictive-prefetch.ts b/apps/web/src/hooks/use-predictive-prefetch.ts index 00d854b1f..5789e5391 100644 --- a/apps/web/src/hooks/use-predictive-prefetch.ts +++ b/apps/web/src/hooks/use-predictive-prefetch.ts @@ -7,7 +7,6 @@ import { type Sample, type Vec, } from "@/lib/predictive-prefetch"; -import type { Route } from "next"; import { useRouter } from "next/navigation"; import type { RefObject } from "react"; import { useEffect, useRef } from "react"; @@ -36,6 +35,12 @@ export type PredictivePrefetchOptions = { coneAngleDeg?: number; minSpeed?: number; enabled?: boolean; + /** + * "auto" (default) prefetches the App Shell; "full" issues a runtime + * prefetch that includes request data — only useful when the destination + * route sets `prefetch = 'allow-runtime'`. + */ + prefetchKind?: "auto" | "full"; /** * When provided, called each animation frame with the full detection state. * Used only by the debug overlay; absent in normal use (zero overhead). @@ -43,6 +48,27 @@ export type PredictivePrefetchOptions = { onFrame?: (frame: PredictivePrefetchDebugFrame) => void; }; +/** + * Prefetch a route with an explicit kind. `kind: "full"` issues a runtime + * prefetch (request data included) when the destination route sets + * `prefetch = 'allow-runtime'`. Centralized here because `typedRoutes` + * narrows the router's `prefetch` signature to `(href)` even though the + * runtime accepts an options argument, and the `PrefetchKind` enum lives in + * next's internals (its FULL value is the string "full"). + */ +export function prefetchRoute( + router: ReturnType<typeof useRouter>, + href: string, + kind: "auto" | "full" +): void { + ( + router.prefetch as unknown as ( + href: string, + options?: { kind: "auto" | "full" } + ) => void + )(href, kind === "full" ? { kind } : undefined); +} + // Rolling sample window for the velocity estimate. const SAMPLE_WINDOW_MS = 50; const MAX_SAMPLES = 6; @@ -62,6 +88,7 @@ export function usePredictivePrefetch( coneAngleDeg = 30, minSpeed = 0.15, enabled = true, + prefetchKind = "auto", onFrame, } = options; @@ -93,9 +120,11 @@ export function usePredictivePrefetch( if (!container || !cursor) return; const velocity = estimateVelocity(samples); + // Anchors plus non-link targets (e.g. tab triggers) that opt in via + // data-prefetch-href. const anchors = Array.from( - container.querySelectorAll<HTMLAnchorElement>( - 'a[href^="/"]:not([href^="//"])' + container.querySelectorAll<HTMLElement>( + 'a[href^="/"]:not([href^="//"]), [data-prefetch-href^="/"]:not([data-prefetch-href^="//"])' ) ); @@ -105,7 +134,9 @@ export function usePredictivePrefetch( : null; for (const anchor of anchors) { - const href = anchor.getAttribute("href"); + const href = + anchor.getAttribute("href") ?? + anchor.getAttribute("data-prefetch-href"); // Same-origin paths only: reject protocol-relative ("//host") and the // back-slash variant browsers normalize to it, plus in-page hashes. if ( @@ -130,7 +161,7 @@ export function usePredictivePrefetch( optsRef.current ); if (heading && !already) { - router.prefetch(href as Route); + prefetchRoute(router, href, prefetchKind); prefetched.add(href); } @@ -179,5 +210,5 @@ export function usePredictivePrefetch( window.removeEventListener("pointermove", handlePointerMove); if (frame) cancelAnimationFrame(frame); }; - }, [containerRef, router, enabled]); + }, [containerRef, router, enabled, prefetchKind]); } diff --git a/apps/web/src/lib/map-data-resolver.ts b/apps/web/src/lib/map-data-resolver.ts index 98b75b28a..474fb9708 100644 --- a/apps/web/src/lib/map-data-resolver.ts +++ b/apps/web/src/lib/map-data-resolver.ts @@ -1,10 +1,20 @@ +import { mapTag } from "@/lib/cache-tags"; import prisma from "@/lib/prisma"; +import { cacheLife, cacheTag } from "next/cache"; /** * Resolves a Map.id to its corresponding MapData.id. * URL route params use Map.id, but event tables reference MapData.id. + * + * Cached: the mapping is immutable once uploaded (re-uploads add rows, but the + * oldest row stays pinned below), and the result feeds nearly every cached map + * read — caching it also lets runtime prefetches advance past it. Invalidated + * via `map:${mapId}` on map removal. */ export async function resolveMapDataId(mapId: number): Promise<number> { + "use cache"; + cacheLife("max"); + cacheTag(mapTag(mapId)); const mapData = await prisma.mapData.findFirst({ where: { mapId }, select: { id: true }, @@ -30,12 +40,15 @@ export async function resolveMapDataId(mapId: number): Promise<number> { /** * Resolves a Map.id to MapData.id only when the map belongs to the route scrim. * Use this for request-controlled route params so MapData.id fallbacks cannot - * cross resource boundaries. + * cross resource boundaries. Cached for the same reasons as `resolveMapDataId`. */ export async function resolveScrimMapDataId( scrimId: number, mapId: number ): Promise<number> { + "use cache"; + cacheLife("max"); + cacheTag(mapTag(mapId)); const mapData = await prisma.mapData.findFirst({ where: { scrimId, mapId }, select: { id: true }, diff --git a/apps/web/src/lib/metadata-i18n.ts b/apps/web/src/lib/metadata-i18n.ts index 0f595db0a..af37e4592 100644 --- a/apps/web/src/lib/metadata-i18n.ts +++ b/apps/web/src/lib/metadata-i18n.ts @@ -1,4 +1,4 @@ -import { defaultLocale } from "@/i18n/config"; +import { defaultLocale, type Locale } from "@/i18n/config"; import { createTranslator } from "next-intl"; import enMessages from "../../messages/en.json"; @@ -32,3 +32,29 @@ export function getStaticTranslations(namespace?: string) { namespace, } as Parameters<typeof createTranslator>[0]); } + +/** + * Cookie-free translator for an EXPLICIT locale, for use inside `"use cache"` + * scopes. `getTranslations` resolves the locale from the LOCALE cookie, and + * request APIs are forbidden inside public cache scopes — so cached server + * components take the locale as a prop (making it part of the cache key) and + * translate through this instead. + */ +export async function getLocaleTranslations( + locale: Locale, + namespace?: string +) { + const messages = + locale === defaultLocale + ? enMessages + : ( + (await import(`../../messages/${locale}.json`)) as { + default: typeof enMessages; + } + ).default; + return createTranslator({ + locale, + messages, + namespace, + } as Parameters<typeof createTranslator>[0]); +}