bug-email-tracking-indicator-conditional-hooks.md9.6 KBView on GitHub
# Bug: whole app white-screens with React #310 when a tracked email is opened

**Status:** fixed · investigation `163e8a0c-5408-4fb7-b93a-31e88d4d70fd`
**Reported:** 2026-08-17/18, <email> on staging
**Component:** `apps/mail/modules/threads/components/email-tracking-indicator.tsx`
**Also live on production** (`https://mail.cedarcopilot.com/inbox/inbox`)

## Symptom

The React Router error boundary replaces the entire app with:

```
Error: Minified React error #310; visit https://react.dev/errors/310
    at cl (entry.client-gEFqsHkG.js:28:49126)
    at an (entry.client-gEFqsHkG.js:28:55604)
    at Object.kf [as useEffect] (entry.client-gEFqsHkG.js:28:55826)
    at n.useEffect (index-D7S5CQV3.js:9:6762)
    at d6 (query-provider-S0nNn7hO.js:1:43067)
    at g6 (query-provider-S0nNn7hO.js:1:43907)
    at kM (query-provider-S0nNn7hO.js:1:44704)
    at hd (thread-display-DCy17IfF.js:7:1335)     <-- app code
    at xf (entry.client-gEFqsHkG.js:28:47866)
```

React error **#310** = *"Rendered more hooks than during the previous render."*

The `query-provider-*.js` frames are a red herring — that chunk carries the bundled
`@tanstack/react-query` library, not `providers/query-provider.tsx`. Deminifying the deployed
chunk shows `d6` = `useClearResetErrorBoundary`, `g6` = `useBaseQuery`, `kM` = `useQuery`.
The only app frame is `hd` in `thread-display-DCy17IfF.js`.

## Impact

- Total app crash (white screen behind the error boundary) on `/mail/inbox`. Recovery requires a
  reload, and the crash re-fires as soon as the same thread row re-renders.
- **Not staging-only.** The identical fingerprint is recorded against production for two distinct
  users on 2026-08-11. One session on 2026-08-05 looped 59 times (#310 followed by 59× #300 as
  React unwound and remounted).
- Only users with **email open/link tracking enabled** are exposed — which is why it looks like a
  single-user problem.

## Root cause

`EmailTrackingIndicator` calls two hooks, then returns early, then calls four more hooks.

```tsx
export function EmailTrackingIndicator({ openCount, clickCount, ... }) {
  const [isOpen, setIsOpen] = useState(false);   // hook 1
  const trpc = useTRPC();                        // hook 2 (useContext)

  if (!openTrackingEnabled && !linkTrackingEnabled) return null;   // early return
  const hasActivity = openCount > 0 || clickCount > 0;
  if (!hasActivity) return null;                                   // early return

  const { data, isLoading } = useQuery(...);     // hook 3  <-- conditional
  const viewerGroups        = useMemo(...);      // hook 4  <-- conditional
  const filteredOpenCount   = useMemo(...);      // hook 5  <-- conditional
  const filteredClickCount  = useMemo(...);      // hook 6  <-- conditional
```

A sent-mail row mounts this component with `openCount === 0 && clickCount === 0`, so React records
a **2-hook** render. `openCount` is not static: `thread.tsx:906-912` reads it from
`getThreadData.trackingData`, which the thread query refetches. The moment the recipient opens the
email, the same mounted instance re-renders with `openCount === 1`, skips both early returns, and
tries to run **6 hooks** against a 2-hook slot. React throws #310 on the third hook — `useQuery`,
whose internal `useEffect` is the frame in the stack.

So the crash is triggered by a recipient action, not by anything the user did — the app dies
"spontaneously" while sitting on the inbox.

### Step-by-step with real data

1. **`thread.tsx:906-914`** — a row renders the tracking pill for the latest sent message:
   ```
   tracking = { trackingId: "…", openCount: 0, clickCount: 0,
                lastOpenedAt: null, openTrackingEnabled: true, linkTrackingEnabled: true }
   ```
2. **`email-tracking-indicator.tsx:122-124`** — `useState`, `useTRPC` run. Hook count = 2.
3. **`email-tracking-indicator.tsx:131-135`** — `hasActivity === false` → `return null`. React
   stores a 2-hook memoized state for this fiber.
4. Recipient opens the email. The deliverability pixel fires; `mail.getThread` refetches and now
   returns `openCount: 1, lastOpenedAt: 2026-08-18T06:07Z`.
5. **`email-tracking-indicator.tsx:137`** — same fiber re-renders, both guards pass, `useQuery` is
   called as hook 3 where React expects the render to be finished → **React #310**.
6. The error propagates past every boundary to the route-level `ErrorBoundary`; the app is replaced
   with the error screen.

## Evidence

PostHog error tracking, project 251598.

Issue [`01a011b3-a61f-7500-8f99-101cd4997d22`](https://us.posthog.com/project/251598/error_tracking/01a011b3-a61f-7500-8f99-101cd4997d22)
— staging, 3 occurrences / 2 sessions / 1 user, first 2026-08-17T21:49:37Z, last 2026-08-18T06:07:51Z.

```
query-error-tracking-issues-list { dateRange: -14d, status: all,
                                   searchQuery: "Minified React error #310" }
query-error-tracking-issue-events { issueId: 01a011b3-…, include: [stacktrace], onlyAppFrames: false }
```

- `$current_url`: `https://mail-staging.cedarcopilot.com/mail/inbox`
- `distinct_id`: `ZQLxbHFaT4tqVAbVHWgh1BtPKTBLloTE`
- `$browser`: Chrome 151 / Mac OS X
- React **component** stack, innermost frame (49 of 50): `hd` @ `thread-display-DCy17IfF.js`
- `$exception_fingerprint`:
  `6e9b6a38ae92553359417fabc5942c104c609dbeac456bec2100be84a740f8f8…`

Production, same defect — issue [`019ff1b5-c6b4-7512-a117-6b8f59395fb0`](https://us.posthog.com/project/251598/error_tracking/019ff1b5-c6b4-7512-a117-6b8f59395fb0),
2 users, `https://mail.cedarcopilot.com/inbox/inbox`, innermost app frame `zf` @
`thread-display-BXf5sW1G.js:7:1153` — the same function under a different build's mangling.

Deminification (no sourcemaps are published — 403 on `*.js.map`, so the deployed chunk was fetched
and read directly):

```
curl https://mail-staging.cedarcopilot.com/assets/thread-display-DCy17IfF.js
→ function hd({trackingId:u,openCount:l,clickCount:h,lastOpenedAt:k,
               openTrackingEnabled:m,linkTrackingEnabled:p}){
    const[T,y]=q.useState(!1),E=pt();
    if(!m&&!p||!(l>0||h>0))return null;
    const{data:L,isLoading:C}=tr(E.deliverability.getEvents.queryOptions({trackingId:u},{enabled:T}))
```

**ESLint already catches this** — the rule is configured but nothing gates on it:

```
$ npx eslint modules/threads/components/email-tracking-indicator.tsx
  137:31  error  React Hook "useQuery" is called conditionally …  react-hooks/rules-of-hooks
  145:24  error  React Hook "useMemo" is called conditionally …   react-hooks/rules-of-hooks
  151:29  error  React Hook "useMemo" is called conditionally …   react-hooks/rules-of-hooks
  155:30  error  React Hook "useMemo" is called conditionally …   react-hooks/rules-of-hooks
✖ 4 problems (4 errors, 0 warnings)
```

## Blast radius

- `EmailTrackingIndicator` has four call sites, all exposed to the same prop flip:
  `threadList/threadItem/components/thread.tsx:915`, `thread/components/mail-display.tsx:310`,
  `conversations/components/timeline/SlackTimelineEvent.tsx:182`,
  `conversations/components/timeline/PastEventsTimeline.tsx:258`.
- Introduced 2026-02-08 in `af3f25257` (one guard before `useQuery`) and widened 2026-02-10 in
  `08d169efd` (both guards before all four hooks).
- A full `eslint .` over `apps/mail` (2,274 files) reports exactly **5** `rules-of-hooks`
  violations: the 4 above, plus `modules/company/components/CompanyGTMPanel.tsx:219`, which is a
  false positive (`useTemplate` is a local click handler, not a hook).

## Fix

1. **`email-tracking-indicator.tsx`** — all six hooks moved above the guards; the two early returns
   collapse into one `if (!isVisible) return null;` placed after the last hook. Hook order is now
   unconditional. Behaviour is unchanged: the query was already gated by `enabled: isOpen`, and a
   disabled TanStack v5 query reports `isLoading === false`, exactly as before.

2. **`.oxlintrc.json`** — `rules-of-hooks: error`. CI runs `oxlint --deny-warnings`, not ESLint, and
   the rule was off there, which is why four ESLint errors shipped to production. Verified against a
   copy of the pre-fix file: the gate produces the same four errors and would have blocked the PR.
   `apps/server/**` and `packages/**` are excluded — server helpers named `use*` are not hooks and
   the rule only fires there as a false positive.

3. **`CompanyGTMPanel.tsx`** — the click handler `useTemplate` renamed to `applyTemplate`. It was
   never a hook; the name was the only reason the rule flagged it, and the rename beats a
   suppression comment.

## Tests

`apps/mail/tests/modules/threads/email-tracking-indicator.test.tsx` drives the exact prop transition
from the logs. Against the unfixed component:

```
● survives the first open landing on an already-mounted indicator (no activity -> opened)
    "Rendered more hooks than during the previous render."
● survives the reverse transition (opened -> no activity)
    "Rendered fewer hooks than expected. This may be caused by an accidental early return statement."
● survives tracking being switched off while the indicator is mounted
    "Rendered fewer hooks than expected. This may be caused by an accidental early return statement."
Tests: 3 failed, 3 total
```

After the fix: `Tests: 3 passed, 3 total`; the whole `tests/modules/threads` suite is 67/67.

## Not monitored via Sherlock

`sherlock_monitors` is deliberately left empty for this investigation. `check-sherlock-monitors`
evaluates `agent_executions` rows against `verify_condition`; a frontend render crash produces no
executions, so the monitor would grade unrelated runs, auto-close as passing, and emit a false
all-clear. Recurrence is already watched by the PostHog error-tracking issues linked above, and the
oxlint gate prevents the class from shipping again.