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

+
+
+

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();