perf(detail): cache + parallelize + skip redundant work in the detail page handler#1214
perf(detail): cache + parallelize + skip redundant work in the detail page handler#1214priosshrsth wants to merge 16 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Per-request memoization deduplicates Copilot API calls within a single RSC / route handler invocation. Validated in measurement: 4 call sites of getInternalUser(self) collapsed to 1 actual network fetch on the detail page. Falls back to no-op outside a request context (CLI scripts, Trigger.dev jobs) so unrelated code paths are unaffected. Cache key includes the optional customApiKey so workspace-key-override calls don't collide.
When set, seeds SWR with the SSR-rendered initialTask and disables
revalidateOnMount, eliminating the redundant client-side refire of
/api/tasks/{id} after hydration. Caller opts in via the new prop; the
default behavior is unchanged so existing call sites continue to
revalidate as before.
…anged Two related changes inside getOneTask: 1. Include workflowState in the initial findFirst so a second query isn't required when the body update path is skipped. 2. Run the assignee Copilot fetch in parallel with task.count and the (now conditional) task.update. getTaskAssignee only needs the assigneeId/assigneeType already returned by findFirst, so it doesn't need to wait for the DB writes. Validated savings: ~1.5 s on staging on a single detail-page load. 3. Skip task.update entirely when the body has no signed-URL changes (image-less tasks). replaceImageSrc returns the same string when there are no <img> tags to rewrite, so a cheap string-equality check detects the no-op write. Saves another write round-trip on text-only tasks. Also drops the now-redundant getTaskAssignee call from the route handler since getOneTask returns the field directly.
…alls
The detail page handler used to fan out to 5 HTTP fetch() calls against
its own /api routes. Each loopback was a separate Vercel function
invocation, paying its own authenticate() + Copilot getTokenPayload()
round-trip, and each ran in its own react.cache scope so the inner
Copilot calls couldn't deduplicate across loaders.
Now the page authenticates once and calls TasksService /
SubtaskService / ViewSettingsService directly. With react.cache wraps
on CopilotAPI (prior commit), getInternalUser(self) collapses from N
call sites across loaders down to a single network fetch. The new
loaders live in a sibling loaders.ts for clarity and possible reuse.
Also passes useFallback to OneTaskDataFetcher so the client-side
useSWR seeds from the SSR-rendered task and skips the redundant
post-hydration refetch of /api/tasks/{id}.
Measured on staging (warm): time-to-task-body in stream went from
~7.5 s to ~4.4 s end-to-end on local-dev-to-staging. Real prod
deployment (same-region us-east-1) will land lower since per-query
RTT shrinks dramatically.
8b81554 to
ab1f249
Compare
|
Deployment failed with the following error: Learn More: https://vercel.link/multiple-function-regions |
…s-app into anit/out-3701-detail-page-optimizations
The merge of feature/c1-optimization reverted page.tsx to the HTTP-loopback fetchers, leaving loaders.ts orphaned. Rewire page.tsx to loadTask/loadSubtaskStatus/loadTaskPath/loadViewSettings and drop the now-dead useFallback prop on OneTaskDataFetcher. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…btask - Authenticate once in page.tsx and pass `User` into all loaders so the four loaders share a single `getTokenPayload` round-trip. Drop the explicit `copilotClient.getTokenPayload()` call by reconstructing the Token shape downstream consumers actually read from `user` fields. - Replace fragile message-regex 404 detection in `loadTask` with `err instanceof APIError && err.status === httpStatus.NOT_FOUND`. - Move `canCreateSubtask: count < 2` into `SubtaskService.getSubtaskStatus` so the SSR loader and the `/subtask-count` controller share one source of truth for the threshold. - Remove the now-unused `apiUrl` import from the detail page. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR optimises the task detail page by collapsing 5 HTTP loopback SSR fetches into direct service calls (single auth, single
Confidence Score: 5/5Safe to merge — the refactoring is well-scoped, the previous blocking concerns around deleted-task redirects and signed-URL refresh have been addressed, and no correctness regressions were found. The core changes (direct service calls, parallel Promise.all, fallbackData) are logically sound. The per-loader error handling correctly restores the redirect path for missing tasks. The two remaining findings are minor: an unused selector binding and an overly-broad catch clause in the subtask loader that returns a safe default rather than crashing. src/app/detail/[task_id]/[user_type]/loaders.ts — the loadSubtaskStatus catch block is broader than needed. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Browser Request] --> B[page.tsx SSR]
B --> C[authenticateWithToken]
C --> D[user object]
D --> E[Promise.all loaders]
E --> F[loadTask]
E --> G[loadSubtaskStatus]
E --> H[loadTaskPath]
E --> I[loadViewSettings]
F --> J[TasksService.getOneTask]
J --> K[DB findFirst with workflowState]
K --> L[replaceImageSrc]
L --> M[inner Promise.all]
M --> N[DB count subtasks]
M --> O[getTaskAssignee]
M --> P{bodyChanged?}
P -- yes --> Q[DB update body]
P -- no --> R[skip update]
N & O & Q --> S[task plus assignee plus subtaskCount]
N & O & R --> S
F & G & H & I --> T[Render HTML with SSR data]
T --> U[SWR shows fallbackData immediately]
U --> V[SWR revalidates on mount]
V --> W[API refreshes signed URLs]
Reviews (3): Last reviewed commit: "fetch task detail on mount" | Re-trigger Greptile |
| const [task, subTaskStatus, taskPath, viewSettings] = await Promise.all([ | ||
| loadTask(user, task_id), | ||
| loadSubtaskStatus(user, task_id), | ||
| loadTaskPath(user, task_id), | ||
| loadViewSettings(user), | ||
| ]) |
There was a problem hiding this comment.
Promise.all rejects when task is hard-deleted, hiding the redirect page
loadTaskPath calls getTraversalPath, which queries SELECT "path" FROM "Tasks" WHERE id = ${id}::uuid with no workspaceId or deletedAt guard. For a task that no longer exists in the DB (hard-deleted or bad task_id), it throws APIError(NOT_FOUND). Similarly, loadSubtaskStatus → getSubtaskCounts throws INTERNAL_SERVER_ERROR when the row is missing. Either error causes the whole Promise.all to reject before the if (!task) check at line 81 is reached, so the user sees an unhandled error page instead of DeletedRedirectPage. The old HTTP-loopback code used raw fetch without res.ok checks, so those helpers silently returned malformed JSON and task === null was still reachable. A guard like wrapping loadTaskPath and loadSubtaskStatus in per-call try/catch (returning a sensible default on NOT_FOUND) would preserve the redirect behavior.
| ...(initialTask | ||
| ? { | ||
| fallbackData: { task: initialTask }, | ||
| revalidateOnMount: false, | ||
| } | ||
| : {}), |
There was a problem hiding this comment.
Client-side signed-URL refresh is permanently disabled
With revalidateOnFocus: false, refreshInterval: 0, and now revalidateOnMount: false whenever initialTask is present (which is always), SWR will never call /api/tasks/{id} again after the initial render. The server re-signs image URLs on every SSR pass, so they're fresh at page load. But if the user keeps the tab open past the signed-URL TTL, images will silently break with no client-side mechanism left to refresh them. The previous mount revalidation was the only safety net for long-lived sessions on image-heavy tasks.
| try { | ||
| return (await new TasksService(user).getOneTask(taskId)) as unknown as TaskResponse | ||
| } catch (err) { |
There was a problem hiding this comment.
The
as unknown as TaskResponse double-cast silences type errors that may indicate a real mismatch between the Prisma return shape (Task & { workflowState: WorkflowState | null; assignee?: ... }) and TaskResponse. If TaskResponse expects a non-nullable workflowState, callers will see a runtime null where the type promises otherwise. Consider aligning the return type or using a proper serializer instead of a double cast.
| try { | |
| return (await new TasksService(user).getOneTask(taskId)) as unknown as TaskResponse | |
| } catch (err) { | |
| try { | |
| const task = await new TasksService(user).getOneTask(taskId) | |
| // TODO: replace with a typed serializer once TaskResponse is aligned with | |
| // the Prisma shape (workflowState nullability, assignee union, etc.) | |
| return task as unknown as TaskResponse | |
| } catch (err) { |
Direct service calls return Prisma `Date` instances that the old HTTP path JSON-stringified into ISO strings. Without that round-trip the Dates reach Redux as non-serializable values and trip the redux-toolkit serializability check (e.g. `payload.assignedAt`). Round-trip each loader's result through `JSON.parse(JSON.stringify(...))` so the shape matches what the HTTP API used to return and what `TaskResponse` (string-typed date fields) expects. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… reachable
When a task is hard-deleted, `loadTaskPath` (via `getTraversalPath`) throws
NOT_FOUND and `loadSubtaskStatus` (via `getSubtaskCounts`) throws 500
("Path for task was not set"). Running them in `Promise.all` alongside
`loadTask` meant a rejection here bypassed the page's `if (!task)` guard,
so the user saw an unhandled error instead of `DeletedRedirectPage`.
Catch APIError in both loaders and return safe defaults (empty path, zero
count). `loadTask` returning `null` remains the canonical "missing task"
signal that triggers the redirect.
Addresses Greptile P1 on PR #1214.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@greptileai review again. If your comments are ersolved then resolve them. |
`parseAssigneeToSelectorOption` and `parseAssigneeToSelectorOptions` were piping empty-string `avatarImageUrl`/`iconImageUrl` straight into the selector's `avatarSrc`, which Copilot's avatar component renders as `<img src="">`. Next.js flags this and re-downloads the page over the network. The "No assignee" sentinel (`NoAssignee`) explicitly uses empty strings for those fields, so the warning fires every time the selector opens. Use `||` to fall through empty strings to undefined, so the selector falls back to its placeholder/initials avatar instead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Under React 18 concurrent rendering, the unmount cleanup in ClientSideStateUpdate could land AFTER the next mount's `setActiveTask(task)` dispatch when navigating rapidly between detail and list pages (Esc → click task → Esc → click task). When that happened `activeTask` ended up undefined and Sidebar got stuck on the loading skeleton because its gate is `!activeTask || !isHydrated`. The cleanup is redundant: the existing `else` branch already dispatches `setActiveTask(undefined)` when the next page (e.g. home) mounts a ClientSideStateUpdate without a `task` prop. Drop the unmount-time clear to close the race window. Keep `setActiveTemplate(null)` since that sentinel is a separate concern with no equivalent reset path on the home page. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removing the unmount-time `setActiveTask(undefined)` closed one race but
risked other regressions on flows that depend on activeTask being cleared
when leaving the detail page. Restore the cleanup and instead add a
small idempotent effect that re-dispatches setActiveTask(task) whenever
the SSR `task` prop is defined but Redux `activeTask` has drifted away
(undefined or different id).
This covers both:
- First-load races where Sidebar mounts before/around the CSU effect
and Redux state hasn't been populated yet.
- Fast Esc → click → repeat sequences where a stale cleanup from a
previous unmount lands AFTER the new mount's dispatch.
Effect deps are just (task, activeTaskInStore), so the work is cheap and
the dispatch is idempotent — once they're in sync it no-ops.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s-app into anit/out-3701-detail-page-optimizations
|
@greptileai review this PR again. |
Ref: OUT-3701 · Stacked on #1215.
Changes
getOneTask— parallelize assignee Copilot fetch with task.update / task.count (was sequential after). Skiptask.updateentirely when the body has no signed-URL changes. IncludeworkflowStatein the initialfindFirstso no second query is needed when update is skipped.tasks.controller.getTask— drop now-redundantgetTaskAssigneecall.page.tsx— replace 5 HTTP-loopback SSR fetches with direct service-method calls. Newloaders.tssibling for clarity.OneTaskDataFetchergetsuseFallbackso the post-hydration client refire of/api/tasks/{id}is eliminated.Why
The previous SSR pass fanned out to 5 separate Vercel function invocations, each with its own
authenticate()andreact.cachescope — so Copilot calls couldn't dedup across loaders. Direct service calls collapse this to 1 invocation, 1 auth, 1 cache scope; thereact.cachewraps from #1215 then make dedup actually work end-to-end.Measured impact (staging, time-to-task-body p50)
getInternalUser:selfactual fetchesNumbers are from local-dev measurements against staging Supabase. Real prod (Vercel us-east-1, colocated with DB) will land lower since these are bottlenecked by laptop→us-east-1 RTT.
Test plan
bodyChangedpath, not the skip-update path)Out of scope
WorkflowStateFetcher+TemplatesFetcher— separate ticketUnsupported(\"ltree\")🤖 Generated with Claude Code