Skip to content

Commit a3cb89a

Browse files
committed
fix: restore post-bundle ready timing
Pause startup readiness timeout while Metro is actively building the first app bundle.
1 parent c09ce15 commit a3cb89a

2 files changed

Lines changed: 209 additions & 4 deletions

File tree

packages/bundler-metro/src/__tests__/startup.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ const emitBundleRequestObserved = (
4747
});
4848
};
4949

50+
const emitMetroEvent = (
51+
metroInstance: MetroInstance,
52+
event: ReportableEvent
53+
) => {
54+
metroInstance.events.emit(event);
55+
};
56+
5057
afterEach(() => {
5158
vi.useRealTimers();
5259
});
@@ -135,9 +142,93 @@ describe('waitForMetroBackedAppReady', () => {
135142
expect(waitForReady).toHaveBeenCalledTimes(1);
136143
});
137144

145+
it('does not count Metro bundle build time against readyTimeout', async () => {
146+
vi.useFakeTimers();
147+
148+
const metroInstance = createMetroInstance();
149+
let resolveReady!: () => void;
150+
const startAttempt = vi.fn(async () => {
151+
emitBundleRequestObserved(metroInstance, 'app');
152+
setTimeout(() => {
153+
emitMetroEvent(metroInstance, { type: 'bundle_build_started' } as never);
154+
}, 0);
155+
});
156+
const waitForReady = vi.fn(
157+
async () =>
158+
await new Promise<void>((resolve) => {
159+
resolveReady = resolve;
160+
})
161+
);
162+
163+
let settled = false;
164+
const promise = waitForMetroBackedAppReady({
165+
metro: metroInstance,
166+
platformId: 'ios',
167+
bundleStartTimeout: 1_000,
168+
readyTimeout: 2_000,
169+
maxAppRestarts: 2,
170+
signal: new AbortController().signal,
171+
startAttempt,
172+
waitForReady,
173+
waitForCrash: async (signal) => await waitForAbort(signal),
174+
}).finally(() => {
175+
settled = true;
176+
});
177+
178+
await vi.advanceTimersByTimeAsync(0);
179+
await vi.advanceTimersByTimeAsync(5_000);
180+
181+
expect(settled).toBe(false);
182+
183+
emitMetroEvent(metroInstance, { type: 'bundle_build_done' } as never);
184+
await vi.advanceTimersByTimeAsync(1_500);
185+
186+
expect(settled).toBe(false);
187+
188+
resolveReady();
189+
await promise;
190+
191+
expect(waitForReady).toHaveBeenCalledTimes(1);
192+
});
193+
138194
it('fails when the app requests its bundle but never reports ready', async () => {
139195
vi.useFakeTimers();
140196

197+
const metroInstance = createMetroInstance();
198+
const startAttempt = vi.fn(async () => {
199+
emitBundleRequestObserved(metroInstance, 'app');
200+
setTimeout(() => {
201+
emitMetroEvent(metroInstance, { type: 'bundle_build_started' } as never);
202+
emitMetroEvent(metroInstance, { type: 'bundle_build_done' } as never);
203+
}, 0);
204+
});
205+
206+
const promise = waitForMetroBackedAppReady({
207+
metro: metroInstance,
208+
platformId: 'ios',
209+
bundleStartTimeout: 1_000,
210+
readyTimeout: 2_000,
211+
maxAppRestarts: 2,
212+
signal: new AbortController().signal,
213+
startAttempt,
214+
waitForReady: async (signal) => await waitForAbort(signal),
215+
waitForCrash: async (signal) => await waitForAbort(signal),
216+
});
217+
218+
await vi.advanceTimersByTimeAsync(0);
219+
await vi.advanceTimersByTimeAsync(2_000);
220+
221+
await expect(promise).rejects.toMatchObject({
222+
name: 'StartupStallError',
223+
code: 'ready_not_reported',
224+
attempts: 1,
225+
});
226+
expect(startAttempt).toHaveBeenCalledTimes(1);
227+
});
228+
229+
it('starts readyTimeout immediately when Metro does not emit bundle build events', async () => {
230+
vi.useFakeTimers();
231+
141232
const metroInstance = createMetroInstance();
142233
const startAttempt = vi.fn(async () => {
143234
emitBundleRequestObserved(metroInstance, 'app');

packages/bundler-metro/src/startup.ts

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ type BundleRequestObservation = {
1515
sawPrewarmRequest: boolean;
1616
};
1717

18+
class ReadyTimeoutError extends Error {
19+
constructor() {
20+
super('Timed out waiting for the app to become ready after Metro bundling.');
21+
this.name = 'ReadyTimeoutError';
22+
}
23+
}
24+
1825
export type WaitForMetroBackedAppReadyOptions = {
1926
metro: MetroInstance;
2027
platformId: string;
@@ -98,6 +105,106 @@ const waitForBundleRequest = async ({
98105
});
99106
};
100107

108+
const waitForReadyAfterBundleRequest = async (options: {
109+
events: MetroInstance['events'];
110+
readyTimeout: number;
111+
signal: AbortSignal;
112+
waitForReady: (signal: AbortSignal) => Promise<void>;
113+
}): Promise<void> => {
114+
const { events, readyTimeout, signal, waitForReady } = options;
115+
116+
return await new Promise<void>((resolve, reject) => {
117+
let bundlingInProgress = false;
118+
let settled = false;
119+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
120+
const readyController = new AbortController();
121+
const readySignal = raceAbortSignals([signal, readyController.signal]);
122+
123+
const clearReadyTimer = () => {
124+
if (timeoutId) {
125+
clearTimeout(timeoutId);
126+
timeoutId = null;
127+
}
128+
};
129+
130+
const cleanup = () => {
131+
clearReadyTimer();
132+
events.removeListener(onMetroEvent);
133+
signal.removeEventListener('abort', onAbort);
134+
};
135+
136+
const resolveOnce = () => {
137+
if (settled) {
138+
return;
139+
}
140+
141+
settled = true;
142+
cleanup();
143+
resolve();
144+
};
145+
146+
const rejectOnce = (error: unknown) => {
147+
if (settled) {
148+
return;
149+
}
150+
151+
settled = true;
152+
cleanup();
153+
reject(error);
154+
};
155+
156+
const startReadyTimer = () => {
157+
clearReadyTimer();
158+
timeoutId = setTimeout(() => {
159+
readyController.abort(new DOMException('The operation was aborted', 'AbortError'));
160+
rejectOnce(new ReadyTimeoutError());
161+
}, readyTimeout);
162+
};
163+
164+
const onAbort = () => {
165+
rejectOnce(signal.reason ?? new DOMException('The operation was aborted', 'AbortError'));
166+
};
167+
168+
const onMetroEvent = (event: ReportableEvent) => {
169+
if (event.type === 'bundle_build_started') {
170+
bundlingInProgress = true;
171+
clearReadyTimer();
172+
return;
173+
}
174+
175+
if (
176+
bundlingInProgress &&
177+
(event.type === 'bundle_build_done' ||
178+
event.type === 'bundle_build_failed')
179+
) {
180+
bundlingInProgress = false;
181+
startReadyTimer();
182+
}
183+
};
184+
185+
startReadyTimer();
186+
events.addListener(onMetroEvent);
187+
signal.addEventListener('abort', onAbort, { once: true });
188+
189+
void waitForReady(readySignal)
190+
.then(() => {
191+
resolveOnce();
192+
})
193+
.catch((error) => {
194+
if (
195+
readyController.signal.aborted &&
196+
!signal.aborted &&
197+
error instanceof DOMException &&
198+
error.name === 'AbortError'
199+
) {
200+
return;
201+
}
202+
203+
rejectOnce(error);
204+
});
205+
});
206+
};
207+
101208
export const waitForMetroBackedAppReady = async ({
102209
metro,
103210
platformId,
@@ -157,9 +264,12 @@ export const waitForMetroBackedAppReady = async ({
157264
]);
158265
sawPrewarmRequest = bundleRequestResult.sawPrewarmRequest;
159266

160-
const readyPromise = waitForReady(
161-
withAbortTimeout(attemptSignal, readyTimeout)
162-
);
267+
const readyPromise = waitForReadyAfterBundleRequest({
268+
events: metro.events,
269+
readyTimeout,
270+
signal: attemptSignal,
271+
waitForReady,
272+
});
163273
await Promise.race([readyPromise, crashPromise]);
164274
attemptController.abort();
165275
onAttemptReset?.();
@@ -186,14 +296,18 @@ export const waitForMetroBackedAppReady = async ({
186296
continue;
187297
}
188298

189-
if (isAbortError(error)) {
299+
if (error instanceof ReadyTimeoutError) {
190300
throw new StartupStallError(readyTimeout, attempt, {
191301
code: 'ready_not_reported',
192302
lastMetroStatus,
193303
sawPrewarmRequest,
194304
});
195305
}
196306

307+
if (isAbortError(error)) {
308+
throw error;
309+
}
310+
197311
throw error;
198312
}
199313
}

0 commit comments

Comments
 (0)