Skip to content

Commit 8f95264

Browse files
authored
perf(pages): overlap route component imports with data requests (#654)
## Issue Unprefetched Pages navigations currently wait for page data before importing the destination component and layouts. If both data fetching and component fetching takes long, the waterfall effect is significant. ## Fix Start imports for known same-origin GET destinations alongside the data request, then reuse them only when the response selects the matched page. Redirects and rewrites still render the server-selected component. Unknown routes, islands, mutations, and other-origin visits do not speculate; unused or aborted import failures do not break a later navigation. > [!CAUTION] > > 1. In the uncommon case of redirects, the component is prefetched and unused, leading to redundant work. I think this tradeoff is alright though. > > 2. This uses dynamic `import()`, so module-level code and transitive dependencies can execute, and route CSS can apply, before the data response or server middleware decision arrives. The router waits for the response before rendering the destination. A redirect or cancellation cannot undo import side effects. Existing explicit component prefetching has the same caveat; this change extends that timing to ordinary GET navigation. Benchmarks use the real React Pages client in Chrome, with 12 adjacent baseline/candidate pairs per scenario. Cold cases use simulated response delays on localhost; these are scheduling measurements, not production latency claims. | Scenario | Before | After | Mean latency reduction | | --- | ---: | ---: | ---: | | Cold, no added delay | 4.02 ms | 3.84 ms | 4.4% | | Cold, 80 ms data / 80 ms assets | 169.56 ms | 85.18 ms | 49.8% | | Cold, 160 ms data / 40 ms assets | 210.79 ms | 165.92 ms | 21.3% | | Cold, 40 ms data / 160 ms assets | 209.92 ms | 166.47 ms | 20.7% | | Warm modules, 80 ms data | 84.77 ms | 84.30 ms | 0.6% | | Completed data and component prefetch | 2.30 ms | 2.22 ms | 3.3% | Either way, it's an improvement! ## Validation: - `vp run test:all`: formatting, lint, package/platform/playground builds, artifact checks, typechecks, and the full suite passed (759 files, 15,370 tests; 31 tests skipped). - The final identifier/comment cleanup passed the 91-test router suite, lint, and all 33 `vp run check` tasks. - Regression coverage includes import/data overlap, waiting for layouts, rewrites, redirects, import failures, aborted visits, and excluded destinations. - Benchmark repository checks and three-page SSR/SSG/runtime smoke comparisons passed, including hydration, navigation, and HMR. Both canonical workloads were restored to 10,000 pages.
1 parent c242d7b commit 8f95264

2 files changed

Lines changed: 194 additions & 1 deletion

File tree

‎packages/void/src/pages/navigation-engine.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,17 @@ export async function createPageNavigationEngine(
640640
_prefetchCache.flushAll();
641641
}
642642

643+
// Start known GET route imports alongside data. The response still selects
644+
// the rendered component, since middleware may rewrite or redirect the URL.
645+
const matchedComponentId = method === 'GET' ? matchRoute(fetchUrl) : null;
646+
const preloadComponentPromise =
647+
matchedComponentId && !routeMeta[matchedComponentId]?.island && components[matchedComponentId]
648+
? Promise.all([resolveComponent(matchedComponentId), resolveLayouts(matchedComponentId)])
649+
: null;
650+
// A speculative failure only belongs to this visit if the response selects
651+
// that component. Handle it while data is pending, even if the visit aborts.
652+
preloadComponentPromise?.catch(() => {});
653+
643654
const headers: Record<string, string> = {
644655
'X-VoidPages': 'true',
645656
Accept: 'application/json',
@@ -829,8 +840,12 @@ export async function createPageNavigationEngine(
829840
commitTarget.fetchUrl = withoutActionKey(fetchUrl, pageData.url);
830841
}
831842

843+
// Reuse the preload only if the response selects the matched page; redirects
844+
// or rewrites may select another. Cancellation stops waiting for the imports.
832845
const [component, layouts] = await Promise.race([
833-
Promise.all([resolveComponent(pageData.component), resolveLayouts(pageData.component)]),
846+
matchedComponentId === pageData.component && preloadComponentPromise
847+
? preloadComponentPromise
848+
: Promise.all([resolveComponent(pageData.component), resolveLayouts(pageData.component)]),
834849
abortSignalPromise(signal),
835850
]);
836851

‎packages/void/test/unit/pages-client.test.ts‎

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ function pageJson(body: unknown): Response {
7676

7777
const loadComponent = async () => ({ default: {} });
7878

79+
function controlledPromise<T>() {
80+
let resolve!: (value: T) => void;
81+
let reject!: (reason: unknown) => void;
82+
const promise = new Promise<T>((res, rej) => {
83+
resolve = res;
84+
reject = rej;
85+
});
86+
return { promise, resolve, reject };
87+
}
88+
7989
function createTestRouter({ adapter, ...config }: TestRouterConfig = {}) {
8090
return createVoidRouter({
8191
components: { home: loadComponent },
@@ -138,6 +148,174 @@ describe('createVoidRouter', () => {
138148
);
139149
});
140150

151+
it('loads the destination page and layouts while its data request is pending', async () => {
152+
stubClientGlobals();
153+
const response = controlledPromise<Response>();
154+
const page = controlledPromise<{ default: object }>();
155+
const layout = controlledPromise<{ default: object }>();
156+
const pageLoader = vi.fn(() => page.promise);
157+
const layoutLoader = vi.fn(() => layout.promise);
158+
const fetchMock = vi.fn(() => response.promise);
159+
vi.stubGlobal('fetch', fetchMock);
160+
const applyUpdate = vi.fn();
161+
const { router } = await createTestRouter({
162+
adapter: { applyUpdate },
163+
components: { home: loadComponent, users: pageLoader, layout: layoutLoader },
164+
layoutTree: { users: ['layout'] },
165+
matchRoute: (path) => (path === '/users' ? 'users' : 'home'),
166+
});
167+
168+
const visit = router.visit('/users?tab=all#top');
169+
expect(fetchMock).toHaveBeenCalledOnce();
170+
expect(pageLoader).toHaveBeenCalledOnce();
171+
expect(layoutLoader).toHaveBeenCalledOnce();
172+
expect(applyUpdate).not.toHaveBeenCalled();
173+
174+
const pageData = { component: 'users', props: { count: 3 } };
175+
response.resolve(pageJson(pageData));
176+
const component = {};
177+
page.resolve({ default: component });
178+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
179+
expect(applyUpdate).not.toHaveBeenCalled();
180+
const layoutComponent = {};
181+
layout.resolve({ default: layoutComponent });
182+
183+
await expect(visit).resolves.toEqual(pageData);
184+
expect(applyUpdate).toHaveBeenCalledWith(pageData, component, [layoutComponent], {
185+
preserveState: false,
186+
});
187+
expect(pageLoader).toHaveBeenCalledOnce();
188+
expect(layoutLoader).toHaveBeenCalledOnce();
189+
});
190+
191+
it.each(['redirect', 'rewrite'])(
192+
'renders the server destination after a %s even if a speculative import fails',
193+
async (kind) => {
194+
stubClientGlobals();
195+
const response = controlledPromise<Response>();
196+
const pageLoader = vi.fn(async () => {
197+
throw new Error('Unused page chunk failed');
198+
});
199+
const destinationLoader = vi.fn(loadComponent);
200+
vi.stubGlobal(
201+
'fetch',
202+
vi.fn(() => response.promise),
203+
);
204+
const applyUpdate = vi.fn();
205+
const { router } = await createTestRouter({
206+
adapter: { applyUpdate },
207+
components: { home: loadComponent, users: pageLoader, destination: destinationLoader },
208+
matchRoute: () => 'users',
209+
});
210+
211+
const visit = router.visit('/users');
212+
// Let a speculative rejection settle before the server responds. Vitest also
213+
// fails this test if that rejection escapes as an unhandled promise.
214+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
215+
expect(pageLoader).toHaveBeenCalledOnce();
216+
expect(destinationLoader).not.toHaveBeenCalled();
217+
const pageData = { component: 'destination', props: {}, url: '/destination' };
218+
const result = pageJson(pageData);
219+
if (kind === 'redirect') {
220+
Object.defineProperties(result, {
221+
redirected: { value: true },
222+
url: { value: 'http://localhost/destination' },
223+
});
224+
}
225+
response.resolve(result);
226+
227+
await expect(visit).resolves.toEqual(pageData);
228+
expect(destinationLoader).toHaveBeenCalledOnce();
229+
expect(applyUpdate.mock.calls[0][0]).toEqual(pageData);
230+
expect(history.pushState).toHaveBeenCalledWith(
231+
expect.anything(),
232+
'',
233+
kind === 'redirect' ? '/destination' : '/users',
234+
);
235+
},
236+
);
237+
238+
it('surfaces a speculative import failure when the response selects that page', async () => {
239+
stubClientGlobals();
240+
const response = controlledPromise<Response>();
241+
const failure = new Error('Page chunk failed');
242+
const pageLoader = vi.fn(async () => {
243+
throw failure;
244+
});
245+
vi.stubGlobal(
246+
'fetch',
247+
vi.fn(() => response.promise),
248+
);
249+
const { router } = await createTestRouter({
250+
components: { home: loadComponent, users: pageLoader },
251+
matchRoute: () => 'users',
252+
});
253+
254+
const visit = router.visit('/users');
255+
const rejected = expect(visit).rejects.toBe(failure);
256+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
257+
response.resolve(pageJson({ component: 'users', props: {} }));
258+
await rejected;
259+
expect(pageLoader).toHaveBeenCalledOnce();
260+
});
261+
262+
it('ignores a speculative import failure after the visit is aborted', async () => {
263+
stubClientGlobals();
264+
const page = controlledPromise<{ default: object }>();
265+
vi.stubGlobal(
266+
'fetch',
267+
vi.fn((url: string, options: RequestInit) =>
268+
url === '/users'
269+
? new Promise<Response>((_resolve, reject) => {
270+
options.signal!.addEventListener('abort', () => {
271+
reject(options.signal!.reason);
272+
});
273+
})
274+
: Promise.resolve(pageJson({ component: 'home', props: {} })),
275+
),
276+
);
277+
const applyUpdate = vi.fn();
278+
const pageLoader = vi.fn(() => page.promise);
279+
const { router } = await createTestRouter({
280+
adapter: { applyUpdate },
281+
components: { home: loadComponent, users: pageLoader },
282+
matchRoute: (path) => (path === '/users' ? 'users' : 'home'),
283+
});
284+
285+
const abandonedVisit = router.visit('/users');
286+
expect(pageLoader).toHaveBeenCalledOnce();
287+
await router.visit('/');
288+
await expect(abandonedVisit).resolves.toBeUndefined();
289+
page.reject(new Error('Abandoned chunk failed'));
290+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
291+
expect(applyUpdate).toHaveBeenCalledOnce();
292+
expect(applyUpdate.mock.calls[0][0].component).toBe('home');
293+
});
294+
295+
it.each(['unknown', 'island', 'mutation', 'external'])(
296+
'does not speculate on %s destinations',
297+
async (kind) => {
298+
stubClientGlobals();
299+
const response = controlledPromise<Response>();
300+
const pageLoader = vi.fn(loadComponent);
301+
const fetchMock = vi.fn(() => response.promise);
302+
vi.stubGlobal('fetch', fetchMock);
303+
const { router } = await createTestRouter({
304+
components: { home: loadComponent, users: pageLoader },
305+
routeMeta: kind === 'island' ? { users: { island: true } } : {},
306+
matchRoute: () => (kind === 'unknown' ? null : 'users'),
307+
});
308+
309+
const visit = router.visit(kind === 'external' ? 'https://other.example/users' : '/users', {
310+
method: kind === 'mutation' ? 'POST' : 'GET',
311+
});
312+
expect(pageLoader).not.toHaveBeenCalled();
313+
response.resolve(pageJson({ component: 'home', props: {} }));
314+
await visit;
315+
expect(pageLoader).not.toHaveBeenCalled();
316+
},
317+
);
318+
141319
it('rejects non-GET methods before route side effects', async () => {
142320
const documentStub = stubClientGlobals();
143321
const homeLoader = vi.fn(async () => ({ default: {} }));

0 commit comments

Comments
 (0)