Diff
aac310a6 → head
before
after
1---
2title: "Blank extension page — infinite-load hang on new tab"
3slug: blank-extension-page
4date: 2026-05-11
5status: closed
6verdict: validated
7confidence: high
8severity: high
9recurring: true
10affected: "Toby new-tab extension (chrome_url_override). Triggered on every extension auto-update or manual reload while a new-tab is open — i.e. every Toby user, every release. Most users don't reopen the page so they don't see it; the ones who do report 'blank page on infinite load'."
11---
12 1
13# Blank extension page — infinite-load hang on new tab2# Blank extension page — infinite-load hang on new tab
14 3
15## TL;DR4## TL;DR
16 5
17The Toby new-tab page renders the static preload skeleton and never transitions to the real UI. AuthWrapper at `apps/extension/app/containers/Toby.tsx:304` returns `null` forever because `isUserHydrated` (added 2026-04-09 in commit `d68726b29`) never flips. The proximate cause is that `getUser()` at `apps/extension/app/state/accessors/user.tsx:45-50` has no timeout / no `chrome.runtime.lastError` check / no `.catch`; when Chrome's "extension context invalidated" state drops the `chrome.storage.local.get` callback (which happens on every extension auto-update), the promise hangs forever.6The Toby new-tab page renders the static preload skeleton and never transitions to the real UI. AuthWrapper at `apps/extension/app/containers/Toby.tsx:304` returns `null` forever because `isUserHydrated` (added 2026-04-09 in commit `d68726b29`) never flips. The proximate cause is that `getUser()` at `apps/extension/app/state/accessors/user.tsx:45-50` has no timeout / no `chrome.runtime.lastError` check / no `.catch`; when Chrome's "extension context invalidated" state drops the `chrome.storage.local.get` callback (which happens on every extension auto-update), the promise hangs forever.
18 7
19Backend (Go API) is innocent. Prod-api SHA hasn't changed since 2026-02-02; 0 5xx in last 24h; SW boot path is structurally clean. The earlier `toby-product-strategist` MV3-SW-boot-regression hypothesis (`388c1db4`) is **refuted**.8Backend (Go API) is innocent. Prod-api SHA hasn't changed since 2026-02-02; 0 5xx in last 24h; SW boot path is structurally clean. The earlier `toby-product-strategist` MV3-SW-boot-regression hypothesis (`388c1db4`) is **refuted**.
20 9
21**Fix (frontend only, defence-in-depth):** bound `getUser()` and `getOnboarding2Draft` with 5s timeouts that fail open; replace `return null` with a visible recovery screen after 8s; instrument with a `NewTabHangShown` beacon.10**Fix (frontend only, defence-in-depth):** bound `getUser()` and `getOnboarding2Draft` with 5s timeouts that fail open; replace `return null` with a visible recovery screen after 8s; instrument with a `NewTabHangShown` beacon.
22 11
12**Status: closed → shipped** (PR https://github.com/axiomzen/toby-mono-repo/pull/12, 2026-05-13).
13
23## Symptom14## Symptom
24 15
25- New-tab page loads, shows the static `.preloadedBg` skeleton (light-grey sidebar columns, circle avatar, center card-grid background).16- New-tab page loads, shows the static `.preloadedBg` skeleton (light-grey sidebar columns, circle avatar, center card-grid background).
26- Never transitions. `#root` exists in the DOM but has zero children.17- Never transitions. `#root` exists in the DOM but has zero children.
27- Accessibility tree is empty; users see only decorative CSS.18- Accessibility tree is empty; users see only decorative CSS.
28- Console is **silent**. No JS error, no failed network call. That's why users describe it as "infinite loading", not "crash".19- Console is **silent**. No JS error, no failed network call. That's why users describe it as "infinite loading", not "crash".
29- Recurring across Chrome / Vivaldi 7.0 / Orion based on CWS reviews and forum reports.20- Recurring across Chrome / Vivaldi 7.0 / Orion based on CWS reviews and forum reports.
30 21
31## Root cause22## Root cause
32 23
33### Proximate (frontend)24### Proximate (frontend)
34 25
35`apps/extension/app/containers/Toby.tsx:304`:26`apps/extension/app/containers/Toby.tsx:304`:
36 27
37```tsx28```tsx
38if (isInitializing || !isDraftReady || !isUserHydrated) return null;29if (isInitializing || !isDraftReady || !isUserHydrated) return null;
39```30```
40 31
41returns `null` forever when **any** of the three booleans never flips true. `<App>` and `<Onboarding2>` are both inside this wrapper, so a `null` return kills the entire visible UI.32returns `null` forever when **any** of the three booleans never flips true. `<App>` and `<Onboarding2>` are both inside this wrapper, so a `null` return kills the entire visible UI.
42 33
43The third boolean — `isUserHydrated` — was added to this gate in commit **`d68726b29`** (Jad Haidar, 2026-04-09 19:07 +03:00, *"fix: gate AuthWrapper on user hydration to prevent duplicate onboarding events"*). That fix solves a real bug (returning users briefly seeing the onboarding flow), but it widened the failure surface without bounding the new dependency.34The third boolean — `isUserHydrated` — was added to this gate in commit **`d68726b29`** (Jad Haidar, 2026-04-09 19:07 +03:00, *"fix: gate AuthWrapper on user hydration to prevent duplicate onboarding events"*). That fix solves a real bug (returning users briefly seeing the onboarding flow), but it widened the failure surface without bounding the new dependency.
44 35
45`isUserHydrated` is bound 1:1 to a single unbounded promise at `apps/extension/app/state/accessors/user.tsx:45-50`:36`isUserHydrated` is bound 1:1 to a single unbounded promise at `apps/extension/app/state/accessors/user.tsx:45-50`:
46 37
47```tsx38```tsx
48export const getUser = () =>39export const getUser = () =>
49 new Promise<LoginResponse | null>((resolve) => {40 new Promise<LoginResponse | null>((resolve) => {
50 chrome.storage.local.get('user', ({ user }) => {41 chrome.storage.local.get('user', ({ user }) => {
51 resolve(user ?? null);42 resolve(user ?? null);
52 });43 });
53 });44 });
54```45```
55 46
56No timeout. No `chrome.runtime.lastError` check. No `.catch()`. The same defect exists in `apps/extension/app/utils/chromeapi.ts:248-259` (which `useOnboarding2Draft` is built on), so `isDraftReady` is exposed to the same class of bug. `isInitializing` waits on `useIsRestoring()` from the react-query persistor (IDB-backed), with a parallel silent-hang surface.47No timeout. No `chrome.runtime.lastError` check. No `.catch()`. The same defect exists in `apps/extension/app/utils/chromeapi.ts:248-259` (which `useOnboarding2Draft` is built on), so `isDraftReady` is exposed to the same class of bug. `isInitializing` waits on `useIsRestoring()` from the react-query persistor (IDB-backed), with a parallel silent-hang surface.
57 48
58### Distal (Chrome MV3 platform)49### Distal (Chrome MV3 platform)
59 50
60Chrome's **"extension context invalidated"** state (renderer-side) drops `chrome.storage.local.get` callbacks. This state is entered when:51Chrome's **"extension context invalidated"** state (renderer-side) drops `chrome.storage.local.get` callbacks. This state is entered when:
61 52
62- Chrome auto-updates the extension while a `chrome_url_override` new-tab is open (this happens to every Toby user, every release — Toby owns the new-tab page).53- Chrome auto-updates the extension while a `chrome_url_override` new-tab is open (this happens to every Toby user, every release — Toby owns the new-tab page).
63- The user manually disables/re-enables or reloads the extension in `chrome://extensions`.54- The user manually disables/re-enables or reloads the extension in `chrome://extensions`.
64- The SW crashes during a critical handshake phase (rarer).55- The SW crashes during a critical handshake phase (rarer).
65 56
66This is a Chromium MV3 platform behaviour, not a Toby code regression.57This is a Chromium MV3 platform behaviour, not a Toby code regression.
67 58
68### Why now (post-2026-04-09)59### Why now (post-2026-04-09)
69 60
70The underlying chrome.storage callback drop is evergreen. The extension used to *accidentally* tolerate it because the AuthWrapper gate only depended on `isInitializing || !isDraftReady`. Commit `d68726b29` added `!isUserHydrated`, binding the rendered UI 1:1 to that one unbounded callback. **The widened gate is what turned a tolerable platform quirk into a reliable user-visible hang.**61The underlying chrome.storage callback drop is evergreen. The extension used to *accidentally* tolerate it because the AuthWrapper gate only depended on `isInitializing || !isDraftReady`. Commit `d68726b29` added `!isUserHydrated`, binding the rendered UI 1:1 to that one unbounded callback. **The widened gate is what turned a tolerable platform quirk into a reliable user-visible hang.**
71 62
72## What this is NOT63## What this is NOT
73 64
74The earlier `toby-product-strategist` hypothesis (artifact `388c1db4-59b7-49e9-8ec3-ecfba972c95f`) that this was an MV3 service-worker boot regression is **refuted** by independent backend evidence (validator re-checked):65The earlier `toby-product-strategist` hypothesis (artifact `388c1db4-59b7-49e9-8ec3-ecfba972c95f`) that this was an MV3 service-worker boot regression is **refuted** by independent backend evidence (validator re-checked):
75 66
76| Probe | Evidence |67| Probe | Evidence |
77|---|---|68|---|---|
78| Prod-api SHA stability | `4b0107858e706c904e6cf2841fbcbf81a1e2f94f` has been the active SHA on three consecutive Cloud Run revisions (00425, 00426, 00427) since **2026-02-02** — well before the post-2026-04-09 user-complaint window. The 2026-04-01 deploys are config-only redeploys. |69| Prod-api SHA stability | `4b0107858e706c904e6cf2841fbcbf81a1e2f94f` has been the active SHA on three consecutive Cloud Run revisions (00425, 00426, 00427) since **2026-02-02** — well before the post-2026-04-09 user-complaint window. The 2026-04-01 deploys are config-only redeploys. |
79| 5xx volume | 0 in last 24h. Worst day this week: 19 / 1.18M = **0.0016%**. |70| 5xx volume | 0 in last 24h. Worst day this week: 19 / 1.18M = **0.0016%**. |
80| ERROR severity logs | 23 entries in last 7 days; 22 are expected 401s on stale-session endpoints, 1 is a downstream `toby-ai-api` 500 not on the auth path. No panics. No fatals. |71| ERROR severity logs | 23 entries in last 7 days; 22 are expected 401s on stale-session endpoints, 1 is a downstream `toby-ai-api` 500 not on the auth path. No panics. No fatals. |
81| DB health | 41,578 DAU, 720 new signups / 7d, healthy diurnal curve, peak 3,352 active-this-hour. |72| DB health | 41,578 DAU, 720 new signups / 7d, healthy diurnal curve, peak 3,352 active-this-hour. |
82| SW boot path | Every `chrome.*.addListener` registers synchronously at module top level. No listener-after-await MV3 boot bug. |73| SW boot path | Every `chrome.*.addListener` registers synchronously at module top level. No listener-after-await MV3 boot bug. |
83| Network in hang path | `getUser()` does **not** make a network call — the hang is pre-HTTP. An API regression structurally cannot cause this. |74| Network in hang path | `getUser()` does **not** make a network call — the hang is pre-HTTP. An API regression structurally cannot cause this. |
84 75
85## Fix76## Fix
86 77
87### Layer 1 — bound the hydration promises with a 5s timeout (fail open)78### Layer 1 — bound the hydration promises with a 5s timeout (fail open)
88 79
89`apps/extension/app/state/accessors/user.tsx`, around line 71 (the `useEffect` that calls `getUser`):80`apps/extension/app/state/accessors/user.tsx`, around line 71 (the `useEffect` that calls `getUser`):
90 81
91```tsx82```tsx
92useEffect(() => {83useEffect(() => {
93 let cancelled = false;84 let cancelled = false;
94 const timeout = setTimeout(() => {85 const timeout = setTimeout(() => {
95 if (!cancelled) {86 if (!cancelled) {
96 console.warn('[toby] getUser() exceeded 5s; falling back to null user.');87 console.warn('[toby] getUser() exceeded 5s; falling back to null user.');
97 setIsUserHydrated(true);88 setIsUserHydrated(true);
98 }89 }
99 }, 5000);90 }, 5000);
100 91
101 getUser()92 getUser()
102 .then((user) => {93 .then((user) => {
103 if (cancelled) return;94 if (cancelled) return;
104 if (user) setUser(user);95 if (user) setUser(user);
105 setIsUserHydrated(true);96 setIsUserHydrated(true);
106 })97 })
107 .catch((err) => {98 .catch((err) => {
108 console.error('[toby] getUser() failed:', err);99 console.error('[toby] getUser() failed:', err);
109 if (!cancelled) setIsUserHydrated(true);100 if (!cancelled) setIsUserHydrated(true);
110 })101 })
111 .finally(() => clearTimeout(timeout));102 .finally(() => clearTimeout(timeout));
112 103
113 return () => {104 return () => {
114 cancelled = true;105 cancelled = true;
115 clearTimeout(timeout);106 clearTimeout(timeout);
116 };107 };
117}, []);108}, []);
118```109```
119 110
120Apply the same shape to `apps/extension/app/hooks/useOnboarding2Draft.ts:12-30` for `isDraftReady`.111Apply the same shape to `apps/extension/app/hooks/useOnboarding2Draft.ts:12-30` for `isDraftReady`.
121 112
122Validator confirmed:113Validator confirmed:
123- **Race-safe.** On the happy path, `.finally(clearTimeout)` runs in the microtask flush before the 5s macrotask can fire.114- **Race-safe.** On the happy path, `.finally(clearTimeout)` runs in the microtask flush before the 5s macrotask can fire.
124- **Non-destructive.** When the storage callback arrives slow (>5s), `.then` still applies the user record once it eventually resolves — the in-memory user is not clobbered.115- **Non-destructive.** When the storage callback arrives slow (>5s), `.then` still applies the user record once it eventually resolves — the in-memory user is not clobbered.
125- **Preserves `d68726b29` intent.** Returning users with healthy storage never see an Onboarding2 flash.116- **Preserves `d68726b29` intent.** Returning users with healthy storage never see an Onboarding2 flash.
126 117
127### Layer 2 — visible recovery screen after 8s118### Layer 2 — visible recovery screen after 8s
128 119
129`apps/extension/app/containers/Toby.tsx:304`:120`apps/extension/app/containers/Toby.tsx:304`:
130 121
131```tsx122```tsx
132const [showStuckEscapeHatch, setShowStuckEscapeHatch] = useState(false);123const [showStuckEscapeHatch, setShowStuckEscapeHatch] = useState(false);
133 124
134useEffect(() => {125useEffect(() => {
135 if (!isInitializing && isDraftReady && isUserHydrated) return;126 if (!isInitializing && isDraftReady && isUserHydrated) return;
136 const t = setTimeout(() => setShowStuckEscapeHatch(true), 8000);127 const t = setTimeout(() => setShowStuckEscapeHatch(true), 8000);
137 return () => clearTimeout(t);128 return () => clearTimeout(t);
138}, [isInitializing, isDraftReady, isUserHydrated]);129}, [isInitializing, isDraftReady, isUserHydrated]);
139 130
140if (isInitializing || !isDraftReady || !isUserHydrated) {131if (isInitializing || !isDraftReady || !isUserHydrated) {
141 if (showStuckEscapeHatch) {132 if (showStuckEscapeHatch) {
142 return <StuckRecoveryScreen onRetry={() => window.location.reload()} />;133 return <StuckRecoveryScreen onRetry={() => window.location.reload()} />;
143 }134 }
144 return null;135 return null;
145}136}
146```137```
147 138
148Copy: *"Your tabs are safe. Tap to recover."* — pre-approved per `toby/00-state-of-the-project.md:50` and `toby/strategy/playbook.md` O1 KR1.139Copy: *"Your tabs are safe. Tap to recover."* — pre-approved per `toby/00-state-of-the-project.md:50` and `toby/strategy/playbook.md` O1 KR1.
149 140
150### Layer 3 — telemetry beacon141### Layer 3 — telemetry beacon
151 142
152At the `setShowStuckEscapeHatch(true)` site:143At the `setShowStuckEscapeHatch(true)` site:
153 144
154```tsx145```tsx
155trackEvent('NewTabHangShown', {146trackEvent('NewTabHangShown', {
156 isInitializing,147 isInitializing,
157 isDraftReady,148 isDraftReady,
158 isUserHydrated,149 isUserHydrated,
159 browser,150 browser,
160 version,151 version,
161});152});
162```153```
163 154
164Establishes the first signal we have between "user complains in CWS review" and the existing Sentry / Amplitude funnels.155Establishes the first signal we have between "user complains in CWS review" and the existing Sentry / Amplitude funnels.
165 156
166## Verify plan157## Verify plan
167 158
1681. **Manual repro (canonical scenario):**1591. **Manual repro (canonical scenario):**
169 1. `cd apps/extension && pnpm install && pnpm dev`160 1. `cd apps/extension && pnpm install && pnpm dev`
170 2. Load unpacked at `apps/extension/.output/chrome-mv3` via `chrome://extensions`.161 2. Load unpacked at `apps/extension/.output/chrome-mv3` via `chrome://extensions`.
171 3. Open the new tab; confirm happy path renders.162 3. Open the new tab; confirm happy path renders.
172 4. Toggle the extension off and back on in `chrome://extensions`. The open tab now has `chrome.runtime.id === undefined` (the canonical context-invalidated state).163 4. Toggle the extension off and back on in `chrome://extensions`. The open tab now has `chrome.runtime.id === undefined` (the canonical context-invalidated state).
173 5. Reload the new tab. **Pre-fix:** blank skeleton forever. **Post-fix:** Onboarding2 (or App) renders after 5s with `[toby] getUser() exceeded 5s` in the console.164 5. Reload the new tab. **Pre-fix:** blank skeleton forever. **Post-fix:** Onboarding2 (or App) renders after 5s with `[toby] getUser() exceeded 5s` in the console.
174 165
1752. **Recovery-screen repro (Layer 2):**1662. **Recovery-screen repro (Layer 2):**
176 In DevTools, before reload: `chrome.storage.local.get = () => {}`. Reload. **Pre-Layer-2:** blank. **Post-Layer-2:** StuckRecoveryScreen renders at 8s with the "tap to recover" CTA.167 In DevTools, before reload: `chrome.storage.local.get = () => {}`. Reload. **Pre-Layer-2:** blank. **Post-Layer-2:** StuckRecoveryScreen renders at 8s with the "tap to recover" CTA.
177 168
1783. **Regression check (`d68726b29` must remain fixed):**1693. **Regression check (`d68726b29` must remain fixed):**
179 When `isUserHydrated` legitimately resolves with a returning user **before** 5s, AuthWrapper must behave exactly as today — no flash of `<Onboarding2>`.170 When `isUserHydrated` legitimately resolves with a returning user **before** 5s, AuthWrapper must behave exactly as today — no flash of `<Onboarding2>`.
180 171
1814. **Telemetry sanity:**1724. **Telemetry sanity:**
182 Confirm `NewTabHangShown` flows into Amplitude. Establish baseline frequency in the first 7 days. If volume is non-trivial *without* a correlated prod-api 5xx spike, the platform-side chrome.storage drop hypothesis is confirmed in prod.173 Confirm `NewTabHangShown` flows into Amplitude. Establish baseline frequency in the first 7 days. If volume is non-trivial *without* a correlated prod-api 5xx spike, the platform-side chrome.storage drop hypothesis is confirmed in prod.
183 174
1845. **Backend monitoring (no action expected):**1755. **Backend monitoring (no action expected):**
185 Watch `prod-api` 5xx; expect to stay at ~0. If a 5xx spike correlates with a `NewTabHangShown` spike, re-open backend investigation. With current cadence (one prod-api code change in 4 months) this is unlikely.176 Watch `prod-api` 5xx; expect to stay at ~0. If a 5xx spike correlates with a `NewTabHangShown` spike, re-open backend investigation. With current cadence (one prod-api code change in 4 months) this is unlikely.
186 177
187## Operator decisions to surface178## Operator decisions to surface
188 179
189- **Should `NewTabHangShown` (and the optional Layer-1 hydration-timeout beacon below) be gated behind a feature flag?** Default proposal is **on**. Validator concurs.180- **Should `NewTabHangShown` (and the optional Layer-1 hydration-timeout beacon below) be gated behind a feature flag?** Default proposal is **on**. Validator concurs.
190- **Should we redeploy prod-api as part of this incident?** Both backend doctor and validator: **no**. The API code is innocent; a redeploy is needless blast-radius.181- **Should we redeploy prod-api as part of this incident?** Both backend doctor and validator: **no**. The API code is innocent; a redeploy is needless blast-radius.
191 182
192## Follow-ups (NOT blockers for closing this incident)183## Follow-ups (NOT blockers for closing this incident)
193 184
1941. **Apply Layer-1 shape to `isInitializing`.** Specifically the `useIsRestoring()` IDB-backed path inside `useHandleRedirectFromQueryParams` at `Toby.tsx:168-275`. Today Layer 2 catches this case at 8s with a recovery screen; the ideal is a 5s Layer-1-style local timer that lets the page self-heal without a tap.1851. **Apply Layer-1 shape to `isInitializing`.** Specifically the `useIsRestoring()` IDB-backed path inside `useHandleRedirectFromQueryParams` at `Toby.tsx:168-275`. Today Layer 2 catches this case at 8s with a recovery screen; the ideal is a 5s Layer-1-style local timer that lets the page self-heal without a tap.
1952. **SW hardening (three items flagged by the backend doctor):**1862. **SW hardening (three items flagged by the backend doctor):**
196 - `apps/extension/entrypoints/background.ts:14` — chain `.catch(err => console.error('[toby-sw] persistQueryClientRestore failed', err))` on the persist-restore call. Currently fire-and-forget; IDB failures are silently swallowed.187 - `apps/extension/entrypoints/background.ts:14` — chain `.catch(err => console.error('[toby-sw] persistQueryClientRestore failed', err))` on the persist-restore call. Currently fire-and-forget; IDB failures are silently swallowed.
197 - `apps/extension/app/background/contextMenus.ts:145-191` — wrap the SW `fetch` calls with an `AbortController` + 10s timeout. Currently a stuck TCP socket can keep the SW alive past its idle window.188 - `apps/extension/app/background/contextMenus.ts:145-191` — wrap the SW `fetch` calls with an `AbortController` + 10s timeout. Currently a stuck TCP socket can keep the SW alive past its idle window.
198 - Build a unified `chromeStorageGet<T>(keys, { timeoutMs })` helper that wraps `chrome.runtime.lastError` + `chrome.runtime.id` validity + a timeout. Replace every raw `chrome.storage.local.get(key, cb)` callsite with this. Layer 1 only patches the `getUser` and `getOnboarding2Draft` sites; this helper would fix the class.189 - Build a unified `chromeStorageGet<T>(keys, { timeoutMs })` helper that wraps `chrome.runtime.lastError` + `chrome.runtime.id` validity + a timeout. Replace every raw `chrome.storage.local.get(key, cb)` callsite with this. Layer 1 only patches the `getUser` and `getOnboarding2Draft` sites; this helper would fix the class.
1993. **Layer-1 telemetry beacon.** Fire a second, lower-stakes event (e.g. `NewTabHydrationTimeout`) at the Layer-1 5s fallback site. Without it, the common post-fix recovery path is invisible in Amplitude — we'd only see the 8s worst-case tail.1903. **Layer-1 telemetry beacon.** Fire a second, lower-stakes event (e.g. `NewTabHydrationTimeout`) at the Layer-1 5s fallback site. Without it, the common post-fix recovery path is invisible in Amplitude — we'd only see the 8s worst-case tail.
200 191
201## Open questions192## Open questions
202 193
203None blocking. Operator decisions above are explicit choices, not unknowns.194None blocking. Operator decisions above are explicit choices, not unknowns.
204 195
205## Citations196## Citations
206 197
207- **Frontend finding:** `artifacts/toby-frontend-doctor/6e2b3eb9-36bf-42d3-8de3-5afa48f4b167/finding.md` (run id `6e2b3eb9-36bf-42d3-8de3-5afa48f4b167`; Playwright screenshot + synthetic HTML alongside).198- **Frontend finding:** `artifacts/toby-frontend-doctor/6e2b3eb9-36bf-42d3-8de3-5afa48f4b167/finding.md` (run id `6e2b3eb9-36bf-42d3-8de3-5afa48f4b167`; Playwright screenshot + synthetic HTML alongside).
208- **Backend finding:** `artifacts/toby-backend-doctor/083ec6d2-63e9-4c3e-b55e-a95301a4aa72/finding.md` (run id `083ec6d2-63e9-4c3e-b55e-a95301a4aa72`).199- **Backend finding:** `artifacts/toby-backend-doctor/083ec6d2-63e9-4c3e-b55e-a95301a4aa72/finding.md` (run id `083ec6d2-63e9-4c3e-b55e-a95301a4aa72`).
209- **Validation:** `artifacts/toby-incident-validator/a28a3690-38d7-4ce9-a9c2-c6d436da1793/validation.md` (run id `a28a3690-38d7-4ce9-a9c2-c6d436da1793`).200- **Validation:** `artifacts/toby-incident-validator/a28a3690-38d7-4ce9-a9c2-c6d436da1793/validation.md` (run id `a28a3690-38d7-4ce9-a9c2-c6d436da1793`).
210- **Synthesis draft (preserved):** `artifacts/toby-incident-coordinator/df069a93-28df-4439-8838-cfd953c4c974/synthesis-draft.md`.201- **Synthesis draft (preserved):** `artifacts/toby-incident-coordinator/df069a93-28df-4439-8838-cfd953c4c974/synthesis-draft.md`.
202- **Ship result:** `artifacts/toby-incident-fix-shipper/b3400d87-0830-4f89-bb70-4c3907c085f1/ship-result.md` (run id `b3400d87-0830-4f89-bb70-4c3907c085f1`).
211- **Proximate code site:** `apps/extension/app/containers/Toby.tsx:304` and `apps/extension/app/state/accessors/user.tsx:45-50,66-99`.203- **Proximate code site:** `apps/extension/app/containers/Toby.tsx:304` and `apps/extension/app/state/accessors/user.tsx:45-50,66-99`.
212- **Class-of-bug code site:** `apps/extension/app/utils/chromeapi.ts:248-259` (same missing-lastError/timeout pattern in `getChromeStorage`).204- **Class-of-bug code site:** `apps/extension/app/utils/chromeapi.ts:248-259` (same missing-lastError/timeout pattern in `getChromeStorage`).
213- **Proximate commit:** `d68726b29` (Jad Haidar, 2026-04-09, +5/-1 to `Toby.tsx`).205- **Proximate commit:** `d68726b29` (Jad Haidar, 2026-04-09, +5/-1 to `Toby.tsx`).
214- **Prior strategist hypothesis (REFUTED):** artifact `388c1db4-59b7-49e9-8ec3-ecfba972c95f`.206- **Prior strategist hypothesis (REFUTED):** artifact `388c1db4-59b7-49e9-8ec3-ecfba972c95f`.
215 207
216## Timeline208## Timeline
217 209
218| Time (UTC) | Event |210| Time (UTC) | Event |
219|---|---|211|---|---|
220| 2026-04-09 16:07 | Commit `d68726b29` lands. AuthWrapper gate widened to depend on `!isUserHydrated`. |212| 2026-04-09 16:07 | Commit `d68726b29` lands. AuthWrapper gate widened to depend on `!isUserHydrated`. |
221| post-2026-04-09 | User complaints about "blank page on infinite load" begin (CWS reviews, Vivaldi 7.0 / Orion forums). |213| post-2026-04-09 | User complaints about "blank page on infinite load" begin (CWS reviews, Vivaldi 7.0 / Orion forums). |
222| 2026-05-11 17:08 | Incident dispatched to warroom (this run). |214| 2026-05-11 17:08 | Incident dispatched to warroom. |
223| 2026-05-11 17:14 | Frontend doctor reports proximate cause + defers to backend. |215| 2026-05-11 17:14 | Frontend doctor reports proximate cause + defers to backend. |
224| 2026-05-11 17:30 | Backend doctor refutes SW-boot-regression hypothesis with prod-api / DB / SW-boot evidence. |216| 2026-05-11 17:30 | Backend doctor refutes SW-boot-regression hypothesis with prod-api / DB / SW-boot evidence. |
225| 2026-05-11 17:36 | Validator confirms synthesis with `validated` / high confidence. |217| 2026-05-11 17:36 | Validator confirms synthesis with `validated` / high confidence. |
226| 2026-05-11 17:38 | Incident doc published. **Status: closed**, fix queued for implementation by the operator. |218| 2026-05-11 17:38 | Incident doc published. **Status: closed**, fix queued for implementation by the operator. |
219| 2026-05-13 04:59 | Bridge files TOBY-14 ("Ship the blank-extension-page reliability hotfix") into the warroom inbox. |
220| 2026-05-13 05:08 | Fix-shipper opens PR https://github.com/axiomzen/toby-mono-repo/pull/12. **Status: shipped.** |
227 221
222## PR shipped
223
224- **PR URL:** https://github.com/axiomzen/toby-mono-repo/pull/12
225- **Branch:** `warroom/2026-05-11-blank-extension-page-toby-14`
226- **Commit:** `06baf0f8a` (base `75a09e34d` on `origin/main`)
227- **Source ticket:** TOBY-14
228- **Shipper run:** `b3400d87-0830-4f89-bb70-4c3907c085f1` (artifact: `artifacts/toby-incident-fix-shipper/b3400d87-0830-4f89-bb70-4c3907c085f1/ship-result.md`)
229- **Files touched:**
230 - `apps/extension/app/state/accessors/user.tsx` (Layer 1 — 5s timeout fail-open on `getUser()`)
231 - `apps/extension/app/hooks/useOnboarding2Draft.ts` (Layer 1 — same shape on `isReady`)
232 - `apps/extension/app/containers/Toby.tsx` (Layer 2 + Layer 3 — `StuckRecoveryScreen` at 8s + `NewTabHangShown` beacon)
233 - `apps/extension/app/components/StuckRecoveryScreen.tsx` (new component, copy "Your tabs are safe. Tap to recover.")
234- **What was deliberately NOT included** (per the "Follow-ups" section): Layer-1 shape for `isInitializing`, the SW-hardening trio, the Layer-1 telemetry beacon. Those remain queued as separate work.
235- **Verify plan:** the doc's "Verify plan" section above is the canonical checklist for CI + reviewer manual repro. No local typecheck/lint ran in the ephemeral worktree (no `node_modules`); the diff is minimal and additive, relying on CI for the full check.
236