diff --git a/CLAUDE.md b/CLAUDE.md index e6ccf23..9a3de39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,14 +75,15 @@ Skills live in `.claude/skills/`. Use the `/skill-name` slash command or referen ### Routing - Routes live in `src/routes/`. `routeTree.gen.ts` is **generated** — do not edit (also marked read-only in `.vscode/settings.json` and excluded from Biome). -- Root shell is `src/routes/__root.tsx`: renders ``, the Mantine `AppShell` (header + navbar + footer), and mounts `AppProviders` (`src/components/AppProviders.tsx`). The provider chain is `NuqsAdapter` -> `GameProvider` -> `MantineProviderWithTheme`; `MantineProviderWithTheme` itself wraps children in Mantine's `ModalsProvider`, and a `ScreenshotPreviewProvider` is also rendered inside it. -- Game-scoped URLs live under `src/routes/$gameId/` — the `gameId` param is written into the game store on mount. +- Root shell is `src/routes/__root.tsx`: renders ``, the Mantine `AppShell` (header + navbar + footer), and mounts `AppProviders` (`src/components/AppProviders.tsx`). The provider chain is `NuqsAdapter` -> `MantineProviderWithTheme`; `MantineProviderWithTheme` itself wraps children in Mantine's `ModalsProvider`, and a `ScreenshotPreviewProvider` is also rendered inside it. +- The root route's `beforeLoad` resolves an authoritative `ssrGameId` for every request (see "Active-game resolution" below) and exposes it via route context. +- Game-scoped URLs live under `src/routes/$gameId/` — the `$gameId` path segment feeds into the root `beforeLoad`'s resolution chain; the segment itself drives favicon `` tags via the route's `head()`. - Profile routes: - `src/routes/profile/` — the anonymous/offline-friendly profile shell. Always reachable, even when signed out or offline (it renders the local DAL view). When the user is both authenticated **and** online, `route.tsx` redirects to `/account/profile/$userId` with the current session's user id. - `src/routes/account/profile/$userId/` — the canonical, userId-keyed profile route. Used for both the current user (after the redirect above) and public views of other users. - HTML head metadata: - Root tags (title, og:*, twitter:*) are defined in `__root.tsx`. Routes can override them with their own `head()` — TanStack Router merges by `property`/`name`, with child routes winning on key conflicts. - - The canonical profile route (`/account/profile/$userId`) overrides root metadata with user-specific OG/Twitter tags. The OG image is the active-game avatar override → primary avatar → legacy `avatarUrl` → site default. The active game is resolved from the request `Host` header via `getActiveGameIdFromRequestServerFn` (`src/features/game/dal/active-game.ts`), so subdomain-aware previews work during SSR. + - The canonical profile route (`/account/profile/$userId`) overrides root metadata with user-specific OG/Twitter tags. The OG image is the active-game avatar override → primary avatar → legacy `avatarUrl` → site default. The active game here resolves as `subdomain → ?gameId= → active-game cookie` (the loader-context route-segment chain isn't used for OG since the profile path itself never carries a gameId segment). The cookie matters because the owner often lands on a profile tab via in-app nav with no `?gameId=` in the URL; including the cookie keeps the SSR'd OG consistent with what they see on screen, while crawlers without the cookie still get the correct preview when the shared URL carries `?gameId=` (the in-tab `useEffect` in `collected-items.tsx` mirrors the active gameId into the URL on hydration). The loader reuses the cached result of `getServerResolvedGameInputsServerFn` (`src/features/game/dal/active-game.ts`, queryKey `SERVER_GAME_INPUTS_QUERY_KEY` from `src/routes/__root.tsx`) that the root `beforeLoad` already populated, so no extra server call fires. - Per-tab title overrides (e.g. "Display Name — Collected Items | Toolkits.gg") use the shared helpers in `src/features/auth/core/profile-tab-head.ts`. Each tab's `loader` calls `loadProfileTabData()` (cache-hit from the parent loader's `ensureQueryData` — no extra fetch). ### Game registry pattern @@ -172,12 +173,23 @@ Follow these steps in order. The registry is the single place to check; no other ### Active-game resolution -The active game is tracked in a `@tanstack/store` at `src/features/game/core/store.ts` (exports `gameStore` and `setGame`) with a `source` priority: `subdomain` > `route` > `toggle`/`session` > `default`. State is rehydrated from `localStorage` (`active-game` key) on module load. Two callers write to it: +GameId resolution is SSR-deterministic: the root route's `beforeLoad` (in `src/routes/__root.tsx`) computes an authoritative `ssrGameId` per request and exposes it via route context. Components read it through `useGameId()` (`src/features/game/core/use-game-id.ts`), which merges the SSR value with a client-only in-memory store used for mid-session toggles. -- `GameProvider` (client-only, mounted via `AppProviders` in `__root.tsx`) — reads `window.location.hostname` (`parseSubdomain` in `#/features/game/core/utils`) with a `?_game=` dev override. -- `src/routes/$gameId/route.tsx` — calls `setGame(gameId, "route")` on every navigation under the `$gameId` segment. +**Priority chain inside `beforeLoad`:** +1. **Subdomain** — from the `Host` header (e.g. `remnant2.toolkits.gg` → `remnant2`). +2. **Dev override** `?_game=` — only when `import.meta.env.DEV`. Used to test subdomain behavior on `localhost`. +3. **Route segment** — leading `/$gameId/...` in `location.pathname`. +4. **Search param** `?gameId=`. +5. **Cookie** `active-game` — durable user preference, written by `GameSwitcher` via `setActiveGameCookie()` (in `#/features/game/core/utils`). +6. Fallback `null` → resolves to `"none"` in `useGameId()`. -A `subdomain`-sourced value deliberately wins over later `route` writes — be careful if you change this precedence. +**Server-side resolution:** `getServerResolvedGameInputsServerFn` in `src/features/game/dal/active-game.ts` reads the Host header and the `active-game` cookie via `getRequest()`. Cached for the session via `queryClient.ensureQueryData` (queryKey `SERVER_GAME_INPUTS_QUERY_KEY` exported from `src/routes/__root.tsx`) with `staleTime: Infinity`, so it runs at most once per page load even when other loaders (e.g. the profile route) need the same inputs. + +**Client layer:** the `@tanstack/store` at `src/features/game/core/store.ts` is a thin reactive layer (`{ gameId: GameId | null }`) used by `GameSwitcher` for mid-session toggles. `useGameId()` returns `clientStore.gameId ?? ssrGameId ?? "none"` — the client store wins when set so a switcher click updates the UI immediately, but on initial paint the store is empty and the SSR value wins (deterministic hydration). + +**Writing from `GameSwitcher`:** `handleSelectGame` calls `setActiveGameCookie(id)` (durable) + `setGame(id)` (immediate UI reactivity). `handleGoHome` clears both. + +Because every input in the chain is server-knowable, no `ClientOnly` wrappers are needed for gameId-driven render output. (`ClientOnly` is still used for genuinely client-only state like `UserMenu`'s auth status.) ### Theme system diff --git a/src/components/AppProviders.tsx b/src/components/AppProviders.tsx index 0a63729..e0ff1e6 100644 --- a/src/components/AppProviders.tsx +++ b/src/components/AppProviders.tsx @@ -1,18 +1,15 @@ import { NuqsAdapter } from "nuqs/adapters/tanstack-router"; import type { PropsWithChildren } from "react"; -import { GameProvider } from "#/features/game/core/GameProvider"; import { ScreenshotPreviewProvider } from "#/features/screenshot/core/ScreenshotPreviewProvider"; import { MantineProviderWithTheme } from "#/features/theme/core/MantineProviderWithTheme"; const AppProviders = ({ children }: PropsWithChildren) => { return ( - - - - {children} - - + + + {children} + ); }; diff --git a/src/components/navigation/AppNavbar.tsx b/src/components/navigation/AppNavbar.tsx index f770d9c..3d90702 100644 --- a/src/components/navigation/AppNavbar.tsx +++ b/src/components/navigation/AppNavbar.tsx @@ -1,51 +1,49 @@ -import { Flex, ScrollArea } from "@mantine/core"; -import { ClientOnly } from "@tanstack/react-router"; -import { getNavLinks } from "#/components/navigation/get-nav-links"; -import { NavbarLinksGroup } from "#/components/navigation/NavbarLinksGroup"; -import { UserMenu } from "#/features/auth/core/UserMenu"; -import { useGameId } from "#/features/game/core/use-game-id"; -import { ChangeThemeButton } from "#/features/theme/core/ChangeThemeButton"; -import classes from "./AppNavbar.module.css"; - -type AppNavbarProps = { - onGettingStartedWizard: () => void; -}; - -const AppNavbar = ({ onGettingStartedWizard }: AppNavbarProps) => { - const gameId = useGameId(); - - const navLinks = getNavLinks({ - gameId, - onGettingStartedWizard, - }); - - return ( - - Loading...}> - -
- {navLinks.map((navLink) => ( - - ))} -
-
-
- - - - - - }> - - - - -
- ); -}; - -export { AppNavbar }; +import { Flex, ScrollArea } from "@mantine/core"; +import { ClientOnly } from "@tanstack/react-router"; +import { getNavLinks } from "#/components/navigation/get-nav-links"; +import { NavbarLinksGroup } from "#/components/navigation/NavbarLinksGroup"; +import { UserMenu } from "#/features/auth/core/UserMenu"; +import { useGameId } from "#/features/game/core/use-game-id"; +import { ChangeThemeButton } from "#/features/theme/core/ChangeThemeButton"; +import classes from "./AppNavbar.module.css"; + +type AppNavbarProps = { + onGettingStartedWizard: () => void; +}; + +const AppNavbar = ({ onGettingStartedWizard }: AppNavbarProps) => { + const gameId = useGameId(); + + const navLinks = getNavLinks({ + gameId, + onGettingStartedWizard, + }); + + return ( + + +
+ {navLinks.map((navLink) => ( + + ))} +
+
+ + + + + + }> + + + + +
+ ); +}; + +export { AppNavbar }; diff --git a/src/constants.ts b/src/constants.ts index 71ec5c8..3df8339 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -11,6 +11,8 @@ const FALLBACK_DISPLAY_NAME = "Toolkits.gg User"; const OG_IMAGE = `${import.meta.env.VITE_CLOUDFRONT_URL}/metadata/og-image.png`; +const SERVER_GAME_INPUTS_QUERY_KEY = ["server-resolved-game-inputs"] as const; + export { DISCORD_URL, GITHUB_URL, @@ -20,4 +22,5 @@ export { CHANGELOG_URL, FALLBACK_DISPLAY_NAME, OG_IMAGE, + SERVER_GAME_INPUTS_QUERY_KEY, }; diff --git a/src/features/auth/core/ProfileHeader.tsx b/src/features/auth/core/ProfileHeader.tsx index f1a209a..a7112bb 100644 --- a/src/features/auth/core/ProfileHeader.tsx +++ b/src/features/auth/core/ProfileHeader.tsx @@ -8,7 +8,6 @@ import { Text, } from "@mantine/core"; import { modals } from "@mantine/modals"; -import { ClientOnly } from "@tanstack/react-router"; import { LuCamera, LuPencil } from "react-icons/lu"; import { AvatarPicker } from "#/features/auth/core/AvatarPicker"; import { ProfileEditForm } from "#/features/auth/core/ProfileEditForm"; @@ -90,11 +89,7 @@ const ProfileHeader = ({ userId, isOwner }: ProfileHeaderProps) => { - } - > - - + {isOwner && ( { - const subdomainGame = - parseSubdomain(window.location.hostname) ?? parseDevGameOverride(); - if (!subdomainGame) return; - - const validatedGameId = getValidatedGameId(subdomainGame); - if (!validatedGameId) return; - - setGame(validatedGameId, "subdomain"); - }, []); - - return <>{children}; -} diff --git a/src/features/game/core/GameSwitcher.tsx b/src/features/game/core/GameSwitcher.tsx index a11d2f2..374225e 100644 --- a/src/features/game/core/GameSwitcher.tsx +++ b/src/features/game/core/GameSwitcher.tsx @@ -11,11 +11,7 @@ import { UnstyledButton, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; -import { - ClientOnly, - useNavigate, - useRouterState, -} from "@tanstack/react-router"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; import { useState } from "react"; import { LuChevronRight, @@ -31,6 +27,7 @@ import { useDalMutation } from "#/features/dal/hooks/use-dal-mutation"; import { useDalQuery } from "#/features/dal/hooks/use-dal-query"; import { setGame } from "#/features/game/core/store"; import { useGameId } from "#/features/game/core/use-game-id"; +import { setActiveGameCookie } from "#/features/game/core/utils"; import { getGameConfig, getGameLogo, @@ -132,7 +129,8 @@ function GameSwitcher() { }; const handleGoHome = async () => { - setGame("none", "toggle"); + setActiveGameCookie(null); + setGame(null); await navigate({ to: "/" }); handleClose(); }; @@ -143,7 +141,8 @@ function GameSwitcher() { }; const handleSelectGame = (id: GameId) => { - setGame(id, "toggle"); + setActiveGameCookie(id); + setGame(id); // If on a game-scoped route, navigate to the same sub-path under the new game const segments = location.pathname.split("/").filter(Boolean); @@ -189,11 +188,9 @@ function GameSwitcher() { > - }> - {getGameLogo(activeGameId) || } - + {getGameLogo(activeGameId) || } - {activeLabel} + {activeLabel} diff --git a/src/features/game/core/store.ts b/src/features/game/core/store.ts index dea921a..3005417 100644 --- a/src/features/game/core/store.ts +++ b/src/features/game/core/store.ts @@ -1,42 +1,19 @@ -// src/stores/gameStore.ts - -import { Store } from "@tanstack/store"; -import type { GameId } from "@/prisma"; - -type GameSource = "subdomain" | "route" | "toggle" | "session" | "default"; - -interface GameState { - gameId: GameId | null; - source: GameSource; -} - -const STORAGE_KEY = "active-game"; - -// Rehydrate from localStorage on module load -const stored = (() => { - try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw ? JSON.parse(raw) : null; - } catch { - return null; - } -})(); - -const gameStore = new Store({ - gameId: stored?.gameId ?? null, - source: stored?.source ?? "default", -}); - -const setGame = (id: GameId, source: GameSource) => { - gameStore.setState((prev) => { - if (prev.source === "subdomain" && source === "route") return prev; - const next = { gameId: id, source }; - // Persist side-effect lives here, not scattered across call sites - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); - } catch {} - return next; - }); -}; - -export { setGame, gameStore }; +import { Store } from "@tanstack/store"; +import type { GameId } from "@/prisma"; + +/** + * Used to allow client-only updates via GameSwitcher toggles. + * The primary gameId is in the `active-game` cookie, + * exposed as `ssrGameId` via `useGameId`. + */ +interface GameState { + gameId: GameId | null; +} + +const gameStore = new Store({ gameId: null }); + +const setGame = (id: GameId | null) => { + gameStore.setState(() => ({ gameId: id })); +}; + +export { gameStore, setGame }; diff --git a/src/features/game/core/use-game-id.ts b/src/features/game/core/use-game-id.ts index 7e18730..acd2bbf 100644 --- a/src/features/game/core/use-game-id.ts +++ b/src/features/game/core/use-game-id.ts @@ -1,14 +1,16 @@ -// src/hooks/useGameId.ts - -import { useSelector } from "@tanstack/react-store"; -import { gameStore } from "#/features/game/core/store"; -import type { GameId } from "@/prisma"; - -export function useGameId(): GameId { - return useSelector(gameStore, (state) => state.gameId) ?? "none"; -} - -// If you ever need the full state (e.g. to read source) -export function useGameState() { - return useSelector(gameStore, (state) => state); -} +import { useRouteContext } from "@tanstack/react-router"; +import { useSelector } from "@tanstack/react-store"; +import { gameStore } from "#/features/game/core/store"; +import type { GameId } from "@/prisma"; + +/** + * Client store wins (when set), so GameSwitcher clicks update the UI instantly; + * otherwise fall back to the SSR value (from initial paint/hydration). + */ +const useGameId = (): GameId => { + const { ssrGameId } = useRouteContext({ from: "__root__" }); + const clientGameId = useSelector(gameStore, (s) => s.gameId); + return clientGameId ?? ssrGameId ?? "none"; +}; + +export { useGameId }; diff --git a/src/features/game/core/utils.ts b/src/features/game/core/utils.ts index bc4eb4a..e1d4131 100644 --- a/src/features/game/core/utils.ts +++ b/src/features/game/core/utils.ts @@ -1,41 +1,70 @@ -import { isRegisteredGameId } from "#/features/game/registry/game-registry.tsx"; -import type { GameId } from "@/prisma"; - -const ROOT_DOMAINS = ["toolkits.gg", "www.toolkits.gg", "localhost"]; - -export function parseSubdomain(hostname: string): GameId | null { - // Strip port (e.g. localhost:3000) - const host = hostname.split(":")[0]; - - // No subdomain possible on bare localhost - if (host === "localhost") return null; - - // Check against known root domains - for (const root of ROOT_DOMAINS) { - if (host === root) return null; - } - - // e.g. "remnant2.toolkits.gg" -> ["remnant2", "toolkits", "gg"] - const parts = host.split("."); - - // Need at least 3 parts for a subdomain: [sub, domain, tld] - if (parts.length < 3) return null; - - const subdomain = parts[0]; - - // Reject "www" explicitly in case it slips through - if (subdomain === "www") return null; - - // Reject invalid subdomains - if (!isRegisteredGameId(subdomain)) return null; - - return subdomain; -} - -// For dev: support ?_game=remnant2 as a subdomain override -// Usage: http://localhost:3000?_game=remnant2 -export function parseDevGameOverride(): string | null { - if (import.meta.env.PROD) return null; - const params = new URLSearchParams(window.location.search); - return params.get("_game"); -} +import { isRegisteredGameId } from "#/features/game/registry/game-registry.tsx"; +import type { GameId } from "@/prisma"; + +const ROOT_DOMAINS = ["toolkits.gg", "www.toolkits.gg", "localhost"]; + +const parseSubdomain = (hostname: string): GameId | null => { + // Strip port (e.g. localhost:3000) + const host = hostname.split(":")[0]; + + // No subdomain possible on bare localhost + if (host === "localhost") return null; + + // Check against known root domains + for (const root of ROOT_DOMAINS) { + if (host === root) return null; + } + + // e.g. "remnant2.toolkits.gg" -> ["remnant2", "toolkits", "gg"] + const parts = host.split("."); + + // Need at least 3 parts for a subdomain: [sub, domain, tld] + if (parts.length < 3) return null; + + const subdomain = parts[0]; + + // Reject "www" explicitly in case it slips through + if (subdomain === "www") return null; + + // Reject invalid subdomains + if (!isRegisteredGameId(subdomain)) return null; + + return subdomain; +}; + +/** + * Writes the active-game preference cookie from the client. + * Read on the server in beforeLoad to seed the gameId resolution chain. + * `null` clears the cookie. + */ +const setActiveGameCookie = (value: string | null): void => { + if (typeof document === "undefined") return; + const base = `active-game=${value ? encodeURIComponent(value) : ""}; path=/; samesite=lax`; + const maxAge = value ? "; max-age=31536000" : "; max-age=0"; + const secure = import.meta.env.PROD ? "; secure" : ""; + // biome-ignore lint/suspicious/noDocumentCookie: Cookie Store API isn't supported in Safari/Firefox. + document.cookie = `${base}${maxAge}${secure}`; +}; + +/** + * Tiny cookie-header parser for server-side reads. Returns the decoded value of + * the named cookie or null if absent. Format: "a=1; b=2; c=3". + */ +const parseCookie = (header: string, name: string): string | null => { + const parts = header.split(/;\s*/); + for (const part of parts) { + const eq = part.indexOf("="); + if (eq === -1) continue; + const k = part.slice(0, eq); + if (k !== name) continue; + const v = part.slice(eq + 1); + try { + return decodeURIComponent(v); + } catch { + return v; + } + } + return null; +}; + +export { parseCookie, parseSubdomain, setActiveGameCookie }; diff --git a/src/features/game/dal/active-game.ts b/src/features/game/dal/active-game.ts index fcfdf64..d27fa4e 100644 --- a/src/features/game/dal/active-game.ts +++ b/src/features/game/dal/active-game.ts @@ -1,18 +1,33 @@ import { createServerFn } from "@tanstack/react-start"; import { getRequest } from "@tanstack/react-start/server"; -import { parseSubdomain } from "#/features/game/core/utils"; +import { parseCookie, parseSubdomain } from "#/features/game/core/utils"; +import { getValidatedGameId } from "#/features/game/registry/game-registry"; import type { GameId } from "@/prisma"; -// Reads the request's Host header and resolves a registered gameId from any -// subdomain prefix. -// This is the only way to get the game id on the server -const getSubdomainGameIdServerFn = createServerFn({ +const ACTIVE_GAME_COOKIE = "active-game"; + +/** + * Reads everything about the request that the server can know up front: + * the subdomain (Host header) and the active-game preference cookie. + * Used by the root route's beforeLoad to feed the gameId priority chain. + */ +const getServerResolvedGameInputsServerFn = createServerFn({ method: "GET", -}).handler(async (): Promise => { - const request = getRequest(); - const host = request.headers.get("host") ?? ""; - const subdomain = parseSubdomain(host); - return subdomain ? (subdomain as GameId) : null; -}); +}).handler( + async (): Promise<{ + subdomainGameId: GameId | null; + cookieGameId: GameId | null; + }> => { + const request = getRequest(); + const host = request.headers.get("host") ?? ""; + const cookieHeader = request.headers.get("cookie") ?? ""; + const subdomainGameId = parseSubdomain(host); + const cookieValue = parseCookie(cookieHeader, ACTIVE_GAME_COOKIE); + return { + subdomainGameId: subdomainGameId ?? null, + cookieGameId: getValidatedGameId(cookieValue ?? "") ?? null, + }; + }, +); -export { getSubdomainGameIdServerFn }; +export { ACTIVE_GAME_COOKIE, getServerResolvedGameInputsServerFn }; diff --git a/src/routes/$gameId/route.tsx b/src/routes/$gameId/route.tsx index a5b4f0e..49c7e61 100644 --- a/src/routes/$gameId/route.tsx +++ b/src/routes/$gameId/route.tsx @@ -1,13 +1,7 @@ -// src/routes/games/$gameId/route.tsx - import { Box, Text, Title } from "@mantine/core"; import { createFileRoute, Outlet } from "@tanstack/react-router"; -import { useEffect } from "react"; -import { setGame } from "#/features/game/core/store"; -import { isRegisteredGameId } from "#/features/game/registry/game-registry"; -import type { GameId } from "@/prisma"; -export const Route = createFileRoute("/$gameId")({ +const Route = createFileRoute("/$gameId")({ head: ({ params }) => ({ links: [ { @@ -35,18 +29,7 @@ export const Route = createFileRoute("/$gameId")({ ); }, - component: function GameRouteLayout() { - const { gameId } = Route.useParams(); - - const validatedGameId = ( - isRegisteredGameId(gameId) ? gameId : "none" - ) satisfies GameId; - - // When the route changes, update the game in our global store - useEffect(() => { - setGame(validatedGameId, "route"); - }, [validatedGameId]); - - return ; - }, + component: () => , }); + +export { Route }; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index a96d1a9..5da5ee5 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -34,8 +34,11 @@ import { AppNavbar } from "#/components/navigation/AppNavbar"; import { SocialMedia } from "#/components/SocialMedia"; import { GettingStartedWizard } from "#/components/wizards/getting-started/components/GettingStartedWizard"; import { useGettingStartedWizard } from "#/components/wizards/getting-started/hooks/use-getting-started-wizard"; -import { OG_IMAGE } from "#/constants"; +import { OG_IMAGE, SERVER_GAME_INPUTS_QUERY_KEY } from "#/constants"; import { GameSwitcher } from "#/features/game/core/GameSwitcher"; +import { getServerResolvedGameInputsServerFn } from "#/features/game/dal/active-game"; +import { getValidatedGameId } from "#/features/game/registry/game-registry"; +import type { GameId } from "@/prisma"; import classes from "./Root.module.css"; interface MyRouterContext { @@ -48,6 +51,36 @@ const description = const url = "https://toolkits.gg"; const Route = createRootRouteWithContext()({ + beforeLoad: async ({ context, location }) => { + // Cache the server-fn result for the lifetime of the session — the Host + // header and the active-game cookie don't change without a hard reload. + const { subdomainGameId, cookieGameId } = + await context.queryClient.ensureQueryData({ + queryKey: SERVER_GAME_INPUTS_QUERY_KEY, + queryFn: () => getServerResolvedGameInputsServerFn(), + staleTime: Number.POSITIVE_INFINITY, + gcTime: Number.POSITIVE_INFINITY, + }); + + const searchParams = new URLSearchParams(location.searchStr); + const devOverride = import.meta.env.DEV + ? (getValidatedGameId(searchParams.get("_game") ?? "") ?? null) + : null; + const firstSeg = location.pathname.split("/").filter(Boolean)[0] ?? ""; + const routeGameId = getValidatedGameId(firstSeg) ?? null; + const searchGameId = + getValidatedGameId(searchParams.get("gameId") ?? "") ?? null; + + const ssrGameId: GameId | null = + subdomainGameId ?? + devOverride ?? + routeGameId ?? + searchGameId ?? + cookieGameId ?? + null; + + return { ssrGameId }; + }, head: () => ({ meta: [ { charSet: "utf-8" }, @@ -196,4 +229,4 @@ function RootDocument({ children }: { children: ReactNode }) { ); } -export { Route }; +export { Route, type SERVER_GAME_INPUTS_QUERY_KEY }; diff --git a/src/routes/account/profile/$userId/collected-items.tsx b/src/routes/account/profile/$userId/collected-items.tsx index dfe4d34..807ce06 100644 --- a/src/routes/account/profile/$userId/collected-items.tsx +++ b/src/routes/account/profile/$userId/collected-items.tsx @@ -1,11 +1,10 @@ import { createFileRoute, getRouteApi } from "@tanstack/react-router"; -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { buildTabHead, loadProfileTabData, } from "#/features/auth/core/profile-tab-head"; -import { gameStore, setGame } from "#/features/game/core/store"; -import { useGameState } from "#/features/game/core/use-game-id"; +import { useGameId } from "#/features/game/core/use-game-id"; import { getGameConfig, getGameMetadata, @@ -24,44 +23,20 @@ const CollectedItems = () => { const { isOwner } = parentRouteApi.useLoaderData(); const { gameId: urlGameId } = Route.useSearch(); const navigate = Route.useNavigate(); - const { gameId: storeGameIdRaw, source } = useGameState(); - const storeGameId: GameId = storeGameIdRaw ?? "none"; - - // Render off url gameId to prevent hydration error. - // Store reads from localstorage, which causes the issue since it is null on server. - // Effects will still handle the needed sync after hydration. - const config = urlGameId ? getGameConfig(urlGameId) : undefined; - const initializedRef = useRef(false); + const gameId = useGameId(); + // Mirror the active gameId back to the URL so the page state is shareable + // and so picking a different game via GameSwitcher keeps the URL in sync. useEffect(() => { - if (!urlGameId) return; - // Read the store fresh here so a store update cannot retrigger this - // effect and revert a GameSwitcher (toggle-source) write. - const current = gameStore.state.gameId ?? "none"; - if (urlGameId !== current) { - setGame(urlGameId, "route"); - } - }, [urlGameId]); - - useEffect(() => { - const firstRun = !initializedRef.current; - initializedRef.current = true; - // On mount, skip the store -> URL write if the URL already has a value (URL wins). - // Exception: subdomain has authority over the URL, so we still need to sync - // the URL to the subdomain game. - if (firstRun && urlGameId && source !== "subdomain") return; - if (storeGameId === "none") return; - if (storeGameId === urlGameId) return; - // Don't write back to URL if the store change was caused by our own - // URL -> store sync above, but only when the URL already carries a gameId. - // If we landed here from a `/$gameId/*` page, the store has source="route" - // but the new URL has no gameId, so it still requires a sync. - if (urlGameId && source === "route") return; + if (gameId === "none") return; + if (urlGameId === gameId) return; void navigate({ - search: (prev) => ({ ...prev, gameId: storeGameId }), + search: (prev) => ({ ...prev, gameId }), replace: true, }); - }, [storeGameId, source, urlGameId, navigate]); + }, [gameId, urlGameId, navigate]); + + const config = gameId !== "none" ? getGameConfig(gameId) : undefined; return ( <> diff --git a/src/routes/account/profile/$userId/route.tsx b/src/routes/account/profile/$userId/route.tsx index cf3e774..dadc005 100644 --- a/src/routes/account/profile/$userId/route.tsx +++ b/src/routes/account/profile/$userId/route.tsx @@ -1,6 +1,10 @@ import { Box, Stack, Text, Title } from "@mantine/core"; import { createFileRoute, notFound, Outlet } from "@tanstack/react-router"; -import { FALLBACK_DISPLAY_NAME, OG_IMAGE } from "#/constants.ts"; +import { + FALLBACK_DISPLAY_NAME, + OG_IMAGE, + SERVER_GAME_INPUTS_QUERY_KEY, +} from "#/constants.ts"; import { ProfileHeader } from "#/features/auth/core/ProfileHeader"; import { ProfileTabNav } from "#/features/auth/core/ProfileTabNav"; import { resolveAvatar } from "#/features/auth/core/utils"; @@ -10,7 +14,7 @@ import { getViewerUserIdServerFn, mapUserToProfileData, } from "#/features/auth/dal/user-profile/user-profile.actions"; -import { getSubdomainGameIdServerFn } from "#/features/game/dal/active-game"; +import { getServerResolvedGameInputsServerFn } from "#/features/game/dal/active-game"; import { getValidatedGameId } from "#/features/game/registry/game-registry"; import type { GameId } from "@/prisma"; @@ -35,28 +39,35 @@ const ProfileLayout = () => { const Route = createFileRoute("/account/profile/$userId")({ loader: async ({ params, context, location }) => { const { queryClient } = context; - const [profile, viewerUserId, subdomainGameId] = await Promise.all([ - queryClient.ensureQueryData({ - queryKey: buildGetProfileQueryKey(params.userId), - queryFn: async () => { - const user = await getPublicUserProfileServerFn({ - data: { userId: params.userId }, - }); - return mapUserToProfileData(user); - }, - }), - getViewerUserIdServerFn(), - getSubdomainGameIdServerFn(), - ]); + const [profile, viewerUserId, { subdomainGameId, cookieGameId }] = + await Promise.all([ + queryClient.ensureQueryData({ + queryKey: buildGetProfileQueryKey(params.userId), + queryFn: async () => { + const user = await getPublicUserProfileServerFn({ + data: { userId: params.userId }, + }); + return mapUserToProfileData(user); + }, + }), + getViewerUserIdServerFn(), + queryClient.ensureQueryData({ + queryKey: SERVER_GAME_INPUTS_QUERY_KEY, + queryFn: () => getServerResolvedGameInputsServerFn(), + staleTime: Number.POSITIVE_INFINITY, + gcTime: Number.POSITIVE_INFINITY, + }), + ]); if (!profile) throw notFound(); - // Mirror the gameStore source priority for the inputs we can read here: - // subdomain (Host header, server-only) > route (?gameId= from the URL, - // universal via the loader's `location`). toggle/session live in - // localStorage and are client-only, so they can't influence SSR. + // Subdomain > ?gameId= > cookie. + // The cookie reflects the owner's current switcher selection, + // so SSR'd OG matches what they see on screen, even before the + // in-tab `useEffect` mirrors the gameId into the URL. const searchParams = new URLSearchParams(location.searchStr); const searchGameId = getValidatedGameId(searchParams.get("gameId") ?? ""); - const activeGameId: GameId | null = subdomainGameId ?? searchGameId ?? null; + const activeGameId: GameId | null = + subdomainGameId ?? searchGameId ?? cookieGameId ?? null; // "none" cleanly bypasses the override branch in resolveAvatar (no override // rows are stored against "none"), so primary-then-legacy fallback applies. diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 223a3a1..891cf41 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,16 +1,7 @@ import { Title } from "@mantine/core"; import { createFileRoute } from "@tanstack/react-router"; -import { useEffect } from "react"; -import { setGame } from "#/features/game/core/store"; -function App() { - // set game to none for the home page - useEffect(() => { - setGame("none", "route"); - }, []); - - return Home Page; -} +const App = () => Home Page; const Route = createFileRoute("/")({ component: App });