From bfc043fd485e57e240ea4af34e0c062b47d169e6 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 11 Apr 2026 14:20:36 -0400 Subject: [PATCH 1/2] Fix Worker hang and hydration mismatches on protected routes --- apps/dashboard/package.json | 16 ++--- .../components/layouts/dashboard-layout.tsx | 34 +++++----- .../components/layouts/dashboard-topbar.tsx | 20 +++++- .../components/pulls/review/review-page.tsx | 15 +---- apps/dashboard/src/lib/auth-runtime.ts | 13 +++- apps/dashboard/src/lib/github-app.server.ts | 2 + .../src/lib/github-request-policy.ts | 35 +++++++++++ apps/dashboard/src/lib/github.query.ts | 16 +---- apps/dashboard/src/lib/github.server.test.ts | 19 +++--- apps/dashboard/src/lib/github.server.ts | 54 +--------------- apps/dashboard/src/lib/tab-store.ts | 4 +- .../src/lib/use-github-revalidation.ts | 40 ++++++------ .../$owner/$repo/issues.$issueId.tsx | 34 ++++------ .../_protected/$owner/$repo/pull.$pullId.tsx | 42 +++++-------- .../$owner/$repo/review.$pullId.tsx | 63 +++++++------------ .../dashboard/src/routes/_protected/index.tsx | 1 + .../src/routes/_protected/issues.tsx | 1 + .../dashboard/src/routes/_protected/pulls.tsx | 1 + .../src/routes/_protected/reviews.tsx | 1 + 19 files changed, 184 insertions(+), 227 deletions(-) create mode 100644 apps/dashboard/src/lib/github-request-policy.ts diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index c6b7f43..6153ed9 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -26,13 +26,13 @@ "@diffkit/ui": "workspace:*", "@pierre/diffs": "^1.1.12", "@tailwindcss/vite": "^4.1.18", - "@tanstack/react-devtools": "latest", - "@tanstack/react-query": "latest", - "@tanstack/react-router": "latest", - "@tanstack/react-router-devtools": "latest", - "@tanstack/react-router-ssr-query": "latest", - "@tanstack/react-start": "latest", - "@tanstack/router-plugin": "^1.132.0", + "@tanstack/react-devtools": "~0.10.2", + "@tanstack/react-query": "~5.97.0", + "@tanstack/react-router": "~1.168.13", + "@tanstack/react-router-devtools": "~1.166.11", + "@tanstack/react-router-ssr-query": "~1.166.10", + "@tanstack/react-start": "~1.167.23", + "@tanstack/router-plugin": "~1.167.12", "agentation": "^3.0.2", "better-auth": "^1.6.0", "drizzle-orm": "^0.45.2", @@ -47,7 +47,7 @@ "@biomejs/biome": "2.4.5", "@cloudflare/workers-types": "^4.20260405.1", "@diffkit/typescript-config": "workspace:*", - "@tanstack/devtools-vite": "latest", + "@tanstack/devtools-vite": "~0.6.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", "@types/node": "^22.10.2", diff --git a/apps/dashboard/src/components/layouts/dashboard-layout.tsx b/apps/dashboard/src/components/layouts/dashboard-layout.tsx index b780a99..fbb326b 100644 --- a/apps/dashboard/src/components/layouts/dashboard-layout.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-layout.tsx @@ -49,18 +49,20 @@ export function DashboardLayout() { ...githubMyIssuesQueryOptions(scope), enabled: hasMounted, }); - const pullCount = pullsQuery.data - ? pullsQuery.data.reviewRequested.length + - pullsQuery.data.assigned.length + - pullsQuery.data.authored.length + - pullsQuery.data.mentioned.length + - pullsQuery.data.involved.length - : undefined; - const issueCount = issuesQuery.data - ? issuesQuery.data.assigned.length + - issuesQuery.data.authored.length + - issuesQuery.data.mentioned.length - : undefined; + const pullCount = + hasMounted && pullsQuery.data + ? pullsQuery.data.reviewRequested.length + + pullsQuery.data.assigned.length + + pullsQuery.data.authored.length + + pullsQuery.data.mentioned.length + + pullsQuery.data.involved.length + : undefined; + const issueCount = + hasMounted && issuesQuery.data + ? issuesQuery.data.assigned.length + + issuesQuery.data.authored.length + + issuesQuery.data.mentioned.length + : undefined; const tabsReady = hasMounted && Boolean(pullsQuery.data && issuesQuery.data); useEffect(() => { @@ -87,7 +89,9 @@ export function DashboardLayout() { counts={{ pulls: pullCount, issues: issueCount, - reviews: pullsQuery.data?.reviewRequested.length, + reviews: hasMounted + ? pullsQuery.data?.reviewRequested.length + : undefined, }} />
@@ -104,7 +108,9 @@ export function DashboardLayout() { counts={{ pulls: pullCount, issues: issueCount, - reviews: pullsQuery.data?.reviewRequested.length, + reviews: hasMounted + ? pullsQuery.data?.reviewRequested.length + : undefined, }} /> diff --git a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx index fc19067..aeb0b39 100644 --- a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx @@ -108,9 +108,23 @@ export function DashboardTopbar({ useEffect(() => { if (!tabsReady) return; - void Promise.allSettled( - primaryNavRoutes.map((to) => routerRef.current.preloadRoute({ to })), - ); + // Preload routes serially to avoid a burst of concurrent server function + // RPCs that can overwhelm the Cloudflare Worker. + let cancelled = false; + (async () => { + for (const to of primaryNavRoutes) { + if (cancelled) break; + try { + await routerRef.current.preloadRoute({ to }); + } catch { + // preload is best-effort + } + } + })(); + + return () => { + cancelled = true; + }; }, [tabsReady]); function navigateToTab(tab: Tab | undefined) { diff --git a/apps/dashboard/src/components/pulls/review/review-page.tsx b/apps/dashboard/src/components/pulls/review/review-page.tsx index a9af473..c7d412c 100644 --- a/apps/dashboard/src/components/pulls/review/review-page.tsx +++ b/apps/dashboard/src/components/pulls/review/review-page.tsx @@ -87,7 +87,6 @@ function useIsDesktop() { export function ReviewPage() { const { user } = routeApi.useRouteContext(); - const loaderData = routeApi.useLoaderData(); const { owner, repo, pullId } = routeApi.useParams(); const pullNumber = Number(pullId); const scope = { userId: user.id }; @@ -110,7 +109,6 @@ export function ReviewPage() { refetchOnWindowFocus: false, }); - const firstFilesPage = loaderData?.firstFilesPage ?? null; const filesQuery = useInfiniteQuery({ queryKey: githubQueryKeys.pulls.files(scope, input), initialPageParam: 1, @@ -123,14 +121,6 @@ export function ReviewPage() { }, }), getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined, - ...(firstFilesPage - ? { - initialData: { - pages: [firstFilesPage], - pageParams: [1], - }, - } - : {}), refetchOnMount: false, refetchOnWindowFocus: false, }); @@ -142,9 +132,8 @@ export function ReviewPage() { refetchOnWindowFocus: false, }); - const pr = pageQuery.data?.detail ?? loaderData?.pageData?.detail ?? null; - const sidebarFiles = - fileSummariesQuery.data ?? loaderData?.fileSummaries ?? []; + const pr = pageQuery.data?.detail ?? null; + const sidebarFiles = fileSummariesQuery.data ?? []; const diffFiles = useMemo( () => filesQuery.data?.pages.flatMap((page) => page.files) ?? [], [filesQuery.data], diff --git a/apps/dashboard/src/lib/auth-runtime.ts b/apps/dashboard/src/lib/auth-runtime.ts index adb2343..b166e1c 100644 --- a/apps/dashboard/src/lib/auth-runtime.ts +++ b/apps/dashboard/src/lib/auth-runtime.ts @@ -12,6 +12,7 @@ import { getGitHubAppUserAccessTokenByUserId, getGitHubOAuthConfig, } from "./github-app.server"; +import { configureGitHubRequestPolicies } from "./github-request-policy"; const authDb = drizzle(env.DB, { schema }); @@ -52,11 +53,15 @@ export async function getRequestSession() { export async function getGitHubClientByUserId( userId: string, ): Promise { - return new Octokit({ + const octokit = new Octokit({ auth: await getGitHubAccessTokenByUserId(userId), retry: { enabled: false }, throttle: { enabled: false }, }); + + configureGitHubRequestPolicies(octokit); + + return octokit; } export async function getGitHubAppUserClientByUserId( @@ -67,9 +72,13 @@ export async function getGitHubAppUserClientByUserId( return null; } - return new Octokit({ + const octokit = new Octokit({ auth: token, retry: { enabled: false }, throttle: { enabled: false }, }); + + configureGitHubRequestPolicies(octokit); + + return octokit; } diff --git a/apps/dashboard/src/lib/github-app.server.ts b/apps/dashboard/src/lib/github-app.server.ts index 6ea0cda..d0e3629 100644 --- a/apps/dashboard/src/lib/github-app.server.ts +++ b/apps/dashboard/src/lib/github-app.server.ts @@ -4,6 +4,7 @@ import { and, eq } from "drizzle-orm"; import { getDb } from "../db"; import { account } from "../db/schema"; import { normalizeGitHubAppPrivateKey } from "./github-private-key"; +import { GITHUB_REQUEST_TIMEOUT_MS } from "./github-request-policy"; type WorkerEnvRecord = typeof env & Record; @@ -150,6 +151,7 @@ async function requestGitHubAppUserToken(params: Record) { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded", }, + signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS), body: new URLSearchParams(params), }); const payload = (await response.json()) as GitHubTokenResponse; diff --git a/apps/dashboard/src/lib/github-request-policy.ts b/apps/dashboard/src/lib/github-request-policy.ts new file mode 100644 index 0000000..f792d0d --- /dev/null +++ b/apps/dashboard/src/lib/github-request-policy.ts @@ -0,0 +1,35 @@ +import type { Octokit as OctokitType } from "octokit"; + +const GITHUB_READ_RETRY_COUNT = 1; +export const GITHUB_REQUEST_TIMEOUT_MS = 12_000; + +type GitHubRequestOptions = Parameters< + OctokitType["hook"]["before"] +>[1] extends (options: infer Options) => unknown + ? Options & { + method?: string; + request?: { + retries?: number; + signal?: AbortSignal; + }; + } + : never; + +function isSafeGitHubRetryMethod(method: string | undefined) { + return method === "GET" || method === "HEAD" || method === "OPTIONS"; +} + +function createGitHubRequestTimeoutSignal() { + return AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS); +} + +export function configureGitHubRequestPolicies(octokit: OctokitType) { + octokit.hook.before("request", (options: GitHubRequestOptions) => { + const requestOptions = options.request ?? {}; + options.request = requestOptions; + requestOptions.retries = isSafeGitHubRetryMethod(options.method) + ? GITHUB_READ_RETRY_COUNT + : 0; + requestOptions.signal ??= createGitHubRequestTimeoutSignal(); + }); +} diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index 99bca72..c378c25 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -253,7 +253,6 @@ export function githubPullDetailQueryOptions( queryFn: () => getPullFromRepo({ data: input }), staleTime: githubCachePolicy.detail.staleTimeMs, gcTime: githubCachePolicy.detail.gcTimeMs, - refetchOnMount: "always", meta: tabPersistedMeta, }); } @@ -265,10 +264,8 @@ export function githubPullPageQueryOptions( return queryOptions({ queryKey: githubQueryKeys.pulls.page(scope, input), queryFn: () => getPullPageData({ data: input }), - staleTime: githubCachePolicy.activity.staleTimeMs, + staleTime: githubCachePolicy.detail.staleTimeMs, gcTime: githubCachePolicy.detail.gcTimeMs, - refetchOnMount: "always", - refetchOnWindowFocus: "always", meta: tabPersistedMeta, }); } @@ -282,7 +279,6 @@ export function githubPullCommentsQueryOptions( queryFn: () => getPullComments({ data: input }), staleTime: githubCachePolicy.activity.staleTimeMs, gcTime: githubCachePolicy.activity.gcTimeMs, - refetchOnMount: "always", meta: tabPersistedMeta, }); } @@ -296,7 +292,6 @@ export function githubPullStatusQueryOptions( queryFn: () => getPullStatus({ data: input }), staleTime: githubCachePolicy.status.staleTimeMs, gcTime: githubCachePolicy.status.gcTimeMs, - refetchOnMount: "always", meta: tabPersistedMeta, }); } @@ -310,7 +305,6 @@ export function githubPullFilesQueryOptions( queryFn: () => getPullFiles({ data: input }), staleTime: githubCachePolicy.detail.staleTimeMs, gcTime: githubCachePolicy.detail.gcTimeMs, - refetchOnMount: "always", meta: tabPersistedMeta, }); } @@ -324,7 +318,6 @@ export function githubPullFileSummariesQueryOptions( queryFn: () => getPullFileSummaries({ data: input }), staleTime: githubCachePolicy.detail.staleTimeMs, gcTime: githubCachePolicy.detail.gcTimeMs, - refetchOnMount: "always", meta: tabPersistedMeta, }); } @@ -338,7 +331,6 @@ export function githubPullReviewCommentsQueryOptions( queryFn: () => getPullReviewComments({ data: input }), staleTime: githubCachePolicy.activity.staleTimeMs, gcTime: githubCachePolicy.activity.gcTimeMs, - refetchOnMount: "always", meta: tabPersistedMeta, }); } @@ -424,7 +416,6 @@ export function githubIssueDetailQueryOptions( queryFn: () => getIssueFromRepo({ data: input }), staleTime: githubCachePolicy.detail.staleTimeMs, gcTime: githubCachePolicy.detail.gcTimeMs, - refetchOnMount: "always", meta: tabPersistedMeta, }); } @@ -436,10 +427,8 @@ export function githubIssuePageQueryOptions( return queryOptions({ queryKey: githubQueryKeys.issues.page(scope, input), queryFn: () => getIssuePageData({ data: input }), - staleTime: githubCachePolicy.activity.staleTimeMs, + staleTime: githubCachePolicy.detail.staleTimeMs, gcTime: githubCachePolicy.detail.gcTimeMs, - refetchOnMount: "always", - refetchOnWindowFocus: "always", meta: tabPersistedMeta, }); } @@ -453,7 +442,6 @@ export function githubIssueCommentsQueryOptions( queryFn: () => getIssueComments({ data: input }), staleTime: githubCachePolicy.activity.staleTimeMs, gcTime: githubCachePolicy.activity.gcTimeMs, - refetchOnMount: "always", meta: tabPersistedMeta, }); } diff --git a/apps/dashboard/src/lib/github.server.test.ts b/apps/dashboard/src/lib/github.server.test.ts index f530d06..3739bab 100644 --- a/apps/dashboard/src/lib/github.server.test.ts +++ b/apps/dashboard/src/lib/github.server.test.ts @@ -50,7 +50,7 @@ beforeEach(() => { }); describe("getGitHubClient", () => { - it("configures Octokit throttling and safe-method retries", async () => { + it("configures Octokit throttling, bounded retries, and request timeouts", async () => { const { getGitHubClient } = await import("./github.server"); await getGitHubClient("user-123"); @@ -102,23 +102,28 @@ describe("getGitHubClient", () => { expect(instance.hookBefore).toHaveBeenCalledTimes(1); const [hookEvent, hookHandler] = instance.hookBefore.mock.calls[0] as [ string, - (options: { method?: string; request?: { retries?: number } }) => void, + (options: { + method?: string; + request?: { retries?: number; signal?: AbortSignal }; + }) => void, ]; expect(hookEvent).toBe("request"); const getOptions = { method: "GET" } as { method?: string; - request?: { retries?: number }; + request?: { retries?: number; signal?: AbortSignal }; }; hookHandler(getOptions); - expect(getOptions.request?.retries).toBe(2); + expect(getOptions.request?.retries).toBe(1); + expect(getOptions.request?.signal).toBeInstanceOf(AbortSignal); const postOptions = { method: "POST" } as { method?: string; - request?: { retries?: number }; + request?: { retries?: number; signal?: AbortSignal }; }; hookHandler(postOptions); expect(postOptions.request?.retries).toBe(0); + expect(postOptions.request?.signal).toBeInstanceOf(AbortSignal); expect( options.throttle.onRateLimit( @@ -129,7 +134,7 @@ describe("getGitHubClient", () => { }, 0, ), - ).toBe(true); + ).toBe(false); expect( options.throttle.onRateLimit( 30, @@ -149,7 +154,7 @@ describe("getGitHubClient", () => { ), ).toBe(false); expect(instance.log.warn).toHaveBeenCalled(); - expect(instance.log.info).toHaveBeenCalledTimes(1); + expect(instance.log.info).not.toHaveBeenCalled(); }); it("creates GitHub App installation clients from app credentials", async () => { diff --git a/apps/dashboard/src/lib/github.server.ts b/apps/dashboard/src/lib/github.server.ts index cf371b7..c1024bb 100644 --- a/apps/dashboard/src/lib/github.server.ts +++ b/apps/dashboard/src/lib/github.server.ts @@ -5,10 +5,9 @@ import { getGitHubAppId, getGitHubAppPrivateKey, } from "./github-app.server"; +import { configureGitHubRequestPolicies } from "./github-request-policy"; const GITHUB_CLIENT_USER_AGENT = "quickhub-dashboard"; -const GITHUB_READ_RETRY_COUNT = 2; -const GITHUB_RATE_LIMIT_RETRY_COUNT = 1; const GITHUB_SECONDARY_RATE_LIMIT_FALLBACK_SECONDS = 60; type GitHubThrottleRequestOptions = { @@ -18,33 +17,6 @@ type GitHubThrottleRequestOptions = { type GitHubThrottleClient = Pick; -function isSafeGitHubRetryMethod(method: string | undefined) { - return method === "GET" || method === "HEAD" || method === "OPTIONS"; -} - -function shouldRetryGitHubRateLimitedRequest({ - method, - retryCount, -}: { - method: string | undefined; - retryCount: number; -}) { - return ( - isSafeGitHubRetryMethod(method) && - retryCount < GITHUB_RATE_LIMIT_RETRY_COUNT - ); -} - -function configureGitHubRequestPolicies(octokit: OctokitType) { - octokit.hook.before("request", (options) => { - const requestOptions = options.request ?? {}; - options.request = requestOptions; - requestOptions.retries = isSafeGitHubRetryMethod(options.method) - ? GITHUB_READ_RETRY_COUNT - : 0; - }); -} - export async function getGitHubClient(userId: string): Promise { const octokit = new Octokit({ auth: await getGitHubAccessTokenByUserId(userId), @@ -65,18 +37,6 @@ export async function getGitHubClient(userId: string): Promise { `GitHub rate limit for ${options.method} ${options.url}; retryAfter=${retryAfter}s retryCount=${retryCount}`, ); - if ( - shouldRetryGitHubRateLimitedRequest({ - method: options.method, - retryCount, - }) - ) { - throttledOctokit.log.info( - `Retrying ${options.method} ${options.url} after ${retryAfter}s`, - ); - return true; - } - return false; }, onSecondaryRateLimit: ( @@ -89,18 +49,6 @@ export async function getGitHubClient(userId: string): Promise { `GitHub secondary rate limit for ${options.method} ${options.url}; retryAfter=${retryAfter}s retryCount=${retryCount}`, ); - if ( - shouldRetryGitHubRateLimitedRequest({ - method: options.method, - retryCount, - }) - ) { - throttledOctokit.log.info( - `Retrying ${options.method} ${options.url} after secondary rate limit (${retryAfter}s)`, - ); - return true; - } - return false; }, }, diff --git a/apps/dashboard/src/lib/tab-store.ts b/apps/dashboard/src/lib/tab-store.ts index d5703b9..5ba720f 100644 --- a/apps/dashboard/src/lib/tab-store.ts +++ b/apps/dashboard/src/lib/tab-store.ts @@ -76,6 +76,8 @@ export function removeTab(id: string) { emitChange(); } +const emptyTabs: Tab[] = []; + export function useTabs() { - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + return useSyncExternalStore(subscribe, getSnapshot, () => emptyTabs); } diff --git a/apps/dashboard/src/lib/use-github-revalidation.ts b/apps/dashboard/src/lib/use-github-revalidation.ts index 06cf9f4..cb7520a 100644 --- a/apps/dashboard/src/lib/use-github-revalidation.ts +++ b/apps/dashboard/src/lib/use-github-revalidation.ts @@ -10,6 +10,7 @@ import { import { type Tab, useTabs } from "./tab-store"; const GITHUB_REVALIDATION_POLL_INTERVAL_MS = 10_000; +const GITHUB_REVALIDATION_INITIAL_DELAY_MS = 5_000; function getUniqueSignalKeys(tabs: Tab[]) { return Array.from( @@ -104,8 +105,8 @@ export function useGitHubRevalidation(userId: string) { const signalsByKey = new Map( records.map((record) => [record.signalKey, record.updatedAt]), ); - const invalidations: Promise[] = []; + // Invalidate list queries first (lightweight) const pullsMineUpdatedAt = signalsByKey.get(githubRevalidationSignalKeys.pullsMine) ?? 0; if ( @@ -115,11 +116,9 @@ export function useGitHubRevalidation(userId: string) { debug("github-revalidation", "invalidating pull list queries", { pullsMineUpdatedAt, }); - invalidations.push( - queryClient.invalidateQueries({ - queryKey: githubQueryKeys.pulls.mine(scope), - }), - ); + await queryClient.invalidateQueries({ + queryKey: githubQueryKeys.pulls.mine(scope), + }); } const issuesMineUpdatedAt = @@ -131,14 +130,16 @@ export function useGitHubRevalidation(userId: string) { debug("github-revalidation", "invalidating issue list queries", { issuesMineUpdatedAt, }); - invalidations.push( - queryClient.invalidateQueries({ - queryKey: githubQueryKeys.issues.mine(scope), - }), - ); + await queryClient.invalidateQueries({ + queryKey: githubQueryKeys.issues.mine(scope), + }); } + // Invalidate tab queries serially to avoid a burst of concurrent + // server function RPCs that can overwhelm the Worker. for (const tab of tabs) { + if (cancelled) break; + const signalKey = getGitHubRevalidationSignalKeysForTab(tab)[0]; const updatedAt = signalsByKey.get(signalKey) ?? 0; if (updatedAt === 0) { @@ -157,9 +158,7 @@ export function useGitHubRevalidation(userId: string) { signalKey, tabId: tab.id, }); - invalidations.push( - invalidatePullTabQueries(queryClient, scope, tab), - ); + await invalidatePullTabQueries(queryClient, scope, tab); } continue; } @@ -175,13 +174,9 @@ export function useGitHubRevalidation(userId: string) { signalKey, tabId: tab.id, }); - invalidations.push( - invalidateIssueTabQueries(queryClient, scope, tab), - ); + await invalidateIssueTabQueries(queryClient, scope, tab); } } - - await Promise.all(invalidations); } catch (error) { debug("github-revalidation", "poll failed", { error: error instanceof Error ? error.message : String(error), @@ -196,7 +191,12 @@ export function useGitHubRevalidation(userId: string) { } }; - void pollSignals(); + // Delay the first poll so it doesn't collide with initial data loading + // (route loaders + preloading) which already makes server function RPCs. + timeoutId = window.setTimeout( + pollSignals, + GITHUB_REVALIDATION_INITIAL_DELAY_MS, + ); return () => { cancelled = true; diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx index 7d4cd2f..997ff0e 100644 --- a/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/issues.$issueId.tsx @@ -1,12 +1,13 @@ import { createFileRoute } from "@tanstack/react-router"; import { IssueDetailPage } from "#/components/issues/detail/issue-detail-page"; import { githubIssuePageQueryOptions } from "#/lib/github.query"; -import { buildSeo, formatPageTitle, summarizeText } from "#/lib/seo"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; export const Route = createFileRoute( "/_protected/$owner/$repo/issues/$issueId", )({ - loader: async ({ context, params }) => { + ssr: false, + loader: ({ context, params }) => { const issueNumber = Number(params.issueId); const scope = { userId: context.user.id }; const pageOptions = githubIssuePageQueryOptions(scope, { @@ -15,10 +16,8 @@ export const Route = createFileRoute( issueNumber, }); + // Clean up null cache entries (issue not found) const cachedData = context.queryClient.getQueryData(pageOptions.queryKey); - if (cachedData !== null && cachedData !== undefined) { - return cachedData; - } if (cachedData === null) { context.queryClient.removeQueries({ queryKey: pageOptions.queryKey, @@ -26,25 +25,16 @@ export const Route = createFileRoute( }); } - return context.queryClient.ensureQueryData(pageOptions); + // Never block navigation — fire prefetch and let the component + // show cached data instantly or a skeleton while loading. + void context.queryClient.prefetchQuery(pageOptions); }, - head: ({ loaderData, match, params }) => { - const issue = loaderData?.detail; - const issueTitle = issue - ? formatPageTitle(`Issue #${issue.number}: ${issue.title}`) - : formatPageTitle(`Issue #${params.issueId}`); - - return buildSeo({ + head: ({ match, params }) => + buildSeo({ path: match.pathname, - title: issueTitle, - description: issue - ? summarizeText( - issue.body, - `Private GitHub issue #${issue.number} in ${params.owner}/${params.repo}.`, - ) - : `Private GitHub issue #${params.issueId} in ${params.owner}/${params.repo}.`, + title: formatPageTitle(`Issue #${params.issueId}`), + description: `Private GitHub issue #${params.issueId} in ${params.owner}/${params.repo}.`, robots: "noindex", - }); - }, + }), component: IssueDetailPage, }); diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx index 3b99431..07bbdc2 100644 --- a/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/pull.$pullId.tsx @@ -4,10 +4,11 @@ import { githubPullPageQueryOptions, githubViewerQueryOptions, } from "#/lib/github.query"; -import { buildSeo, formatPageTitle, summarizeText } from "#/lib/seo"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; export const Route = createFileRoute("/_protected/$owner/$repo/pull/$pullId")({ - loader: async ({ context, params }) => { + ssr: false, + loader: ({ context, params }) => { const pullNumber = Number(params.pullId); const scope = { userId: context.user.id }; const pageOptions = githubPullPageQueryOptions(scope, { @@ -16,41 +17,26 @@ export const Route = createFileRoute("/_protected/$owner/$repo/pull/$pullId")({ pullNumber, }); + // Clean up broken cache entries (no detail) const cachedData = context.queryClient.getQueryData(pageOptions.queryKey); - if (cachedData?.detail) { - void context.queryClient.ensureQueryData(githubViewerQueryOptions(scope)); - return cachedData; - } - if (cachedData !== undefined) { + if (cachedData !== undefined && !cachedData?.detail) { context.queryClient.removeQueries({ queryKey: pageOptions.queryKey, exact: true, }); } - const [pageData] = await Promise.all([ - context.queryClient.ensureQueryData(pageOptions), - context.queryClient.ensureQueryData(githubViewerQueryOptions(scope)), - ]); - return pageData; + // Never block navigation — fire prefetches and let the component + // show cached data instantly or a skeleton while loading. + void context.queryClient.prefetchQuery(pageOptions); + void context.queryClient.prefetchQuery(githubViewerQueryOptions(scope)); }, - head: ({ loaderData, match, params }) => { - const pull = loaderData?.detail; - const title = pull - ? formatPageTitle(pull.title) - : formatPageTitle(`PR #${params.pullId}`); - - return buildSeo({ + head: ({ match, params }) => + buildSeo({ path: match.pathname, - title, - description: pull - ? summarizeText( - pull.body, - `Private pull request #${pull.number} in ${params.owner}/${params.repo}.`, - ) - : `Private pull request #${params.pullId} in ${params.owner}/${params.repo}.`, + title: formatPageTitle(`PR #${params.pullId}`), + description: `Private pull request #${params.pullId} in ${params.owner}/${params.repo}.`, robots: "noindex", - }); - }, + }), component: PullDetailPage, }); diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx index bf9c76e..1a5e77c 100644 --- a/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/review.$pullId.tsx @@ -6,13 +6,14 @@ import { githubPullPageQueryOptions, githubQueryKeys, } from "#/lib/github.query"; -import { buildSeo, formatPageTitle, summarizeText } from "#/lib/seo"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; const PULL_FILES_PAGE_SIZE = 25; export const Route = createFileRoute("/_protected/$owner/$repo/review/$pullId")( { - loader: async ({ context, params }) => { + ssr: false, + loader: ({ context, params }) => { const pullNumber = Number(params.pullId); const scope = { userId: context.user.id }; const input = { owner: params.owner, repo: params.repo, pullNumber }; @@ -22,59 +23,37 @@ export const Route = createFileRoute("/_protected/$owner/$repo/review/$pullId")( input, ); - let cachedPageData = context.queryClient.getQueryData( + // Clean up broken cache entries (no detail) + const cachedPageData = context.queryClient.getQueryData( pageOptions.queryKey, ); - const cachedFileSummaries = context.queryClient.getQueryData( - fileSummariesOptions.queryKey, - ); if (cachedPageData !== undefined && !cachedPageData?.detail) { context.queryClient.removeQueries({ queryKey: pageOptions.queryKey, exact: true, }); - cachedPageData = undefined; } - // Check if infinite query already has data - const filesQueryKey = githubQueryKeys.pulls.files(scope, input); - const cachedFilesData = context.queryClient.getQueryData(filesQueryKey); - - const [pageData, fileSummaries, firstFilesPage] = await Promise.all([ - cachedPageData ?? context.queryClient.ensureQueryData(pageOptions), - cachedFileSummaries ?? - context.queryClient.ensureQueryData(fileSummariesOptions), - cachedFilesData - ? null - : getPullFiles({ - data: { - ...input, - page: 1, - perPage: PULL_FILES_PAGE_SIZE, - }, - }), - ]); + // Never block navigation — fire prefetches and let the component + // show cached data instantly or a skeleton while loading. + void context.queryClient.prefetchQuery(pageOptions); + void context.queryClient.prefetchQuery(fileSummariesOptions); - return { pageData, fileSummaries, firstFilesPage }; + // Prefetch first page of files if not cached + const filesQueryKey = githubQueryKeys.pulls.files(scope, input); + if (!context.queryClient.getQueryData(filesQueryKey)) { + void getPullFiles({ + data: { ...input, page: 1, perPage: PULL_FILES_PAGE_SIZE }, + }); + } }, - head: ({ loaderData, match, params }) => { - const pull = loaderData?.pageData?.detail; - const title = pull - ? formatPageTitle(pull.title) - : formatPageTitle(`Review PR #${params.pullId}`); - - return buildSeo({ + head: ({ match, params }) => + buildSeo({ path: match.pathname, - title, - description: pull - ? summarizeText( - pull.body, - `Private code review workspace for pull request #${pull.number} in ${params.owner}/${params.repo}.`, - ) - : `Private code review workspace for pull request #${params.pullId} in ${params.owner}/${params.repo}.`, + title: formatPageTitle(`Review PR #${params.pullId}`), + description: `Private code review workspace for pull request #${params.pullId} in ${params.owner}/${params.repo}.`, robots: "noindex", - }); - }, + }), component: ReviewPage, }, ); diff --git a/apps/dashboard/src/routes/_protected/index.tsx b/apps/dashboard/src/routes/_protected/index.tsx index 4f2e9d7..27627f5 100644 --- a/apps/dashboard/src/routes/_protected/index.tsx +++ b/apps/dashboard/src/routes/_protected/index.tsx @@ -12,6 +12,7 @@ import { buildSeo, formatPageTitle } from "#/lib/seo"; import { useHasMounted } from "#/lib/use-has-mounted"; export const Route = createFileRoute("/_protected/")({ + ssr: false, loader: async ({ context }) => { const scope = { userId: context.user.id }; await Promise.all([ diff --git a/apps/dashboard/src/routes/_protected/issues.tsx b/apps/dashboard/src/routes/_protected/issues.tsx index 9bd3a96..9a0ba9f 100644 --- a/apps/dashboard/src/routes/_protected/issues.tsx +++ b/apps/dashboard/src/routes/_protected/issues.tsx @@ -27,6 +27,7 @@ import { buildSeo, formatPageTitle } from "#/lib/seo"; import { useHasMounted } from "#/lib/use-has-mounted"; export const Route = createFileRoute("/_protected/issues")({ + ssr: false, loader: async ({ context }) => { const scope = { userId: context.user.id }; const [, filterStore] = await Promise.all([ diff --git a/apps/dashboard/src/routes/_protected/pulls.tsx b/apps/dashboard/src/routes/_protected/pulls.tsx index 82cdf48..bd986c1 100644 --- a/apps/dashboard/src/routes/_protected/pulls.tsx +++ b/apps/dashboard/src/routes/_protected/pulls.tsx @@ -33,6 +33,7 @@ import { buildSeo, formatPageTitle } from "#/lib/seo"; import { useHasMounted } from "#/lib/use-has-mounted"; export const Route = createFileRoute("/_protected/pulls")({ + ssr: false, loader: async ({ context }) => { const scope = { userId: context.user.id }; const [, filterStore] = await Promise.all([ diff --git a/apps/dashboard/src/routes/_protected/reviews.tsx b/apps/dashboard/src/routes/_protected/reviews.tsx index 627ae4d..474f7f1 100644 --- a/apps/dashboard/src/routes/_protected/reviews.tsx +++ b/apps/dashboard/src/routes/_protected/reviews.tsx @@ -17,6 +17,7 @@ import { buildSeo, formatPageTitle } from "#/lib/seo"; import { useHasMounted } from "#/lib/use-has-mounted"; export const Route = createFileRoute("/_protected/reviews")({ + ssr: false, loader: async ({ context }) => { const scope = { userId: context.user.id }; const [, filterStore] = await Promise.all([ From 831e2ad1a09f70cab0769283429aca0a11e48dce Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 11 Apr 2026 14:22:40 -0400 Subject: [PATCH 2/2] Update lockfile for pinned TanStack versions --- pnpm-lock.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d826d28..3f45ad5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,25 +45,25 @@ importers: specifier: ^4.1.18 version: 4.2.2(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) '@tanstack/react-devtools': - specifier: latest + specifier: ~0.10.2 version: 0.10.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.12) '@tanstack/react-query': - specifier: latest + specifier: ~5.97.0 version: 5.97.0(react@19.2.4) '@tanstack/react-router': - specifier: latest + specifier: ~1.168.13 version: 1.168.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-router-devtools': - specifier: latest + specifier: ~1.166.11 version: 1.166.11(@tanstack/react-router@1.168.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.168.9)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-router-ssr-query': - specifier: latest + specifier: ~1.166.10 version: 1.166.10(@tanstack/query-core@5.97.0)(@tanstack/react-query@5.97.0(react@19.2.4))(@tanstack/react-router@1.168.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.168.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start': - specifier: latest + specifier: ~1.167.23 version: 1.167.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) '@tanstack/router-plugin': - specifier: ^1.132.0 + specifier: ~1.167.12 version: 1.167.12(@tanstack/react-router@1.168.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) agentation: specifier: ^3.0.2 @@ -103,7 +103,7 @@ importers: specifier: workspace:* version: link:../../packages/typescript-config '@tanstack/devtools-vite': - specifier: latest + specifier: ~0.6.0 version: 0.6.0(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) '@testing-library/dom': specifier: ^10.4.1