diff --git a/examples/basic/src/routes/page.tsx b/examples/basic/src/routes/page.tsx
index da6f946c3..068ef443e 100644
--- a/examples/basic/src/routes/page.tsx
+++ b/examples/basic/src/routes/page.tsx
@@ -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';
@@ -10,8 +11,17 @@ const Home = () => {
}, []);
return (
-
-
Hello World
+
+
+
+
+ Go to /
+ Go to /home
+ Go to /fatal-link-demo
+ Go to /:symbol/calc
+
);
};
diff --git a/examples/basic/src/routes/style.css b/examples/basic/src/routes/style.css
index b841ef4cf..3f11bf757 100644
--- a/examples/basic/src/routes/style.css
+++ b/examples/basic/src/routes/style.css
@@ -8,3 +8,15 @@
.hello:hover {
background: #ccc;
}
+
+.links {
+ display: flex;
+ flex-direction: column;
+
+ > * {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 50px;
+ }
+}
\ No newline at end of file
diff --git a/jest.config.js b/jest.config.js
index e97592444..2cd845284 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -16,7 +16,7 @@ module.exports = {
target: 'es6',
lib: ['esnext'],
module: 'commonjs',
- moduleResolution: 'nodenext',
+ moduleResolution: 'bundler',
skipLibCheck: true,
esModuleInterop: true,
noUnusedLocals: false
diff --git a/packages/platform-web/src/shuvi-app/react/Link.tsx b/packages/platform-web/src/shuvi-app/react/Link.tsx
index 2fa1225e5..c6bdae315 100644
--- a/packages/platform-web/src/shuvi-app/react/Link.tsx
+++ b/packages/platform-web/src/shuvi-app/react/Link.tsx
@@ -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 } = {};
@@ -58,7 +59,10 @@ async function prefetchFn(router: IRouter, to: PathRecord): Promise
{
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');
+ })
: []
);
}
@@ -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();
diff --git a/packages/platform-web/src/shuvi-app/react/utils/requestIdleCallback.ts b/packages/platform-web/src/shuvi-app/react/utils/requestIdleCallback.ts
deleted file mode 100644
index fc1488adc..000000000
--- a/packages/platform-web/src/shuvi-app/react/utils/requestIdleCallback.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-export const requestIdleCallback =
- (typeof self !== 'undefined' &&
- self.requestIdleCallback &&
- self.requestIdleCallback.bind(window)) ||
- function (cb: IdleRequestCallback): number {
- let start = Date.now();
- return setTimeout(function () {
- cb({
- didTimeout: false,
- timeRemaining: function () {
- return Math.max(0, 50 - (Date.now() - start));
- }
- });
- }, 1) as unknown as number;
- };
-
-export const cancelIdleCallback =
- (typeof self !== 'undefined' &&
- self.cancelIdleCallback &&
- self.cancelIdleCallback.bind(window)) ||
- function (id: number) {
- return clearTimeout(id);
- };
diff --git a/packages/platform-web/src/shuvi-app/react/utils/useIntersection.tsx b/packages/platform-web/src/shuvi-app/react/utils/useIntersection.tsx
index 22057e3b6..14ffa8733 100644
--- a/packages/platform-web/src/shuvi-app/react/utils/useIntersection.tsx
+++ b/packages/platform-web/src/shuvi-app/react/utils/useIntersection.tsx
@@ -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;
diff --git a/packages/utils/src/__tests__/idleCallback/awaitPageLoadAndIdle.browser.test.ts b/packages/utils/src/__tests__/idleCallback/awaitPageLoadAndIdle.browser.test.ts
new file mode 100644
index 000000000..f627ba5f3
--- /dev/null
+++ b/packages/utils/src/__tests__/idleCallback/awaitPageLoadAndIdle.browser.test.ts
@@ -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();
+ });
+ });
+});
diff --git a/packages/utils/src/__tests__/idleCallback/awaitPageLoadAndIdle.node.test.ts b/packages/utils/src/__tests__/idleCallback/awaitPageLoadAndIdle.node.test.ts
new file mode 100644
index 000000000..133b7c76c
--- /dev/null
+++ b/packages/utils/src/__tests__/idleCallback/awaitPageLoadAndIdle.node.test.ts
@@ -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'
+ );
+ });
+});
diff --git a/packages/utils/src/__tests__/idleCallback/requestIdleCallback.browser.test.ts b/packages/utils/src/__tests__/idleCallback/requestIdleCallback.browser.test.ts
new file mode 100644
index 000000000..90b464aff
--- /dev/null
+++ b/packages/utils/src/__tests__/idleCallback/requestIdleCallback.browser.test.ts
@@ -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);
+ });
+ });
+});
diff --git a/packages/utils/src/__tests__/idleCallback/requestIdleCallback.node.test.ts b/packages/utils/src/__tests__/idleCallback/requestIdleCallback.node.test.ts
new file mode 100644
index 000000000..8a42d2543
--- /dev/null
+++ b/packages/utils/src/__tests__/idleCallback/requestIdleCallback.node.test.ts
@@ -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();
+ });
+ });
+});
diff --git a/packages/utils/src/idleCallback.ts b/packages/utils/src/idleCallback.ts
new file mode 100644
index 000000000..c393fc5ce
--- /dev/null
+++ b/packages/utils/src/idleCallback.ts
@@ -0,0 +1,97 @@
+/**
+ * Force execution of callback after the specified timeout, default 3000ms
+ */
+function _requestIdleCallbackPolyfill(
+ cb: IdleRequestCallback,
+ options: IdleRequestOptions = { timeout: 3000 }
+) {
+ return setTimeout(() => {
+ cb({ didTimeout: false, timeRemaining: () => 50 });
+ }, options.timeout);
+}
+
+/**
+ * For server side, invoke the callback immediately
+ */
+function _requestIdleCallbackServerSide(cb: IdleRequestCallback) {
+ cb({ didTimeout: false, timeRemaining: () => 50 });
+ return NaN;
+}
+
+/**
+ * Assume the polyfill is implemented by setTimeout
+ */
+function _cancelIdleCallbackPolyfill(id: number) {
+ clearTimeout(id);
+}
+
+/**
+ * Do nothing on server side
+ */
+function _cancelIdleCallbackServerSide(_id: number) {
+ return;
+}
+
+export const requestIdleCallback =
+ typeof window !== 'undefined'
+ ? window.requestIdleCallback || _requestIdleCallbackPolyfill
+ : _requestIdleCallbackServerSide;
+
+export const cancelIdleCallback =
+ typeof window !== 'undefined'
+ ? window.cancelIdleCallback || _cancelIdleCallbackPolyfill
+ : _cancelIdleCallbackServerSide;
+
+/**
+ * awaitPageLoadAndIdle - Invokes the callback after:
+ * 1. The page has finished loading
+ * 2. Idle time remaining is >= specified `remainingTime` (default 49ms)
+ * 3. Timeout of `timeout` duration (default 2000ms) if idle condition is not met
+ */
+export function awaitPageLoadAndIdle(
+ { remainingTime, timeout }: { remainingTime: number; timeout: number } = {
+ remainingTime: 49,
+ timeout: 2000
+ }
+): Promise {
+ return new Promise((resolve, reject) => {
+ // Return early if function is called in a non-browser environment
+ if (typeof window === 'undefined') {
+ return reject(
+ new Error('[awaitPageLoadAndIdle] server side is not supported')
+ );
+ }
+
+ let idleCallbackId: number | undefined; // Tracks the idle callback
+
+ const tid = setTimeout(() => {
+ if (cancelIdleCallback && idleCallbackId) {
+ cancelIdleCallback(idleCallbackId);
+ }
+ resolve(); // Force resolve after timeout
+ }, timeout);
+
+ // Function to check if sufficient idle time is available
+ function onIdle(deadline: IdleDeadline) {
+ if (deadline.timeRemaining() >= remainingTime) {
+ clearTimeout(tid);
+ resolve();
+ } else {
+ idleCallbackId = requestIdleCallback(onIdle); // Retry if idle time insufficient
+ }
+ }
+
+ // Event handler to trigger on page load
+ const onLoad = () => {
+ window.removeEventListener('load', onLoad); // Clean up listener
+ idleCallbackId = requestIdleCallback(onIdle); // Start checking for idle time
+ };
+
+ // If page is already loaded, check idle time immediately
+ if (document.readyState === 'complete') {
+ idleCallbackId = requestIdleCallback(onIdle);
+ } else {
+ window.addEventListener('load', onLoad); // Wait for page load event
+ }
+ });
+}
diff --git a/packages/utils/tsconfig.build.json b/packages/utils/tsconfig.build.json
index 3aaddcdec..8cf01932c 100644
--- a/packages/utils/tsconfig.build.json
+++ b/packages/utils/tsconfig.build.json
@@ -2,7 +2,8 @@
"extends": "../../tsconfig.build.cjs.json",
"compilerOptions": {
"outDir": "lib",
- "esModuleInterop": true
+ "esModuleInterop": true,
+ "lib": ["dom", "dom.iterable", "esnext"]
},
"include": ["src"]
}
diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json
index e0f5eb0dd..82bcdcf6c 100644
--- a/packages/utils/tsconfig.json
+++ b/packages/utils/tsconfig.json
@@ -1,7 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
- "esModuleInterop": true
+ "esModuleInterop": true,
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "module": "esnext",
+ "moduleResolution": "bundler",
},
"include": ["src/**/*.ts"]
}
diff --git a/test/e2e/loader.test.ts b/test/e2e/loader.test.ts
index 4d643c91c..2c221d87a 100644
--- a/test/e2e/loader.test.ts
+++ b/test/e2e/loader.test.ts
@@ -1,7 +1,6 @@
import {
AppCtx,
Page,
- devFixture,
buildFixture,
serveFixture,
ShuviConfig
@@ -26,7 +25,14 @@ describe.each([false, true])('loader', hasBasename => {
describe(`ssr = true [hasBasename=${hasBasename}]`, () => {
beforeAll(async () => {
- ctx = await devFixture('loader', { ssr: true, ...baseShuviConfig });
+ buildFixture('loader', {
+ ssr: true,
+ ...baseShuviConfig
+ });
+ ctx = await serveFixture('loader', {
+ ssr: true,
+ ...baseShuviConfig
+ });
});
afterAll(async () => {
await ctx.close();