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.