From dfec016a30289e04fd1746df883b85c13f25a8c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 12:05:09 +0000 Subject: [PATCH 1/4] docs: add handoff for TanStack Router + Query migration Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CZqUFo6ufUPzXdXb1SssPb --- docs/HANDOFF_TANSTACK_ROUTER_QUERY.md | 203 ++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 docs/HANDOFF_TANSTACK_ROUTER_QUERY.md diff --git a/docs/HANDOFF_TANSTACK_ROUTER_QUERY.md b/docs/HANDOFF_TANSTACK_ROUTER_QUERY.md new file mode 100644 index 00000000..115f3b2c --- /dev/null +++ b/docs/HANDOFF_TANSTACK_ROUTER_QUERY.md @@ -0,0 +1,203 @@ +# Handoff: Adopt TanStack Router + Query patterns + +**Branch:** `claude/tanstack-router-query-stuAq` +**Status:** Plan only — no code changes made yet. +**Reference:** [TkDodo — "TanStack Router and Query"](https://tkdodo.eu/blog/tan-stack-router-and-query) + +This document hands off a migration to deepen the integration between TanStack +Router and TanStack Query, following TkDodo's recommended patterns. Read it +top-to-bottom before touching code; the **Caveats** section is load-bearing. + +--- + +## TL;DR + +The app is already on TanStack Router (file-based routing, `routeTree.gen.ts`) +and the *basic* Router+Query wiring exists. What's missing are the three deeper +patterns from the blog: + +1. `queryOptions()` factories (single source of truth for a query) — **0 usages today** +2. Route `loader`s prefetching via `queryClient.ensureQueryData` — **only 2 of ~80 routes** +3. `useSuspenseQuery` in components — **0 usages today** + +Goal: remove the duplication between query hooks and loaders, and let data load +through the router (prefetch in loader → guaranteed-available data in component), +while keeping TanStack Query as the cache / source of truth. + +--- + +## Current state (verified) + +Already in place: + +- ✅ `QueryClient` passed via router `context` — `src/main.tsx` + ```ts + const router = createRouter({ routeTree, context: { queryClient }, defaultPreload: "intent", ... }); + ``` +- ✅ `createRootRouteWithContext<{ queryClient: QueryClient }>()` — `src/routes/__root.tsx` +- ✅ Query-key factories per feature — e.g. `src/hooks/queries/festivals/types.ts` (`festivalsKeys`) +- ✅ `fetchX` functions separated from `useXQuery` hooks — e.g. `fetchFestivalBySlug` in `useFestivalBySlug.ts` +- ✅ `defaultPreload: "intent"` and global `defaultPendingComponent: RouteLoadingFallback`, `defaultNotFoundComponent: NotFound` + +Not yet adopted: + +- ❌ `queryOptions()` factories (`grep -rn "queryOptions(" src` → 0) +- ❌ `useSuspenseQuery` (`grep -rn "useSuspenseQuery" src` → 0) +- ❌ Loaders only on 2 routes: + - `src/routes/festivals/$festivalSlug.tsx` + - `src/routes/admin/festivals/$festivalSlug/editions/$editionSlug/import.tsx` + +Both existing loaders **inline** `{ queryKey, queryFn }`, duplicating what the +hooks already define — exactly the duplication `queryOptions` removes. + +### Scale +~73 files under `src/hooks/queries/`, ~80 route files under `src/routes/`. + +--- + +## Guiding principles (from the post) + +- **Router and Query are complementary.** Router owns URL state + *when* to load + (loaders); Query owns caching, dedup, background refetch, invalidation. Don't + move server-cache concerns into loaders. +- **One definition per query** via `queryOptions()` — the same object feeds + `ensureQueryData` (loader) and `useSuspenseQuery` / `useQuery` (component). +- **`ensureQueryData` in loaders**, not `prefetchQuery` — it returns data and + surfaces errors to the router error boundary. +- **`useSuspenseQuery` in components** so data is non-nullable; pair with route + `pendingComponent` (already global) and an `errorComponent`. + +--- + +## Phase 0 — Foundations (low risk, do first) + +1. **Confirm `staleTime`.** `main.tsx` sets `queries.staleTime: 5_000`. A + non-trivial `staleTime` keeps loader-prefetched data from instantly + refetching on mount. Confirm 5s is intended or override per-query in the + factory. + +2. **Pick where `queryOptions` factories live.** Recommendation: co-locate in + each feature's existing `types.ts` (already holds key factories). Example: + ```ts + // src/hooks/queries/festivals/types.ts + import { queryOptions } from "@tanstack/react-query"; + import { fetchFestivalBySlug } from "./useFestivalBySlug"; + + export const festivalQueries = { + bySlug: (slug: string) => + queryOptions({ + queryKey: festivalsKeys.bySlug(slug), + queryFn: () => fetchFestivalBySlug(slug), + }), + }; + ``` + ⚠️ **Import-cycle risk:** `types.ts` importing from `useFestivalBySlug.ts`, + which imports from `types.ts`. Cleanest fix: move each `fetchX` function + *into* `types.ts` (or a sibling `api.ts`) and have the `useXQuery` hook import + the factory. **Lock this structural decision in before scaling.** + +3. **Repoint hooks to consume the factory** so there's exactly one definition: + ```ts + export function useFestivalBySlugQuery(slug?: string) { + return useQuery({ ...festivalQueries.bySlug(slug!), enabled: !!slug }); + } + ``` + +--- + +## Phase 1 — Pilot vertical slice (festival → edition → set) + +Convert one complete path end-to-end as the reference implementation: + +- `src/routes/festivals/$festivalSlug.tsx` — already has a loader; switch it to + `context.queryClient.ensureQueryData(festivalQueries.bySlug(params.festivalSlug))`. +- `.../editions/$editionSlug.tsx` — add loader using an `editionQueries` factory. +- A leaf such as `.../sets/$setSlug.tsx` — in the component, replace + `useSetBySlug` (`useQuery`) with `useSuspenseQuery(setQueries.bySlug(...))` and + delete the null-guards. +- Add an `errorComponent` to these routes (today they lean only on the global + not-found + pending components). +- Verify preloading: `defaultPreload: "intent"` means hovering a link warms the + cache via the loader. + +This slice is the thing reviewers sign off on before rollout. + +--- + +## Phase 2 — Roll out by feature folder + +Apply the Phase 0/1 template feature-by-feature, leaf-data first (fewest +dependents): + +1. `festivals`, `festivals/editions` +2. `sets`, `stages`, `genres` +3. `festival-info`, `custom-links`, `knowledge` +4. `artists` (+ `artists/notes`) +5. `groups` (+ `groups/invites`), `voting` +6. `auth` (see caveat 1) + +Per feature: add `queryOptions` factory → repoint the hook → add `loader` + +`ensureQueryData` to the owning route(s) → switch required reads to +`useSuspenseQuery` → add `errorComponent`. + +--- + +## Phase 3 — Cleanup & guardrails + +- Remove dead null-checks / `enabled` guards that only existed because data could + be `undefined`. +- Update `CLAUDE.md` data-fetching guideline (point 4) to describe the + loader-prefetch pattern; optionally add a convention: "queries consumed by a + route with a loader should use a `queryOptions` factory + `useSuspenseQuery`." + +--- + +## Caveats specific to this codebase (read these) + +1. **Auth-dependent queries can't be naively prefetched in loaders.** + `useProfile`, `useUserPermissions`, `useUserVotes`, `useGroupVotes` depend on + the authenticated user, which comes from `AuthContext` rendered *inside* + `__root`'s component — not available in loaders. Options: keep these as + `useQuery` (no loader prefetch), **or** resolve auth/session into the router + context via `beforeLoad` so `context.user` is available to loaders. + **Recommendation: leave auth queries as-is in the first pass.** + +2. **`enabled` queries don't map to `useSuspenseQuery`.** Anywhere a query is + conditional (`enabled: !!x`), suspense is wrong — keep `useQuery`. Many hooks + use this pattern. + +3. **Realtime subscriptions** write into the Query cache (per CLAUDE.md). The + behavior is unaffected, but confirm `staleTime` doesn't fight live updates. + +4. **Subdomain `rewrite` logic** in `main.tsx` makes `$festivalSlug` implicit on + `*.getupline.com`. Loaders read `params.festivalSlug` — verify rewrites + populate params correctly under preload/suspense. + +5. **No `export ... from` and no barrel files** (CLAUDE.md). Import + `queryOptions` factories directly from each `types.ts`. + +--- + +## Project conventions to honor (from CLAUDE.md) + +- Function declarations, not arrow consts, for components/named functions. +- Query hooks end in `Query`, mutation hooks end in `Mutation`. +- Prefer `mutation.mutate(vars, { onSuccess, onError })` over `await mutateAsync` + in try/catch. +- No comments unless necessary; no barrel exports. +- Auto-commit code changes per user message. + +--- + +## Effort estimate + +~73 hooks + ~80 routes. Phase 0–1 is ~½ day and de-risks the rest; Phases 2–3 +are mechanical and parallelizable by feature folder. + +## Quick verification commands + +```bash +grep -rn "queryOptions(" src # factory adoption +grep -rn "useSuspenseQuery" src # component adoption +grep -rln "loader:" src/routes # routes with prefetch +``` From aea87f28cd04943ac72cf44f636facaf2adcfeb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 15:59:51 +0000 Subject: [PATCH 2/4] docs: rework handoff into src/api restructure + router/auth efforts Refined via grilling session: collapse src/hooks/queries into a feature-sliced src/api module (one self-contained file per endpoint with types + fetch + queryOptions factory + hook), then adopt router loaders/useSuspenseQuery, then hoist auth session into router context. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01K513rsQz6Lg1HbbfYiafrE --- docs/HANDOFF_TANSTACK_ROUTER_QUERY.md | 302 ++++++++++++++++---------- 1 file changed, 185 insertions(+), 117 deletions(-) diff --git a/docs/HANDOFF_TANSTACK_ROUTER_QUERY.md b/docs/HANDOFF_TANSTACK_ROUTER_QUERY.md index 115f3b2c..a3b69f7c 100644 --- a/docs/HANDOFF_TANSTACK_ROUTER_QUERY.md +++ b/docs/HANDOFF_TANSTACK_ROUTER_QUERY.md @@ -1,28 +1,31 @@ -# Handoff: Adopt TanStack Router + Query patterns +# Handoff: API modules + TanStack Router/Query adoption **Branch:** `claude/tanstack-router-query-stuAq` **Status:** Plan only — no code changes made yet. **Reference:** [TkDodo — "TanStack Router and Query"](https://tkdodo.eu/blog/tan-stack-router-and-query) -This document hands off a migration to deepen the integration between TanStack -Router and TanStack Query, following TkDodo's recommended patterns. Read it -top-to-bottom before touching code; the **Caveats** section is load-bearing. +This plan was refined in a grilling session. It now has **three sequential +efforts**, not one. Read top-to-bottom before touching code; the **Caveats** and +**Decisions** sections are load-bearing. --- ## TL;DR The app is already on TanStack Router (file-based routing, `routeTree.gen.ts`) -and the *basic* Router+Query wiring exists. What's missing are the three deeper -patterns from the blog: +with basic Router+Query wiring. Three deeper patterns from the blog are missing +(`queryOptions` factories, loader prefetch via `ensureQueryData`, +`useSuspenseQuery`), and adopting them well first means **restructuring the data +layer** into self-contained, feature-sliced API modules. -1. `queryOptions()` factories (single source of truth for a query) — **0 usages today** -2. Route `loader`s prefetching via `queryClient.ensureQueryData` — **only 2 of ~80 routes** -3. `useSuspenseQuery` in components — **0 usages today** +The work is split into three efforts, done in order: -Goal: remove the duplication between query hooks and loaders, and let data load -through the router (prefetch in loader → guaranteed-available data in component), -while keeping TanStack Query as the cache / source of truth. +1. **Restructure** `src/hooks/queries/` → `src/api/` (mechanical, no behavior change) +2. **Router adoption** — loaders + `useSuspenseQuery` per feature +3. **Auth hoist** — session into router context so auth queries can prefetch + +Each effort is done **feature-by-feature**, and the three efforts are kept +**separate** (don't bundle a restructure and a router change in one slice). --- @@ -30,36 +33,137 @@ while keeping TanStack Query as the cache / source of truth. Already in place: -- ✅ `QueryClient` passed via router `context` — `src/main.tsx` - ```ts - const router = createRouter({ routeTree, context: { queryClient }, defaultPreload: "intent", ... }); - ``` +- ✅ `QueryClient` passed via router `context`, `defaultPreload: "intent"`, + `defaultPreloadStaleTime: 0` — `src/main.tsx` - ✅ `createRootRouteWithContext<{ queryClient: QueryClient }>()` — `src/routes/__root.tsx` -- ✅ Query-key factories per feature — e.g. `src/hooks/queries/festivals/types.ts` (`festivalsKeys`) -- ✅ `fetchX` functions separated from `useXQuery` hooks — e.g. `fetchFestivalBySlug` in `useFestivalBySlug.ts` -- ✅ `defaultPreload: "intent"` and global `defaultPendingComponent: RouteLoadingFallback`, `defaultNotFoundComponent: NotFound` +- ✅ Global `defaultPendingComponent: RouteLoadingFallback`, `defaultNotFoundComponent: NotFound` +- ✅ Query-key factories per feature — e.g. `festivalsKeys` in `src/hooks/queries/festivals/types.ts` +- ✅ `fetchX` functions separated from `useXQuery` hooks — e.g. `fetchFestivalBySlug` -Not yet adopted: +Not yet adopted (verified counts): -- ❌ `queryOptions()` factories (`grep -rn "queryOptions(" src` → 0) -- ❌ `useSuspenseQuery` (`grep -rn "useSuspenseQuery" src` → 0) -- ❌ Loaders only on 2 routes: +- ❌ `queryOptions()` factories — **0 usages** +- ❌ `useSuspenseQuery` — **0 usages** +- ❌ Loaders on only **2** routes, both inlining `{ queryKey, queryFn }`: - `src/routes/festivals/$festivalSlug.tsx` - `src/routes/admin/festivals/$festivalSlug/editions/$editionSlug/import.tsx` -Both existing loaders **inline** `{ queryKey, queryFn }`, duplicating what the -hooks already define — exactly the duplication `queryOptions` removes. - ### Scale -~73 files under `src/hooks/queries/`, ~80 route files under `src/routes/`. + +- **73** files under `src/hooks/queries/` +- **31** route files (`*.tsx`) under `src/routes/` +- **25** files in `src/hooks/queries/` use an `enabled:` guard (conditional queries) +- `staleTime: 5_000` global default in `main.tsx` + +--- + +## Decisions (locked in the grilling session) + +### Target structure: `src/api/`, feature-sliced, flattened + +- The data layer moves from `src/hooks/queries/` to **`src/api/`**. It is the + central data-access module; it no longer lives under `hooks/` because each + file is now more than a hook. +- **Flat**: one directory per feature. Today's nested sub-features are promoted + to top-level siblings: + - `festivals/editions/` → `editions/` + - `artists/notes/` → `artist-notes/` + - `groups/invites/` → `invites/` + - The two loose root files (`useAdminRolesQuery.ts`, `useInviteValidationQuery.ts`) + get their own feature folders. + +### Per-feature layout + +``` +src/api/festivals/ + types.ts // entity Row type (Festival) + key factory (festivalsKeys) + useFestivalBySlug.ts // per-endpoint types + fetch + queryOptions factory + hook + useFestivals.ts + useCreateFestival.ts + ... +``` + +- **`types.ts` (shared, per feature):** holds the entity Row type (`Festival`, + imported by ~10+ UI files outside the folder) **and** the query-key factory + (`festivalsKeys`, shared across endpoints and across features — e.g. edition + mutations invalidate `festivalsKeys.all()`). +- **One file per endpoint**, keeping the existing **`use`-prefixed filename** + (`useFestivalBySlug.ts`). Each file is self-contained and holds, together: + 1. the endpoint's own request/response types (e.g. `CreateFestivalInput`) + 2. the `fetchX` / mutate function + 3. a **per-file `queryOptions` factory export** (e.g. `festivalBySlugQuery(slug)`) + 4. the hook, consuming its own factory + + ```ts + // src/api/festivals/useFestivalBySlug.ts + import { queryOptions, useQuery } from "@tanstack/react-query"; + import { supabase } from "@/integrations/supabase/client"; + import { Festival, festivalsKeys } from "./types"; + + export async function fetchFestivalBySlug(slug: string): Promise { + const { data, error } = await supabase + .from("festivals").select("*") + .eq("archived", false).eq("slug", slug).single(); + if (error) throw new Error("Failed to load festival"); + return data; + } + + export function festivalBySlugQuery(slug: string) { + return queryOptions({ + queryKey: festivalsKeys.bySlug(slug), + queryFn: () => fetchFestivalBySlug(slug), + }); + } + + export function useFestivalBySlugQuery(slug: string | undefined) { + return useQuery({ ...festivalBySlugQuery(slug!), enabled: !!slug }); + } + ``` + + This breaks the import cycle the original plan worried about: the factory and + the hook live together, and both import keys/types from the pure `types.ts`. + +### Sequencing: three separate, vertical efforts + +1. **Effort 1 — Restructure** (`src/hooks/queries/` → `src/api/`). Feature by + feature: move files, add per-file `queryOptions` factories, repoint hooks to + consume their own factory, update consumer import paths. **No behavior + change.** This is the big mechanical, reviewable diff. +2. **Effort 2 — Router adoption.** Feature by feature: add route `loader`s using + `context.queryClient.ensureQueryData(theQueryFactory(...))`, convert eligible + reads to `useSuspenseQuery`, add an `errorComponent`. `enabled`/conditional + queries stay `useQuery` (see caveat 2). +3. **Effort 3 — Auth hoist** (enables auth-dependent queries in the router). See + below; this is its own design and ADR. + +Restructure slices land first; router slices follow. Do **not** combine a +feature's restructure and its router change in the same slice. + +### Auth hoist (Effort 3) + +Auth-dependent queries (`useProfile`, `useUserPermissions`, `useUserVotes`, +`useGroupVotes`) can't prefetch in loaders today because `AuthProvider` (and the +`user` it owns) is rendered *inside* `__root`'s component, below the router. + +Decision: **resolve the session into router context, narrow `AuthProvider`.** + +- Root `beforeLoad` resolves the session into `context.user`, making it available + to loaders. +- `supabase.auth.onAuthStateChange` calls `router.invalidate()` for reactivity + (login/logout re-runs `beforeLoad` + loaders). +- `AuthProvider` **stays** but stops owning the session listener. It reads `user` + from router context and keeps everything else it does today: `profile`, + `needsOnboarding`, the `AuthDialog` modal, `SIGNED_IN` invite processing, + `signOut`. +- The **23 `useAuth` consumers keep the same `useAuth` API** — only the source of + `user` changes. This removes today's duplicate session source. --- ## Guiding principles (from the post) - **Router and Query are complementary.** Router owns URL state + *when* to load - (loaders); Query owns caching, dedup, background refetch, invalidation. Don't - move server-cache concerns into loaders. + (loaders); Query owns caching, dedup, background refetch, invalidation. - **One definition per query** via `queryOptions()` — the same object feeds `ensureQueryData` (loader) and `useSuspenseQuery` / `useQuery` (component). - **`ensureQueryData` in loaders**, not `prefetchQuery` — it returns data and @@ -69,112 +173,75 @@ hooks already define — exactly the duplication `queryOptions` removes. --- -## Phase 0 — Foundations (low risk, do first) - -1. **Confirm `staleTime`.** `main.tsx` sets `queries.staleTime: 5_000`. A - non-trivial `staleTime` keeps loader-prefetched data from instantly - refetching on mount. Confirm 5s is intended or override per-query in the - factory. - -2. **Pick where `queryOptions` factories live.** Recommendation: co-locate in - each feature's existing `types.ts` (already holds key factories). Example: - ```ts - // src/hooks/queries/festivals/types.ts - import { queryOptions } from "@tanstack/react-query"; - import { fetchFestivalBySlug } from "./useFestivalBySlug"; - - export const festivalQueries = { - bySlug: (slug: string) => - queryOptions({ - queryKey: festivalsKeys.bySlug(slug), - queryFn: () => fetchFestivalBySlug(slug), - }), - }; - ``` - ⚠️ **Import-cycle risk:** `types.ts` importing from `useFestivalBySlug.ts`, - which imports from `types.ts`. Cleanest fix: move each `fetchX` function - *into* `types.ts` (or a sibling `api.ts`) and have the `useXQuery` hook import - the factory. **Lock this structural decision in before scaling.** - -3. **Repoint hooks to consume the factory** so there's exactly one definition: - ```ts - export function useFestivalBySlugQuery(slug?: string) { - return useQuery({ ...festivalQueries.bySlug(slug!), enabled: !!slug }); - } - ``` +## Phase plan ---- - -## Phase 1 — Pilot vertical slice (festival → edition → set) - -Convert one complete path end-to-end as the reference implementation: +### Effort 1 — Restructure (do first, per feature) -- `src/routes/festivals/$festivalSlug.tsx` — already has a loader; switch it to - `context.queryClient.ensureQueryData(festivalQueries.bySlug(params.festivalSlug))`. -- `.../editions/$editionSlug.tsx` — add loader using an `editionQueries` factory. -- A leaf such as `.../sets/$setSlug.tsx` — in the component, replace - `useSetBySlug` (`useQuery`) with `useSuspenseQuery(setQueries.bySlug(...))` and - delete the null-guards. -- Add an `errorComponent` to these routes (today they lean only on the global - not-found + pending components). -- Verify preloading: `defaultPreload: "intent"` means hovering a link warms the - cache via the loader. +Suggested order, leaf-data first (fewest dependents): -This slice is the thing reviewers sign off on before rollout. +1. `festivals`, `editions` +2. `sets`, `stages`, `genres` +3. `festival-info`, `custom-links`, `knowledge` +4. `artists` (+ `artist-notes`) +5. `groups` (+ `invites`), `voting` +6. `auth`, plus loose `admin-roles` / `invite-validation` ---- +Per feature: create `src/api//`, move files, add per-file +`queryOptions` factory, repoint the hook to consume it, fix consumer imports. +Verify build + tests green; no behavior should change. -## Phase 2 — Roll out by feature folder +### Effort 2 — Router adoption (per feature, after Effort 1) -Apply the Phase 0/1 template feature-by-feature, leaf-data first (fewest -dependents): +Per feature, on routes that own the data: -1. `festivals`, `festivals/editions` -2. `sets`, `stages`, `genres` -3. `festival-info`, `custom-links`, `knowledge` -4. `artists` (+ `artists/notes`) -5. `groups` (+ `groups/invites`), `voting` -6. `auth` (see caveat 1) +- Add `loader` using `context.queryClient.ensureQueryData(factory(...))`. +- Replace non-conditional `useQuery` reads with `useSuspenseQuery(factory(...))`; + delete the null-guards that only existed because data could be `undefined`. +- Add an `errorComponent` to the route. +- Pilot first slice end-to-end: festival → edition → set + (`$festivalSlug` → `$editionSlug` → `$setSlug`). This is the reference + implementation reviewers sign off on before rollout. -Per feature: add `queryOptions` factory → repoint the hook → add `loader` + -`ensureQueryData` to the owning route(s) → switch required reads to -`useSuspenseQuery` → add `errorComponent`. +### Effort 3 — Auth hoist (separate; its own design + ADR) ---- +Build per the **Auth hoist** decision above. Sequence the reactivity carefully +(`router.invalidate()` on auth state change) and verify the 23 `useAuth` +consumers still behave identically. -## Phase 3 — Cleanup & guardrails +### Cleanup & guardrails (after each effort) - Remove dead null-checks / `enabled` guards that only existed because data could - be `undefined`. -- Update `CLAUDE.md` data-fetching guideline (point 4) to describe the - loader-prefetch pattern; optionally add a convention: "queries consumed by a - route with a loader should use a `queryOptions` factory + `useSuspenseQuery`." + be `undefined` (Effort 2 only). +- Update `CLAUDE.md`: point data-fetching guidance at `src/api/` and the + loader-prefetch pattern. --- ## Caveats specific to this codebase (read these) -1. **Auth-dependent queries can't be naively prefetched in loaders.** - `useProfile`, `useUserPermissions`, `useUserVotes`, `useGroupVotes` depend on - the authenticated user, which comes from `AuthContext` rendered *inside* - `__root`'s component — not available in loaders. Options: keep these as - `useQuery` (no loader prefetch), **or** resolve auth/session into the router - context via `beforeLoad` so `context.user` is available to loaders. - **Recommendation: leave auth queries as-is in the first pass.** - -2. **`enabled` queries don't map to `useSuspenseQuery`.** Anywhere a query is - conditional (`enabled: !!x`), suspense is wrong — keep `useQuery`. Many hooks - use this pattern. - -3. **Realtime subscriptions** write into the Query cache (per CLAUDE.md). The - behavior is unaffected, but confirm `staleTime` doesn't fight live updates. - +1. **Auth-dependent queries** — addressed by Effort 3. Until then they stay + `useQuery` (no loader prefetch). +2. **`enabled` queries don't map to `useSuspenseQuery`.** 25 files use the + pattern; conditional queries keep `useQuery` even after the router pass. +3. **Realtime subscriptions** write into the Query cache (per CLAUDE.md). + Confirm `staleTime` doesn't fight live updates. 4. **Subdomain `rewrite` logic** in `main.tsx` makes `$festivalSlug` implicit on `*.getupline.com`. Loaders read `params.festivalSlug` — verify rewrites populate params correctly under preload/suspense. +5. **No `export ... from` and no barrel files** (CLAUDE.md). Import factories + directly from each endpoint file. + +--- + +## Open questions (resolve at build time) -5. **No `export ... from` and no barrel files** (CLAUDE.md). Import - `queryOptions` factories directly from each `types.ts`. +- **`staleTime`.** Global is `5_000`. Confirm it's intended, or override + per-query in factories so loader-prefetched data doesn't instantly refetch. +- **`errorComponent`: global vs per-route.** Today routes lean only on the global + not-found + pending components. Decide whether to add a global default error + component or wire them per-route in Effort 2. +- **Auth reactivity mechanics.** Exact `router.invalidate()` wiring and where the + `onAuthStateChange` subscription lives once `AuthProvider` is narrowed. --- @@ -184,15 +251,16 @@ Per feature: add `queryOptions` factory → repoint the hook → add `loader` + - Query hooks end in `Query`, mutation hooks end in `Mutation`. - Prefer `mutation.mutate(vars, { onSuccess, onError })` over `await mutateAsync` in try/catch. -- No comments unless necessary; no barrel exports. +- No comments unless necessary; no barrel exports; import directly from file path. - Auto-commit code changes per user message. --- ## Effort estimate -~73 hooks + ~80 routes. Phase 0–1 is ~½ day and de-risks the rest; Phases 2–3 -are mechanical and parallelizable by feature folder. +73 endpoint files + 31 routes. Effort 1 (restructure) is the bulk but mechanical +and parallelizable by feature folder. Effort 2 is route-by-route. Effort 3 (auth +hoist) is small in files but high in care. ## Quick verification commands From bfae8ac56a1a46b5f4ddc1486b31f05512841b32 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 16:05:03 +0000 Subject: [PATCH 3/4] docs: add ADR-0001 for src/api modules; link auth follow-up #50 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01K513rsQz6Lg1HbbfYiafrE --- docs/HANDOFF_TANSTACK_ROUTER_QUERY.md | 4 ++-- docs/adr/0001-api-modules.md | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0001-api-modules.md diff --git a/docs/HANDOFF_TANSTACK_ROUTER_QUERY.md b/docs/HANDOFF_TANSTACK_ROUTER_QUERY.md index a3b69f7c..bf62f873 100644 --- a/docs/HANDOFF_TANSTACK_ROUTER_QUERY.md +++ b/docs/HANDOFF_TANSTACK_ROUTER_QUERY.md @@ -134,7 +134,7 @@ src/api/festivals/ reads to `useSuspenseQuery`, add an `errorComponent`. `enabled`/conditional queries stay `useQuery` (see caveat 2). 3. **Effort 3 — Auth hoist** (enables auth-dependent queries in the router). See - below; this is its own design and ADR. + below; tracked as its own follow-up ticket (#50). Restructure slices land first; router slices follow. Do **not** combine a feature's restructure and its router change in the same slice. @@ -202,7 +202,7 @@ Per feature, on routes that own the data: (`$festivalSlug` → `$editionSlug` → `$setSlug`). This is the reference implementation reviewers sign off on before rollout. -### Effort 3 — Auth hoist (separate; its own design + ADR) +### Effort 3 — Auth hoist (separate; tracked in #50) Build per the **Auth hoist** decision above. Sequence the reactivity carefully (`router.invalidate()` on auth state change) and verify the 23 `useAuth` diff --git a/docs/adr/0001-api-modules.md b/docs/adr/0001-api-modules.md new file mode 100644 index 00000000..41b637de --- /dev/null +++ b/docs/adr/0001-api-modules.md @@ -0,0 +1,22 @@ +# Feature-sliced `src/api` modules for data access + +The data-access layer moves from `src/hooks/queries/` to a feature-sliced +**`src/api/`** module. Each feature is a flat directory (nested sub-features like +`festivals/editions/` are promoted to top-level siblings such as `editions/`). +Per feature, a shared `types.ts` holds the entity Row type plus the query-key +factory; every endpoint is one self-contained file (keeping its `use`-prefixed +name) that holds, together, the endpoint's request/response types, the +`fetchX`/mutate function, a per-file `queryOptions` factory, and the hook that +consumes that factory. + +We chose this over keeping `src/hooks/queries/` and merely adding factories +because we want a single source of truth per query that feeds both route loaders +(`ensureQueryData`) and components (`useQuery`/`useSuspenseQuery`) without import +cycles, and because these files are now more than hooks. Co-locating the factory +with the hook (both importing keys/types from the pure `types.ts`) avoids the +`types.ts ↔ useX.ts` cycle that a factory-in-`types.ts` layout would create. + +This deliberately deviates from the `src/hooks/queries/` convention in CLAUDE.md +(which should be updated when the restructure lands), so it is recorded here to +explain why the layer no longer lives under `hooks/` and why `types.ts` now +carries runtime query logic. From 7710597f8d51f36a6e750c386ce7d1130d2b798b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 16:06:00 +0000 Subject: [PATCH 4/4] docs: refine auth hoist - router resolves session independently, AuthProvider intact Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01K513rsQz6Lg1HbbfYiafrE --- docs/HANDOFF_TANSTACK_ROUTER_QUERY.md | 33 +++++++++++++++------------ 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/HANDOFF_TANSTACK_ROUTER_QUERY.md b/docs/HANDOFF_TANSTACK_ROUTER_QUERY.md index bf62f873..7a29a1cb 100644 --- a/docs/HANDOFF_TANSTACK_ROUTER_QUERY.md +++ b/docs/HANDOFF_TANSTACK_ROUTER_QUERY.md @@ -145,18 +145,20 @@ Auth-dependent queries (`useProfile`, `useUserPermissions`, `useUserVotes`, `useGroupVotes`) can't prefetch in loaders today because `AuthProvider` (and the `user` it owns) is rendered *inside* `__root`'s component, below the router. -Decision: **resolve the session into router context, narrow `AuthProvider`.** - -- Root `beforeLoad` resolves the session into `context.user`, making it available - to loaders. -- `supabase.auth.onAuthStateChange` calls `router.invalidate()` for reactivity - (login/logout re-runs `beforeLoad` + loaders). -- `AuthProvider` **stays** but stops owning the session listener. It reads `user` - from router context and keeps everything else it does today: `profile`, - `needsOnboarding`, the `AuthDialog` modal, `SIGNED_IN` invite processing, - `signOut`. -- The **23 `useAuth` consumers keep the same `useAuth` API** — only the source of - `user` changes. This removes today's duplicate session source. +Decision: **the router resolves the session independently; `AuthProvider` stays +fully intact.** + +- Root `beforeLoad` calls `supabase.auth.getSession()` to put `user` in + `context.user`, making it available to loaders. +- A router-side `supabase.auth.onAuthStateChange` listener calls + `router.invalidate()` for reactivity (login/logout re-runs `beforeLoad` + + loaders). +- `AuthProvider` is **not** changed: it keeps its own session state, `profile`, + `needsOnboarding`, the `AuthDialog` modal, `SIGNED_IN` invite processing, and + `signOut`. The **23 `useAuth` consumers are untouched.** +- Both the router and `AuthProvider` project from the supabase client (the real + source of truth). This accepts a second `getSession`/listener as the deliberate + price of keeping the heavily-used provider risk-free. --- @@ -204,9 +206,10 @@ Per feature, on routes that own the data: ### Effort 3 — Auth hoist (separate; tracked in #50) -Build per the **Auth hoist** decision above. Sequence the reactivity carefully -(`router.invalidate()` on auth state change) and verify the 23 `useAuth` -consumers still behave identically. +Build per the **Auth hoist** decision above. The router resolves the session +independently and `AuthProvider` stays intact, so the 23 `useAuth` consumers +need no changes. Sequence the reactivity carefully (`router.invalidate()` on +auth state change). ### Cleanup & guardrails (after each effort)