Skip to content

Commit

Permalink
perf: lazy prefetching to avoid negative performance impact for the f…
Browse files Browse the repository at this point in the history
…irst page (#602)
  • Loading branch information
evenchange4 authored Dec 31, 2024
1 parent 01ed9e2 commit bcf880f
Show file tree
Hide file tree
Showing 14 changed files with 409 additions and 33 deletions.
14 changes: 12 additions & 2 deletions examples/basic/src/routes/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect } from 'react';
import { Link } from '@shuvi/runtime';
import esModule from '@shuvi/package-esmodule';
import { consoleLog } from '@shuvi/package-esmodule/utils';
import styles from './style.css';
Expand All @@ -10,8 +11,17 @@ const Home = () => {
}, []);

return (
<div className={styles.hello}>
<p>Hello World</p>
<div>
<div className={styles.hello}>
<p>Hello World</p>
</div>

<div className={styles.links}>
<Link to="/">Go to /</Link>
<Link to="/home">Go to /home</Link>
<Link to="/fatal-link-demo">Go to /fatal-link-demo</Link>
<Link to="/symbol-demo/calc">Go to /:symbol/calc</Link>
</div>
</div>
);
};
Expand Down
12 changes: 12 additions & 0 deletions examples/basic/src/routes/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,15 @@
.hello:hover {
background: #ccc;
}

.links {
display: flex;
flex-direction: column;

> * {
display: flex;
align-items: center;
justify-content: center;
height: 50px;
}
}
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ module.exports = {
target: 'es6',
lib: ['esnext'],
module: 'commonjs',
moduleResolution: 'nodenext',
moduleResolution: 'bundler',
skipLibCheck: true,
esModuleInterop: true,
noUnusedLocals: false
Expand Down
13 changes: 11 additions & 2 deletions packages/platform-web/src/shuvi-app/react/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getFilesOfRoute
} from '@shuvi/platform-shared/shared';
import useIntersection from './utils/useIntersection';
import { awaitPageLoadAndIdle } from '@shuvi/utils/idleCallback';

const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/;
const prefetched: { [cacheKey: string]: boolean } = {};
Expand Down Expand Up @@ -58,7 +59,10 @@ async function prefetchFn(router: IRouter, to: PathRecord): Promise<void> {
const canPrefetch: boolean = hasSupportPrefetch();
await Promise.all(
canPrefetch
? files.js.map(({ url, id }) => prefetchViaDom(url, id, 'script'))
? files.js.map(async ({ url, id }) => {
await awaitPageLoadAndIdle({ remainingTime: 49, timeout: 10 * 1000 });
await prefetchViaDom(url, id, 'script');
})
: []
);
}
Expand All @@ -79,7 +83,12 @@ export const Link = function LinkWithPrefetch({
const [setIntersectionRef, isVisible, resetVisible] = useIntersection({});
const { router } = React.useContext(RouterContext);
const setRef = React.useCallback(
(el: Element) => {
async (el: Element) => {
/**
* Lazy prefetching to avoid negative performance impact for the first page.
*/
await awaitPageLoadAndIdle({ remainingTime: 49, timeout: 10 * 1000 });

// Before the link getting observed, check if visible state need to be reset
if (isHrefValid && previousHref.current !== to) {
resetVisible();
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { requestIdleCallback, cancelIdleCallback } from './requestIdleCallback';
import {
requestIdleCallback,
cancelIdleCallback
} from '@shuvi/utils/idleCallback';

type UseIntersection = {
rootRef?: React.RefObject<HTMLElement>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* @jest-environment jsdom
*/

describe('awaitPageLoadAndIdle', () => {
let originalRequestIdleCallback: typeof window.requestIdleCallback;
let originalCancelIdleCallback: typeof window.cancelIdleCallback;

beforeAll(() => {
// Save the original `requestIdleCallback` and `cancelIdleCallback` for restoration
originalRequestIdleCallback = window.requestIdleCallback;
originalCancelIdleCallback = window.cancelIdleCallback;

Object.defineProperty(document, 'readyState', {
value: 'complete',
writable: true
});
});

beforeEach(() => {
// Restore mocks after each test
jest.clearAllMocks();
});

afterEach(() => {
// Restore mocks after each test
jest.clearAllMocks();
window.requestIdleCallback = originalRequestIdleCallback;
window.cancelIdleCallback = originalCancelIdleCallback;
// @ts-expect-error test purpose
document.readyState = 'loading'; // Reset the readyState
});

it('should resolve immediately if the page is already loaded and idle time condition is met', async () => {
expect.assertions(2);

// @ts-expect-error test purpose
document.readyState = 'complete';

window.requestIdleCallback = jest.fn(callback => {
const fakeDeadline = { timeRemaining: () => 50 } as IdleDeadline;
return setTimeout(() => callback(fakeDeadline), 10) as unknown as number;
});
window.cancelIdleCallback = jest.fn(id => {
clearTimeout(id);
});

await jest.isolateModulesAsync(async () => {
const { awaitPageLoadAndIdle } = await import('../../idleCallback');
await expect(awaitPageLoadAndIdle()).resolves.toBeUndefined();
expect(window.cancelIdleCallback).not.toHaveBeenCalled();
});
});

it('should wait until the page load event is triggered', async () => {
expect.assertions(2);

// @ts-expect-error test purpose
document.readyState = 'loading';

window.requestIdleCallback = jest.fn(callback => {
const fakeDeadline = { timeRemaining: () => 50 } as IdleDeadline;
return setTimeout(() => callback(fakeDeadline), 10) as unknown as number;
});
window.cancelIdleCallback = jest.fn(id => {
clearTimeout(id);
});

await jest.isolateModulesAsync(async () => {
const { awaitPageLoadAndIdle } = await import('../../idleCallback');
const promise = awaitPageLoadAndIdle();

// Simulate page load event
setTimeout(() => {
// @ts-expect-error test purpose
document.readyState = 'complete';
window.dispatchEvent(new Event('load'));
}, 20);

await expect(promise).resolves.toBeUndefined();
expect(window.cancelIdleCallback).not.toHaveBeenCalled();
});
});

it('should resolve after the timeout if idle time is not sufficient', async () => {
expect.assertions(2);

// @ts-expect-error test purpose
document.readyState = 'complete';

window.requestIdleCallback = jest.fn(callback => {
const fakeDeadline = { timeRemaining: () => 10 } as IdleDeadline;
return setTimeout(() => callback(fakeDeadline), 10) as unknown as number;
});
window.cancelIdleCallback = jest.fn(id => {
clearTimeout(id);
});
await jest.isolateModulesAsync(async () => {
const { awaitPageLoadAndIdle } = await import('../../idleCallback');
await expect(
awaitPageLoadAndIdle({ remainingTime: 49, timeout: 1000 })
).resolves.toBeUndefined();
expect(window.cancelIdleCallback).toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* @jest-environment node
*/

import { awaitPageLoadAndIdle } from '../../idleCallback';

describe('awaitPageLoadAndIdle', () => {
it('should reject if called in a non-browser environment', async () => {
await expect(awaitPageLoadAndIdle()).rejects.toThrow(
'[awaitPageLoadAndIdle] server side is not supported'
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* @jest-environment jsdom
*/

describe('browser requestIdleCallback and cancelIdleCallback polyfill', () => {
let originalWindow: typeof window;

beforeAll(() => {
// Save the original window object
originalWindow = global.window;
});

beforeEach(() => {
jest.clearAllMocks();
// Mock window object for a browser environment
global.window = {} as unknown as typeof originalWindow;
});

afterEach(() => {
// Clear all mocks and restore original window object after each test
jest.clearAllMocks();
global.window = originalWindow;
});

test('should use window.requestIdleCallback if available', async () => {
expect.assertions(1);

await jest.isolateModulesAsync(async () => {
global.window.requestIdleCallback = jest.fn();
const callback = jest.fn();
const { requestIdleCallback } = await import('../../idleCallback');
requestIdleCallback(callback);
expect(global.window.requestIdleCallback).toHaveBeenCalledWith(callback);
});
});

test('should fall back to polyfill if window.requestIdleCallback is unavailable', async () => {
expect.assertions(3);

// @ts-expect-error test purpose
global.window.requestIdleCallback = undefined;
expect(global.window.requestIdleCallback).toBeUndefined();

const callback = jest.fn();
const timeout = 3000;

// Mock setTimeout to control its behavior
jest.useFakeTimers();

await jest.isolateModulesAsync(async () => {
const { requestIdleCallback } = await import('../../idleCallback');
requestIdleCallback(callback);
jest.advanceTimersByTime(timeout);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
didTimeout: false,
timeRemaining: expect.any(Function)
})
);
// execute timeRemaining function
expect(callback.mock.calls[0][0].timeRemaining()).toBe(50);

jest.useRealTimers();
});
});

test('cancelIdleCallback should use window.cancelIdleCallback if available', async () => {
global.window.cancelIdleCallback = jest.fn();

const id = 123;

await jest.isolateModulesAsync(async () => {
const { cancelIdleCallback } = await import('../../idleCallback');
cancelIdleCallback(id);
expect(global.window.cancelIdleCallback).toHaveBeenCalledWith(id);
});
});

test('should fall back to clearTimeout if window.cancelIdleCallback is unavailable', async () => {
expect.assertions(2);

// @ts-expect-error test purpose
global.window.cancelIdleCallback = undefined;
expect(global.window.cancelIdleCallback).toBeUndefined();

// Mock clearTimeout to test the polyfill
const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
const id = setTimeout(() => {}, 0) as unknown as number;

await jest.isolateModulesAsync(async () => {
const { cancelIdleCallback } = await import('../../idleCallback');
cancelIdleCallback(id);
expect(clearTimeoutSpy).toHaveBeenCalledWith(id);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* @jest-environment node
*/

describe('node requestIdleCallback and cancelIdleCallback polyfill', () => {
test('requestIdleCallback should invoke callback immediately on server', async () => {
expect.assertions(3);
expect(global.requestIdleCallback).toBeUndefined();

const callback = jest.fn();

await jest.isolateModulesAsync(async () => {
const { requestIdleCallback } = await import('../../idleCallback');
requestIdleCallback(callback);

expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
didTimeout: false,
timeRemaining: expect.any(Function)
})
);
// execute timeRemaining function
expect(callback.mock.calls[0][0].timeRemaining()).toBe(50);
});
});

test('cancelIdleCallback should do nothing on server', async () => {
expect.assertions(2);

expect(global.cancelIdleCallback).toBeUndefined();

const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');

await jest.isolateModulesAsync(async () => {
const { cancelIdleCallback } = await import('../../idleCallback');

cancelIdleCallback(123);

// Should not call clearTimeout
expect(clearTimeoutSpy).not.toHaveBeenCalled();
});
});
});
Loading

0 comments on commit bcf880f

Please sign in to comment.