Skip to content

Commit b2fb3fc

Browse files
fix(app): polyfill requestIdleCallback for iOS Safari (#5816)
## Summary - iPhone users could not use the app: every iOS Safari (and CriOS, which uses WebKit) instance threw `TypeError: window.requestIdleCallback is not a function` at `AppDataProvider` mount, which `ErrorBoundary` caught — leaving the user stuck on the error fallback. Surfaced by the on-device debug console added in #5808. - `requestIdleCallback` is documented as unsupported on Safari/iOS by [caniuse](https://caniuse.com/requestidlecallback), [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback), and [WebKit bug 164193](https://bugs.webkit.org/show_bug.cgi?id=164193). It exists only behind the *Experimental Features* toggle, which is off by default — so this is a hard regression for **every** iOS visitor, not a one-off device bug. - Fix is an inline feature-detect in the existing `useEffect`: use `requestIdleCallback` if present, otherwise fall back to `setTimeout(cb, 1)`. Cleanup picks the matching cancel function via captured boolean. No new npm dependency, no global mutation. - Adds a regression test that stubs both idle APIs as `undefined` and asserts the three initial fetches still fire and children still render. ### Original iPhone error report ``` Message: window.requestIdleCallback is not a function. (In 'window.requestIdleCallback(async()=>{try{let[e,t,n]=await Promise.all([fetch(`${T}/specs`),fetch(`${T}/libraries`),fetch(`${T}/stats`)]);...},{timeout:2e3})') URL: https://anyplot.ai/?debug=1 User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 26_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/148.0.7778.100 Mobile/15E148 Safari/604.1 ``` ## Test plan - [x] `cd app && yarn test --run` — 467/467 pass, including new `falls back to setTimeout when requestIdleCallback is unavailable (iOS Safari)` case in `Layout.test.tsx`. - [x] `cd app && yarn build` — TypeScript / Vite build green. - [x] Pre-existing lint findings on `main` unchanged; no new findings introduced by this PR. - [ ] Production verification on the reporter's iPhone after deploy (visit https://anyplot.ai/?debug=1, confirm home page loads and the Eruda console is clean). ## Why this approach (not a polyfill package) - Two well-known polyfills exist ([aFarkas/requestIdleCallback](https://github.com/aFarkas/requestIdleCallback), [pladaria/requestidlecallback-polyfill](https://github.com/pladaria/requestidlecallback-polyfill)) but both internally just wrap `setTimeout` — there is no way to truly polyfill real browser idle time. Adding a dependency would ship bytes to every modern-browser user for the same effect we get inline. - Single call site in the entire repo — per CLAUDE.md "no abstractions beyond what the task requires", a per-file inline fix is the right shape. If a second call site appears later, that is the moment to extract a `utils/idleCallback.ts`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7a09315 commit b2fb3fc

2 files changed

Lines changed: 43 additions & 4 deletions

File tree

app/src/components/Layout.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,32 @@ describe('AppDataProvider', () => {
127127
expect(screen.getByTestId('child')).toHaveTextContent('still renders');
128128
consoleSpy.mockRestore();
129129
});
130+
131+
it('falls back to setTimeout when requestIdleCallback is unavailable (iOS Safari)', async () => {
132+
// Simulate Safari/iOS where requestIdleCallback is undefined by default.
133+
vi.stubGlobal('requestIdleCallback', undefined);
134+
vi.stubGlobal('cancelIdleCallback', undefined);
135+
136+
const fetchMock = vi.fn().mockResolvedValue({
137+
ok: true,
138+
json: () => Promise.resolve({}),
139+
});
140+
vi.stubGlobal('fetch', fetchMock);
141+
142+
wrap(
143+
<AppDataProvider>
144+
<div data-testid="child">renders without TypeError</div>
145+
</AppDataProvider>,
146+
);
147+
148+
await waitFor(() => {
149+
expect(fetchMock).toHaveBeenCalledTimes(3);
150+
});
151+
152+
expect(screen.getByTestId('child')).toHaveTextContent('renders without TypeError');
153+
154+
// Restore stubbed globals so subsequent tests in this file (or future ones
155+
// appended after this) don't see `undefined` for the idle callback APIs.
156+
vi.unstubAllGlobals();
157+
});
130158
});

app/src/components/Layout.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,11 @@ export function AppDataProvider({ children }: { children: ReactNode }) {
3838
setHomeState((prev) => ({ ...prev, scrollY: window.scrollY }));
3939
}, []);
4040

41-
// Load shared data after browser is idle — gives /plots/filter bandwidth priority
41+
// Load shared data after browser is idle — gives /plots/filter bandwidth priority.
42+
// Safari/iOS doesn't ship requestIdleCallback by default, so feature-detect
43+
// and fall back to setTimeout — otherwise the TypeError takes the app down.
4244
useEffect(() => {
43-
const id = window.requestIdleCallback(async () => {
45+
const callback = async () => {
4446
try {
4547
const [specsRes, libsRes, statsRes] = await Promise.all([
4648
fetch(`${API_URL}/specs`),
@@ -65,8 +67,17 @@ export function AppDataProvider({ children }: { children: ReactNode }) {
6567
} catch (err) {
6668
console.warn('Initial data load incomplete:', err instanceof Error ? err.message : err);
6769
}
68-
}, { timeout: 2000 });
69-
return () => window.cancelIdleCallback(id);
70+
};
71+
72+
const hasRIC = typeof window.requestIdleCallback === 'function';
73+
const id: number = hasRIC
74+
? window.requestIdleCallback(callback, { timeout: 2000 })
75+
: window.setTimeout(callback, 1);
76+
77+
return () => {
78+
if (hasRIC) window.cancelIdleCallback(id);
79+
else window.clearTimeout(id);
80+
};
7081
}, []);
7182

7283
return (

0 commit comments

Comments
 (0)