Skip to content

fix(state): isolate activeTask/activeTemplate seeding (OUT-3714)#1218

Merged
priosshrsth merged 4 commits into
feature/c1-optimizationfrom
anit/out-3714-fix-activetask-becoming-undefined-on-fast-navigation
May 13, 2026
Merged

fix(state): isolate activeTask/activeTemplate seeding (OUT-3714)#1218
priosshrsth merged 4 commits into
feature/c1-optimizationfrom
anit/out-3714-fix-activetask-becoming-undefined-on-fast-navigation

Conversation

@priosshrsth
Copy link
Copy Markdown
Collaborator

@priosshrsth priosshrsth commented May 12, 2026

Summary

Fixes OUT-3714: Sidebar can get stuck on its loading skeleton because Redux activeTask is undefined while the SSR task prop is defined.

Root cause

ClientSideStateUpdate's mega-useEffect coupled ~14 unrelated state concerns into one body + one cleanup that cleared activeTask + activeTemplate. Any single dep changing re-ran every dispatch and cleanup. Under React 18 concurrent rendering, a stale unmount cleanup from a previous mount could land AFTER the next mount's setActiveTask(task) dispatch, leaving activeTask undefined.

The race is latent on main toofeature/c1-optimization exposed it. Moving AssigneeFetcher / WorkspaceFetcher from server-side awaits to client-side SWR (in src/app/layout.tsx) shortened the time between successive commits and shifted rapid Esc → click navigation into the race window. Workspaces with fast Copilot responses on main never hit it.

Surgical fix

Isolate only the two state concerns that actually have cleanups (activeTask + activeTemplate) into dedicated components. Everything else in ClientSideStateUpdate stays exactly as today.

  • New src/hoc/state-seeders.tsx: SeedActiveTask + SeedActiveTemplate.
    • SeedActiveTask has two effects:
      1. Reconcile-on-render — idempotent self-heal whenever activeTask drifts from the SSR task prop. Rescues a stale-cleanup race because the next render with drift re-dispatches.
      2. Empty-dep unmount-clear — fires only on true unmount, never on reconcile re-runs.
  • ClientSideStateUpdate.tsx: dropped the task + template props, the setActiveTask/setActiveTemplate dispatches in the effect body, and both lines in the cleanup (the cleanup is now empty and removed). task is out of the dep array. Everything else unchanged.
  • DetailStateUpdate.tsx: mounts <SeedActiveTask task={task} /> inside CSU.
  • manage-templates/[template_id]/page.tsx: mounts <SeedActiveTemplate template={template} /> inside CSU.
  • WorkflowStateFetcher.tsx: mounts <SeedActiveTask task={task} /> inside CSU. Preserves the redundant double-seeding that the detail page already had (probabilistic safety: two mount-time dispatches means a stale cleanup has to land after BOTH to break things).

Home, client, configure-tasks-app, AllTasksFetcher, TemplatesFetcher all continue using ClientSideStateUpdate exactly as on main — no API changes.

Why this is safer than the previous design

Issue Before After
activeTask cleanup unconditionally clears on every CSU instance's unmount yes no — only SeedActiveTask cleans up activeTask
Mega-effect re-runs all dispatches when any dep changes yes each concern has its own effect with its own deps
Mount-only dispatch — any stale cleanup that lands later is permanent yes reconcile-on-render auto-heals drift
Cross-concern cleanup (activeTask + activeTemplate) tied together yes independent components

Test plan

  • Cold-load a detail page directly via URL — Sidebar renders task immediately (no stuck skeleton).
  • Esc → click task → Esc → click task → … rapidly. Repeat 10+ times. Sidebar always renders the task.
  • Realtime update of the currently-viewed task — Sidebar reflects the update.
  • Realtime delete of the currently-viewed task — redirected to board.
  • Manage-templates page: opening a template populates the editor; navigating away clears activeTemplate.
  • Home / client board renders tasks; switching between home and detail preserves in-progress board edits.

Files

5 files changed. ClientSideStateUpdate stays the same for 5 of its 7 callers.

🤖 Generated with Claude Code

@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 12, 2026

OUT-3714

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 12, 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 4:52am

Request Review

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 12, 2026

Greptile Summary

Splits activeTask and activeTemplate seeding out of the monolithic ClientSideStateUpdate effect into dedicated SeedActiveTask and SeedActiveTemplate components, fixing a React 18 concurrent-rendering race where a stale unmount cleanup could land after the next mount's dispatch and leave activeTask undefined (sidebar stuck on skeleton).

  • SeedActiveTask uses a two-effect design: a reconcile effect on [task, activeTask] that self-heals any drift, plus a separate []-dep effect whose cleanup fires only on true unmount — closing the race for task seeding.
  • ClientSideStateUpdate drops task/template props, their dispatches, and the cleanup return; the remaining deps and dispatches are unchanged.
  • SeedActiveTemplate uses a single [template]-dep effect (the old CSU pattern) and does not apply the two-effect reconcile guard, leaving it susceptible to the same concurrent-rendering race that motivated this PR.

Confidence Score: 4/5

Safe to merge for the task-detail path; the template-editor path retains a concurrent-rendering race that can leave the editor blank on fast navigation.

The task-seeding race is robustly fixed by the two-effect reconcile pattern. However, SeedActiveTemplate uses the old single-effect cleanup design: a stale concurrent cleanup can still overwrite a fresh setActiveTemplate dispatch with null, and there is no self-heal mechanism to recover.

src/hoc/state-seeders.tsx — specifically the SeedActiveTemplate implementation, which needs the same two-effect reconcile pattern applied to SeedActiveTask.

Important Files Changed

Filename Overview
src/hoc/state-seeders.tsx New file introducing SeedActiveTask (two-effect, race-safe) and SeedActiveTemplate (single-effect, same race as before the fix); SeedActiveTemplate lacks the reconcile/self-heal pattern applied to SeedActiveTask
src/hoc/ClientSideStateUpdate.tsx Removed task/template props, their dispatches, and the cleanup return — the mega-effect is now cleaner and the cleanup is gone; remaining deps and dispatches are unchanged
src/app/detail/[task_id]/[user_type]/DetailStateUpdate.tsx Replaced inline task prop with SeedActiveTask child in both the normal and redirect paths; structure is correct
src/app/_fetchers/WorkflowStateFetcher.tsx Replaced task prop on CSU with SeedActiveTask child, preserving the intentional double-seeding for the redirect path
src/app/manage-templates/[template_id]/page.tsx Replaced template prop on CSU with SeedActiveTemplate child; call-site change is correct, but SeedActiveTemplate itself carries a race risk

Sequence Diagram

sequenceDiagram
    participant React as React 18 Renderer
    participant CSU as ClientSideStateUpdate
    participant SAT as SeedActiveTask (new)
    participant SAT2 as SeedActiveTask (prev)
    participant Store as Redux Store

    Note over React,Store: Normal mount
    React->>CSU: mount with workflowStates/token
    CSU->>Store: dispatch workflowStates/token/...
    React->>SAT: mount with task prop
    SAT->>Store: reconcile effect sets activeTask(task)

    Note over React,Store: Concurrent race - stale cleanup wins
    React->>SAT: mount NEW instance, sets activeTask(task)
    SAT2-->>Store: stale cleanup fires setActiveTask(undefined)
    SAT->>Store: reconcile detects drift, re-dispatches activeTask(task)
    Note over SAT,Store: Self-healed - sidebar never stuck

    Note over React,Store: SeedActiveTemplate - no reconcile guard
    React->>Store: new mount sets activeTemplate(template)
    SAT2-->>Store: stale cleanup fires setActiveTemplate(null)
    Note over Store: activeTemplate stays null - no recovery
Loading

Reviews (2): Last reviewed commit: "fix(state): isolate activeTask/activeTem..." | Re-trigger Greptile

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 12, 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

Comment thread src/hoc/state-seeders.tsx Outdated
Comment thread src/hoc/state-seeders.tsx Outdated
Sidebar got stuck on its loading skeleton because `activeTask` could be
left `undefined` while the SSR `task` prop was defined. Two paths:

  - Fast Esc → click → click navigation: a stale unmount cleanup from a
    previous mount of ClientSideStateUpdate could land AFTER the new
    mount's setActiveTask(task) dispatch under React 18 concurrent
    rendering, clearing it.
  - First load on slow workspaces: similar race during hydration.

The race is latent on main too — feature/c1-optimization exposed it by
removing the server-side awaits on AssigneeFetcher/WorkspaceFetcher
(moved to client-side SWR in the layout), which shortens the time
between successive page commits and shifts the navigation into the race
window.

Root cause is that ClientSideStateUpdate's mega-effect coupled ~14
unrelated state concerns into one effect with a unified cleanup that
cleared activeTask + activeTemplate. Any single dep changing re-ran
every dispatch AND the cleanup.

Surgical fix: extract only the two concerns with cleanups
(activeTask + activeTemplate) into dedicated components. Everything
else stays in CSU exactly as today.

  - `src/hoc/state-seeders.tsx` (new): SeedActiveTask + SeedActiveTemplate.
    SeedActiveTask has a reconcile-on-render effect (idempotent self-heal
    against stale-cleanup races) and a separate empty-dep unmount-clear
    so the cleanup fires only on true unmount.
  - `src/hoc/ClientSideStateUpdate.tsx`: dropped `task` + `template`
    props, the corresponding branches in the mega-effect, and both lines
    in the cleanup (cleanup is now empty so it's removed). Dep array
    no longer includes `task`. Everything else unchanged.
  - `DetailStateUpdate.tsx`: mounts <SeedActiveTask task={task} /> inside
    CSU instead of passing task to CSU.
  - `manage-templates/[template_id]/page.tsx`: mounts <SeedActiveTemplate>
    inside CSU instead of passing template.
  - `WorkflowStateFetcher.tsx`: mounts <SeedActiveTask task={task} />
    inside CSU. Preserves the probabilistic safety of the redundant
    seeding the detail page already had.

Home, client, configure-tasks-app, AllTasksFetcher, TemplatesFetcher all
keep using ClientSideStateUpdate exactly as before — no API changes for
them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@priosshrsth priosshrsth force-pushed the anit/out-3714-fix-activetask-becoming-undefined-on-fast-navigation branch from 91b431c to 4f23993 Compare May 12, 2026 13:17
@priosshrsth priosshrsth changed the title fix(state): split ClientSideStateUpdate into per-concern seeders (OUT-3714) fix(state): isolate activeTask/activeTemplate seeding (OUT-3714) May 12, 2026
@priosshrsth
Copy link
Copy Markdown
Collaborator Author

@greptileai Review again and resolve your existing comments if they are no longer valid.

Comment thread src/hoc/state-seeders.tsx
Greptile flagged that SeedActiveTemplate kept the old single-effect shape
where the cleanup is tied to the same effect that re-runs on prop changes.
A stale cleanup from a previous SeedActiveTemplate instance could fire
after the new mount's setActiveTemplate(template) dispatch under React 18
concurrent rendering, leaving activeTemplate as null with no recovery —
template editor would silently render empty until the user navigates away
and back.

Mirror SeedActiveTask: reconcile-on-render effect with [template,
activeTemplate] deps for self-healing drift, and a separate empty-deps
effect whose cleanup fires only on true unmount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With SeedActiveTask's reconcile-on-render pattern, a single seeder per
page is sufficient — drift always self-heals. The double-seeding via
WorkflowStateFetcher was kept as probabilistic safety in case the
race re-emerged, but it provides no additional protection beyond what
the reconcile already guarantees.

Drop the SeedActiveTask call in WorkflowStateFetcher and the now-unused
task prop. DetailStateUpdate's SeedActiveTask is the sole owner of
activeTask.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The deferred cleanup in the realtime task-delete handler captured tasks
and accessibleTasks at event time. By the time setTimeout(0) fired, the
user could already have been redirected to the board and CSU may have
seeded a fresh list from SSR — the captured-snapshot dispatch would then
clobber the freshly seeded data (and lose any new task that landed via
a racing create event).

Read latest state from the store inside the setTimeout callback instead.
Keeps the original deferral (a race patch for update-before-create
ordering) while avoiding the clobber on the redirect path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@priosshrsth priosshrsth merged commit 6805bf6 into feature/c1-optimization May 13, 2026
3 checks passed
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.

2 participants