diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 08f447d41..9aa15676f 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. @@ -8,6 +6,33 @@ The Next.js app uses `next-intl` for internationalization, with `en.json`, `ko.j Before committing, ensure that changes are formatted, linted, typechecked, and pass the test suite. +## Cache Components (Cache Components / PPR is enabled) + +`cacheComponents: true` + `partialPrefetching: true` are on. PPR prerenders every request **twice** (a cache-warming pass, then the final prerender), so four rules prevent the recurring `HANGING_PROMISE_REJECTION` (`dynamic "use cache"`) and `Unexpected cache miss after cache warming phase` errors: + +1. **`use cache` inputs must be deterministic across passes.** A cached function's arguments (and closure values) become its cache key. Only pass route params, scalar DB columns, or **fixed-order** constant arrays. Never pass: + - a raw `findFirst`/`findMany` result — add an explicit `orderBy` (an unordered query can return a different row/order between passes; e.g. `resolveScrimMapDataId`, flag `identify`'s team `idArray`). + - a `Set`→array, `Object.keys/values/entries`, or any array whose order isn't pinned. + - a `new Date()`/`Date.now()`/`Math.random()` value — quantize it (e.g. day-align, like `computeDateRange`) or don't key on it. + +2. **Reads that reach `use cache` in a layout's static-shell region must be request-time.** `@vercel/edge-config`'s `get()` and every feature flag (`@/lib/flags`) compile to `use cache`. If such a read runs outside a page's dynamic boundary (e.g. in the `Footer`/a `layout.tsx`) without already being request-time, add `await connection()` as the first line (see `AuthedAppHeader`, `Footer`, the team-stats layout). Note: awaiting `getTranslations`/`getLocale` does **not** defer — only `connection()`/`headers()`/`cookies()`/`auth()` do. + +3. **Flag reads in render code must use the precomputed code.** `proxy.ts` evaluates all `pageFlags` once per request and forwards a signed code on the `x-flags-code` request header; pages/layouts/server components MUST read flags via `getFlag`/`getAllFlags` (`@/lib/flags-helpers`), never by calling the flag function directly. A live flag call can transiently fail and silently fall back to `defaultValue` in only one of the two prerender passes — the passes then disagree about *which* `use cache` calls run, which is the classic cause of `HANGING_PROMISE_REJECTION`. Route handlers and server actions (never prerendered) still call flags live (`resolveAllFlags` or the flag itself). New flags must be appended to `pageFlags` in `src/lib/flags-precompute.ts`. + +4. **The static shell must stay request-free — providers hydrate via islands, fallbacks are sync.** The root layout's provider tree is prerendered; request-derived values (locale override, flags, session extras) stream in as Suspense islands that hydrate stateful client providers (`IntlProvider`/`FeatureFlagsProvider`/`BrandThemeProvider` + their `*Hydrator` components in `layout.tsx`). Consequences: + - `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`). + +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 ### Issue tracker @@ -21,3 +46,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/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/next.config.ts b/apps/web/next.config.ts index 17a0831a7..6881b833a 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -25,6 +25,8 @@ 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 58cf25be0..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.2.9", + "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/(auth)/auth-error/layout.tsx b/apps/web/src/app/(auth)/auth-error/layout.tsx index d96e75ceb..327562839 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"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("authError.metadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/app/(auth)/auth-error/page.tsx b/apps/web/src/app/(auth)/auth-error/page.tsx index 65c2993e4..a1059bb25 100644 --- a/apps/web/src/app/(auth)/auth-error/page.tsx +++ b/apps/web/src/app/(auth)/auth-error/page.tsx @@ -1,6 +1,9 @@ import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { getStaticTranslations } from "@/lib/metadata-i18n"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; +import { Suspense } from "react"; type Error = | "Configuration" @@ -10,9 +13,31 @@ type Error = | "AdapterError" | "Default"; -export default async function AuthErrorPage(props: PageProps<"/auth-error">) { - const searchParams = await props.searchParams; - const t = await getTranslations("authError"); +// Static shell: the page frame and heading prerender (default-locale title via +// the cookie-free translator); the searchParams-derived error message streams +// into ONE boundary whose fallback mirrors the loaded content's layout. +export default function AuthErrorPage(props: PageProps<"/auth-error">) { + const t = getStaticTranslations("authError"); + + return ( +
+

{t("title")}

+ }> + + +
+ ); +} + +async function AuthErrorContent({ + searchParams, +}: { + searchParams: PageProps<"/auth-error">["searchParams"]; +}) { + const [params, t] = await Promise.all([ + searchParams, + getTranslations("authError"), + ]); const errorMessages: Record = { Configuration: t("errors.configuration"), @@ -22,15 +47,14 @@ export default async function AuthErrorPage(props: PageProps<"/auth-error">) { AdapterError: t("errors.adapterError"), Default: t("errors.default"), }; - const rawError = searchParams.error; + const rawError = params.error; const error: Error = typeof rawError === "string" && Object.hasOwn(errorMessages, rawError) ? (rawError as Error) : "Default"; return ( -
-

{t("title")}

+ <>

{t("error")}{" "} {errorMessages[error] ?? errorMessages.Default} @@ -52,6 +76,20 @@ export default async function AuthErrorPage(props: PageProps<"/auth-error">) { {t("signIn")}

- + + ); +} + +// Mirrors the loaded content: two message lines and the button row. +function AuthErrorSkeleton() { + return ( + <> + + +
+ + +
+ ); } diff --git a/apps/web/src/app/(auth)/sign-in/page.tsx b/apps/web/src/app/(auth)/sign-in/page.tsx index c3ba4473a..dadf38786 100644 --- a/apps/web/src/app/(auth)/sign-in/page.tsx +++ b/apps/web/src/app/(auth)/sign-in/page.tsx @@ -1,23 +1,19 @@ import { UserAuthForm } from "@/components/auth/user-auth-form"; import { Link } from "@/components/ui/link"; +import { Skeleton } from "@/components/ui/skeleton"; import { auth } from "@/lib/auth"; -import type { PagePropsWithLocale } from "@/types/next"; +import { defaultLocale } from "@/i18n/config"; +import { + getMetadataTranslations, + getStaticTranslations, +} 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"; +import { Suspense } from "react"; -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 +31,7 @@ export async function generateMetadata( height: 630, }, ], - locale, + locale: defaultLocale, }, }; } @@ -66,18 +62,13 @@ function getSafeCallbackUrl(callbackUrl: string | undefined): string { return "/dashboard"; } -export default async function AuthenticationPage(props: { +// Static shell: the page frame and logo link prerender (default-locale text +// via the cookie-free translator), and the session/searchParams-derived form +// streams into ONE boundary whose fallback mirrors UserAuthForm's layout. +export default function AuthenticationPage(props: { searchParams: Promise<{ callbackUrl?: string }>; }) { - const session = await auth(); - const searchParams = await props.searchParams; - const safeCallbackUrl = getSafeCallbackUrl(searchParams.callbackUrl); - - if (session) { - redirect(safeCallbackUrl as Route); - } - - const t = await getTranslations("signInPage"); + const t = getStaticTranslations("signInPage"); return (
@@ -97,8 +88,63 @@ export default async function AuthenticationPage(props: {
{t("parsertime")} - + }> + + + + + ); +} + +async function SignInContent(props: { + searchParams: Promise<{ callbackUrl?: string }>; +}) { + const [session, searchParams] = await Promise.all([ + auth(), + props.searchParams, + ]); + const safeCallbackUrl = getSafeCallbackUrl(searchParams.callbackUrl); + + if (session) { + redirect(safeCallbackUrl as Route); + } + + return ; +} + +// Mirrors UserAuthForm's pending layout (card with centered header, three +// provider buttons, separator, email field, submit, footer links) so the +// streamed form replaces this in place with no jump. +function AuthFormSkeleton() { + return ( +
+
+
+ + +
+
+
+
+ {["discord", "google", "github"].map((k) => ( + + ))} +
+
+ +
+
+
+ + +
+ + +
+
+
+
); } diff --git a/apps/web/src/app/(auth)/sign-up/page.tsx b/apps/web/src/app/(auth)/sign-up/page.tsx index fc8794c25..62f19678b 100644 --- a/apps/web/src/app/(auth)/sign-up/page.tsx +++ b/apps/web/src/app/(auth)/sign-up/page.tsx @@ -1,21 +1,15 @@ 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, + getStaticTranslations, +} 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,13 +27,17 @@ export async function generateMetadata( height: 630, }, ], - locale, + locale: defaultLocale, }, }; } -export default async function AuthenticationPage() { - const t = await getTranslations("signInPage"); +// Fully static: the wordmark is "Parsertime" in every catalog, so the +// cookie-free translator keeps the whole page prerenderable — UserAuthForm is +// a client component that hydrates with the streamed locale like the rest of +// the shell. +export default function AuthenticationPage() { + const t = getStaticTranslations("signInPage"); return (
diff --git a/apps/web/src/app/(auth)/verify-request/layout.tsx b/apps/web/src/app/(auth)/verify-request/layout.tsx index e266d7fbf..933cd25d1 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"); +export function generateMetadata(): Metadata { + const t = getMetadataTranslations("verifyRequest.metadata"); return { title: t("title"), description: t("description") }; } diff --git a/apps/web/src/app/(auth)/verify-request/page.tsx b/apps/web/src/app/(auth)/verify-request/page.tsx index 96e654d0d..6b9d43571 100644 --- a/apps/web/src/app/(auth)/verify-request/page.tsx +++ b/apps/web/src/app/(auth)/verify-request/page.tsx @@ -1,12 +1,24 @@ import { Button } from "@/components/ui/button"; 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 VerifyRequestPage() { +export default function VerifyRequestPage() { + return ( +
+ }> + + +
+ ); +} + +async function VerifyRequestContent() { const t = await getTranslations("verifyRequest"); return ( -
+ <>

{t("title")}

{t("description")} @@ -14,6 +26,18 @@ export default async function VerifyRequestPage() { -

+ + ); +} + +// Mirrors the loaded title/description/button lines so the streamed content +// replaces this in place. +function VerifyRequestSkeleton() { + return ( + <> + + + + ); } diff --git a/apps/web/src/app/[team]/compare/page.tsx b/apps/web/src/app/[team]/compare/page.tsx index 9fe5102f3..d6add3a39 100644 --- a/apps/web/src/app/[team]/compare/page.tsx +++ b/apps/web/src/app/[team]/compare/page.tsx @@ -1,24 +1,20 @@ import { ComparisonContent } from "@/components/compare/comparison-content"; import { DashboardLayout } from "@/components/dashboard-layout"; +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 { 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"; +import { Suspense } from "react"; -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"), @@ -26,10 +22,28 @@ export async function generateMetadata( }; } -export default async function ComparePage( +// Static shell: the DashboardLayout chrome prerenders and the auth-gated +// comparison content streams into ONE boundary whose fallback mirrors +// ComparisonContent's own layout, so navigation shows a single stable +// skeleton instead of a cascade of different loading states. +export default function ComparePage( props: PagePropsWithLocale<"/[team]/compare"> ) { - const params = await props.params; + return ( + + }> + + + + ); +} + +async function CompareContent({ + params, +}: { + params: PagePropsWithLocale<"/[team]/compare">["params"]; +}) { + const { team, locale } = await params; const session = await auth(); if (!session?.user?.email) { @@ -44,13 +58,13 @@ export default async function ComparePage( } // Extract team ID from team slug - const teamId = parseInt(params.team); + const teamId = parseInt(team); if (isNaN(teamId)) { notFound(); } // Verify user has access to this team - const team = await prisma.team.findUnique({ + const dbTeam = await prisma.team.findUnique({ where: { id: teamId }, include: { users: true, @@ -59,14 +73,96 @@ export default async function ComparePage( if ( user.role !== $Enums.UserRole.ADMIN && - (!team || !team.users.some((teamUser) => teamUser.id === user.id)) + (!dbTeam || !dbTeam.users.some((teamUser) => teamUser.id === user.id)) ) { notFound(); } + return ; +} + +const FOUR = ["a", "b", "c", "d"]; +const SIX = ["a", "b", "c", "d", "e", "f"]; + +// Mirrors ComparisonContent's pending layout (heading, map-selection-mode +// card, comparison-mode card + filters, view tabs with stat grid + chart) so +// the streamed content replaces this in place with no jump. +function CompareSkeleton() { return ( - - - +
+
+ + +
+ +
+
+
+ + +
+
+ + + +
+
+
+ +
+
+
+
+ + +
+
+ + + +
+
+
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+
+ {SIX.map((k) => ( + + ))} +
+
+
+ {FOUR.map((k) => ( +
+
+ + + +
+
+ ))} +
+ +
+
+
); } diff --git a/apps/web/src/app/[team]/map-groups/page.tsx b/apps/web/src/app/[team]/map-groups/page.tsx index a8505c1c2..9b7deec3e 100644 --- a/apps/web/src/app/[team]/map-groups/page.tsx +++ b/apps/web/src/app/[team]/map-groups/page.tsx @@ -1,24 +1,21 @@ import { MapGroupManager } from "@/components/compare/map-group-manager"; import { DashboardLayout } from "@/components/dashboard-layout"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +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 { 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"; +import { Suspense } from "react"; -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"), @@ -26,10 +23,32 @@ export async function generateMetadata( }; } -export default async function MapGroupsPage( +// Static shell: the page frame and heading prerender, and the auth-derived +// content streams into ONE boundary whose fallback mirrors MapGroupManager's +// own pending layout (card frame + centered loader). +export default function MapGroupsPage( props: PagePropsWithLocale<"/[team]/map-groups"> ) { - const params = await props.params; + return ( + +
+
+

Map Groups

+
+ }> + + +
+
+ ); +} + +async function MapGroupsContent({ + params, +}: { + params: PagePropsWithLocale<"/[team]/map-groups">["params"]; +}) { + const { team: teamSlug } = await params; const session = await auth(); if (!session?.user?.email) { @@ -44,7 +63,7 @@ export default async function MapGroupsPage( } // Extract team ID from team slug - const teamId = parseInt(params.team); + const teamId = parseInt(teamSlug); if (isNaN(teamId)) { notFound(); } @@ -92,14 +111,29 @@ export default async function MapGroupsPage( scrimDate: map.Scrim!.date, })); + return ; +} + +// Mirrors MapGroupManager's first paint: the card frame with header row +// (title, description, action button) and the centered pending indicator its +// client-side query shows, so the streamed content replaces this in place. +function MapGroupsSkeleton() { return ( - -
-
-

Map Groups

+ + +
+
+ + +
+
- -
- + + +
+ +
+
+ ); } diff --git a/apps/web/src/app/[team]/ops/page.tsx b/apps/web/src/app/[team]/ops/page.tsx index a344d85be..157547e0d 100644 --- a/apps/web/src/app/[team]/ops/page.tsx +++ b/apps/web/src/app/[team]/ops/page.tsx @@ -1,37 +1,68 @@ import { BlacklistManager } from "@/components/team-ops/blacklist-manager"; import { DashboardLayout } from "@/components/dashboard-layout"; +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 { canManageTeam } from "@/lib/auth"; +import { + getMetadataTranslations, + getStaticTranslations, +} from "@/lib/metadata-i18n"; import { listBlacklist, getBlacklistSuggestions, } from "@/lib/team-ops/blacklist"; import type { PagePropsWithLocale } from "@/types/next"; 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<"/[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"), }; } -export default async function TeamOpsPage( - props: PagePropsWithLocale<"/[team]/ops"> -) { - const params = await props.params; +// Static shell: the page frame and headings prerender (default-locale text via +// the cookie-free translator), and the auth/blacklist content streams into ONE +// boundary whose fallback mirrors BlacklistManager's own pending layout. +export default function TeamOpsPage(props: PagePropsWithLocale<"/[team]/ops">) { + const t = getStaticTranslations("teamOps"); + + return ( + +
+
+
+

{t("title")}

+

+ {t("blacklist.subtitle")} +

+
+
+ + {/* Blacklist section — additional sections slot in below */} +
+

{t("blacklist.heading")}

+ }> + + +
+
+
+ ); +} + +async function TeamOpsContent({ + params, +}: { + params: PagePropsWithLocale<"/[team]/ops">["params"]; +}) { + const { team } = await params; const session = await auth(); if (!session?.user?.email) { @@ -45,7 +76,7 @@ export default async function TeamOpsPage( notFound(); } - const teamId = parseInt(params.team); + const teamId = parseInt(team); if (isNaN(teamId)) { notFound(); } @@ -59,33 +90,30 @@ export default async function TeamOpsPage( getBlacklistSuggestions(teamId), ]); - const t = await getTranslations({ - locale: params.locale, - namespace: "teamOps", - }); + return ( + + ); +} + +const SKELETON_ROWS = ["a", "b", "c", "d", "e"]; +// Mirrors BlacklistManager's pending layout (search/add field, bordered +// divided row list) so the streamed content replaces this in place. +function BlacklistSkeleton() { return ( - -
-
-
-

{t("title")}

-

- {t("blacklist.subtitle")} -

+
+ +
+ {SKELETON_ROWS.map((k) => ( +
+
+ + +
+
-
- - {/* Blacklist section — additional sections slot in below */} -
-

{t("blacklist.heading")}

- -
+ ))}
- +
); } 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..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,24 +1,32 @@ +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"; 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"; 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"; +import { + getMetadataTranslations, + getStaticTranslations, +} from "@/lib/metadata-i18n"; import prisma from "@/lib/prisma"; import type { Metadata, Route } from "next"; import { getTranslations } from "next-intl/server"; +import { Suspense } from "react"; 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))) @@ -33,14 +41,36 @@ export async function generateMetadata( }; } -export default async function EditScrimPage( +// Static shell: the page frame prerenders and the request-derived content +// (params, auth, scrim data) streams into ONE boundary whose fallback mirrors +// the form's pending layout — the back link needs route params, so it streams +// with the content. +export default function EditScrimPage( props: PageProps<"/[team]/scrim/[scrimId]/edit"> ) { - const params = await props.params; + return ( + +
+ }> + + +
+
+ ); +} + +async function EditScrimContent({ + params, +}: { + 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(params.scrimId))) - ) + ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(parseInt(scrimId)))) ); const session = await auth(); const t = await getTranslations("scrimPage.editScrim"); @@ -59,7 +89,7 @@ export default async function EditScrimPage( AppRuntime.runPromise( ScoutingService.pipe(Effect.flatMap((svc) => svc.getScoutingTeams())) ), - scoutingTool(), + getFlag(scoutingTool), ] ); @@ -99,32 +129,88 @@ export default async function EditScrimPage( })); return ( - -
-

- - ← {t("back")} - -

-
-

- {t("title")} -

+ <> +

+ + ← {t("back")} + +

+
+

+ {t("title")} +

+ + ({ + abbreviation: st.abbreviation, + fullName: st.fullName, + }))} + /> +
+ +
+ + ); +} - ({ - abbreviation: st.abbreviation, - fullName: st.fullName, - }))} - /> -
- +// Mirrors the loaded content's layout (back-link line, real heading, form +// field groups, guest-mode switch row, maps list, submit button, danger-zone +// card) so the streamed content replaces this in place with no jump. +function EditScrimSkeleton() { + const t = getStaticTranslations("scrimPage.editScrim"); + + return ( + <> +

+ +

+
+

+ {t("title")} +

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+ + +
+
+
+ + {["a", "b", "c"].map((k) => ( + + ))} +
+
-
-
+
+
+ + + + +
+
+ ); } diff --git a/apps/web/src/app/[team]/scrim/[scrimId]/layout.tsx b/apps/web/src/app/[team]/scrim/[scrimId]/layout.tsx index b89711136..82d8df2e2 100644 --- a/apps/web/src/app/[team]/scrim/[scrimId]/layout.tsx +++ b/apps/web/src/app/[team]/scrim/[scrimId]/layout.tsx @@ -1,20 +1,13 @@ -import { NoAuthCard } from "@/components/auth/no-auth"; import { SelectedPlayerProvider } from "@/components/map/player-switcher"; -import { isAuthedToViewScrim } from "@/lib/auth"; -export default async function ScrimDashboardLayout( +// 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]"> ) { - const params = await props.params; - - const { children } = props; - - const id = parseInt(params.scrimId); - const isAuthed = await isAuthedToViewScrim(id); - - 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 aa6e6b05d..000000000 --- a/apps/web/src/app/[team]/scrim/[scrimId]/loading.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { DashboardLayout } from "@/components/dashboard-layout"; -import { DirectionalTransition } from "@/components/directional-transition"; -import { Skeleton } from "@/components/ui/skeleton"; -import { getTranslations } from "next-intl/server"; - -export default async function ScrimLoading() { - const t = await getTranslations("scrimPage"); - - return ( - - -
- - - - - - - - -
-

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

-
- {Array.from({ length: 8 }).map((_, index) => ( - - ))} -
-
-
-
-
- ); -} 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..5fada2a3d 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 @@ -1,30 +1,38 @@ import { NoAuthCard } from "@/components/auth/no-auth"; import { Footer } from "@/components/footer"; +import { MapPageSkeleton } from "@/components/map/map-page-skeleton"; 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 the + // map skeleton so the content region never drops to a blank while auth + // resolves. return ( - {children} -