Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf: lazy prefetching to avoid negative performance impact #602

Merged
merged 1 commit into from
Dec 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading