Skip to content

perf(detail): cache + parallelize + skip redundant work in the detail page handler#1214

Open
priosshrsth wants to merge 16 commits into
feature/c1-optimizationfrom
anit/out-3701-detail-page-optimizations
Open

perf(detail): cache + parallelize + skip redundant work in the detail page handler#1214
priosshrsth wants to merge 16 commits into
feature/c1-optimizationfrom
anit/out-3701-detail-page-optimizations

Conversation

@priosshrsth
Copy link
Copy Markdown
Collaborator

@priosshrsth priosshrsth commented May 11, 2026

Ref: OUT-3701 · Stacked on #1215.

Changes

  • getOneTask — parallelize assignee Copilot fetch with task.update / task.count (was sequential after). Skip task.update entirely when the body has no signed-URL changes. Include workflowState in the initial findFirst so no second query is needed when update is skipped.
  • tasks.controller.getTask — drop now-redundant getTaskAssignee call.
  • page.tsx — replace 5 HTTP-loopback SSR fetches with direct service-method calls. New loaders.ts sibling for clarity. OneTaskDataFetcher gets useFallback so 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() and react.cache scope — so Copilot calls couldn't dedup across loaders. Direct service calls collapse this to 1 invocation, 1 auth, 1 cache scope; the react.cache wraps from #1215 then make dedup actually work end-to-end.

Measured impact (staging, time-to-task-body p50)

Baseline With #1215 only With this PR
getInternalUser:self actual fetches 4 1 1
Time-to-task-body ~7.5 s ~6.6 s ~4.4 s

Numbers 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

  • IU on task with IU assignee — title/body render, edits work
  • IU on task with client assignee — assignee picker populates
  • Client on a task — read-only view works
  • Image-heavy task — signed URLs still refresh (exercises the bodyChanged path, not the skip-update path)
  • Preview mode — header renders correctly
  • Vercel preview measurement to validate prod numbers

Out of scope

  • Apply OUT-3702 pattern to WorkflowStateFetcher + TemplatesFetcher — separate ticket
  • Suspense-wrap secondary detail sections — separate ticket
  • Path-traversal query merge — blocked on Prisma Unsupported(\"ltree\")

🤖 Generated with Claude Code

@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 11, 2026

OUT-3701

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 11, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tasks-app Ready Ready Preview, Comment May 13, 2026 10:25am

Request Review

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.
@priosshrsth priosshrsth force-pushed the anit/out-3701-detail-page-optimizations branch from 8b81554 to ab1f249 Compare May 11, 2026 09:40
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 11, 2026

Deployment failed with the following error:

Deploying Serverless Functions to multiple regions is restricted to the Pro and Enterprise plans.

Learn More: https://vercel.link/multiple-function-regions

@priosshrsth priosshrsth changed the base branch from anit/out-3701-optimize-task-detail-page to feature/c1-optimization May 12, 2026 07:19
priosshrsth and others added 2 commits May 12, 2026 07:25
…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-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 12, 2026

Greptile Summary

This PR optimises the task detail page by collapsing 5 HTTP loopback SSR fetches into direct service calls (single auth, single react.cache scope), parallelising subtask count + Copilot assignee fetch + conditional body update inside getOneTask, and surfacing SSR task data immediately to the client via SWR fallbackData.

  • getOneTask refactor — includes workflowState in the initial findFirst, skips task.update when no image-src changes are detected, and runs the DB update, subtask count, and assignee fetch concurrently in a single Promise.all.
  • loaders.ts — new SSR loader module replaces HTTP loopback helpers with direct service calls; per-loader try/catch blocks restore the redirect behaviour for deleted tasks.
  • OneTaskDataFetcher + Redux cleanupfallbackData eliminates the SSR→client loading flash; deprecated accesibleTaskIds slice state and setAccesibleTaskIds action are removed.

Confidence Score: 5/5

Safe 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

Filename Overview
src/app/api/tasks/tasks.service.ts Parallelises subtask count, assignee fetch, and optional body update; includes workflowState in the initial findFirst to avoid a second query. Logic is sound; the newBody !== task.body string-value comparison works correctly in JS.
src/app/detail/[task_id]/[user_type]/loaders.ts New SSR loader module replacing HTTP loopback fetches with direct service calls. Error handling for loadTask and loadTaskPath is appropriately scoped to NOT_FOUND; loadSubtaskStatus over-catches all APIError types (see comment).
src/app/detail/[task_id]/[user_type]/page.tsx Removes 5 HTTP loopback fetches in favour of direct loader calls; auth is consolidated to a single authenticateWithToken call. The null guard on task before rendering OneTaskDataFetcher ensures the non-nullable initialTask prop is always satisfied.
src/app/_fetchers/OneTaskDataFetcher.tsx Adds fallbackData so the SSR task renders immediately without a loading flash; SWR still revalidates on mount by default, preserving the signed-URL refresh path.
src/hoc/ClientSideStateUpdate.tsx Removes the deprecated accesibleTaskIds prop and setAccesibleTaskIds dispatch; activeTaskInStore is newly destructured from the selector but is never used in the component (see comment).
src/app/api/tasks/tasks.controller.ts Removes the now-redundant getTaskAssignee call; the assignee is included in the getOneTask return value and the JSON response shape is unchanged.
src/app/api/tasks/subtasks.service.ts Adds getSubtaskStatus as a thin wrapper around getSubtaskCounts so callers get the combined { count, canCreateSubtask } shape directly from the service.
src/redux/features/taskBoardSlice.tsx Removes the deprecated setAccesibleTaskIds reducer and its export; no remaining callers after ClientSideStateUpdate.tsx cleanup.

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]
Loading

Reviews (3): Last reviewed commit: "fetch task detail on mount" | Re-trigger Greptile

Comment on lines +71 to 76
const [task, subTaskStatus, taskPath, viewSettings] = await Promise.all([
loadTask(user, task_id),
loadSubtaskStatus(user, task_id),
loadTaskPath(user, task_id),
loadViewSettings(user),
])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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, loadSubtaskStatusgetSubtaskCounts 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.

Copy link
Copy Markdown
Collaborator Author

@priosshrsth priosshrsth May 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@greptileai Addressed this.

Comment on lines +29 to +34
...(initialTask
? {
fallbackData: { task: initialTask },
revalidateOnMount: false,
}
: {}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Comment on lines +25 to +27
try {
return (await new TasksService(user).getOneTask(taskId)) as unknown as TaskResponse
} catch (err) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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) {

Copy link
Copy Markdown
Collaborator Author

@priosshrsth priosshrsth May 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@greptileai Addressed this.

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>
@priosshrsth
Copy link
Copy Markdown
Collaborator Author

@greptileai review again. If your comments are ersolved then resolve them.

priosshrsth and others added 2 commits May 12, 2026 10:08
`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
@priosshrsth
Copy link
Copy Markdown
Collaborator Author

@greptileai review this PR again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant