diff --git a/.changeset/openruntime-router-hooks.md b/.changeset/openruntime-router-hooks.md new file mode 100644 index 000000000000..37cd2b8c2d31 --- /dev/null +++ b/.changeset/openruntime-router-hooks.md @@ -0,0 +1,8 @@ +--- +'@modern-js/runtime': minor +'@modern-js/plugin': minor +--- + +feat: add runtime hooks for hydration, router state, route loader, and route component lifecycle + +feat: 新增 hydration、router state、route loader 和 route component lifecycle 运行时 hooks diff --git a/packages/runtime/plugin-runtime/rstest.config.mts b/packages/runtime/plugin-runtime/rstest.config.mts index ef5e4b4c07ef..482924048e4f 100644 --- a/packages/runtime/plugin-runtime/rstest.config.mts +++ b/packages/runtime/plugin-runtime/rstest.config.mts @@ -22,7 +22,10 @@ export default { withTestPreset({ name: 'plugin-runtime-node', testEnvironment: 'node', - exclude: ['tests/router/prefetch.test.tsx'], + exclude: [ + 'tests/router/lifecycleHooks.test.tsx', + 'tests/router/prefetch.test.tsx', + ], extends: commonConfig, plugins: [ { @@ -46,7 +49,10 @@ export default { withTestPreset({ name: 'plugin-runtime-client', testEnvironment: 'happy-dom', - include: ['tests/router/prefetch.test.tsx'], + include: [ + 'tests/router/lifecycleHooks.test.tsx', + 'tests/router/prefetch.test.tsx', + ], extends: commonConfig, plugins: [ { diff --git a/packages/runtime/plugin-runtime/src/core/browser/hydrate.tsx b/packages/runtime/plugin-runtime/src/core/browser/hydrate.tsx index 688fc4c7caa7..1e486fc8880d 100644 --- a/packages/runtime/plugin-runtime/src/core/browser/hydrate.tsx +++ b/packages/runtime/plugin-runtime/src/core/browser/hydrate.tsx @@ -9,14 +9,30 @@ import { WithCallback } from './withCallback'; export const isReact18 = () => process.env.IS_REACT18 === 'true'; +export type HydrationReporter = (event: { + type: 'start' | 'success' | 'fallback' | 'error' | 'recoverable-error'; + renderLevel: RenderLevel; + renderMode: string; + reason?: string; + root?: HTMLElement | Root; + error?: unknown; + errorInfo?: unknown; +}) => void; + +export interface ModernHydrateOptions { + callback?: () => void; + onRecoverableError?: (error: unknown, errorInfo?: unknown) => void; +} + export function hydrateRoot( App: React.ReactElement, context: TRuntimeContext, ModernRender: (App: React.ReactElement) => Promise, ModernHydrate: ( App: React.ReactElement, - callback?: () => void, + options?: ModernHydrateOptions, ) => Promise, + reportHydration?: HydrationReporter, ) { const hydrateContext: TRuntimeContext & { __hydration?: boolean } = { ...context, @@ -26,17 +42,76 @@ export function hydrateRoot( _hydration: true, }; - const callback = () => { - // won't cause component re-render because context's reference identity doesn't change - delete hydrateContext._hydration; - }; - // if render level not exist, use client render const renderLevel = window?._SSR_DATA?.renderLevel || RenderLevel.CLIENT_RENDER; const renderMode = window?._SSR_DATA?.mode || 'string'; + const report = (event: Parameters[0]) => { + reportHydration?.(event); + }; + const reportFallback = async ( + reason: string, + render: Promise, + ) => { + try { + const root = await render; + report({ + type: 'fallback', + renderLevel, + renderMode, + reason, + root, + }); + return root; + } catch (error) { + report({ + type: 'error', + renderLevel, + renderMode, + reason, + error, + }); + throw error; + } + }; + + let reportedSuccess = false; + const reportSuccess = (root?: HTMLElement | Root) => { + if (reportedSuccess) { + return; + } + reportedSuccess = true; + report({ + type: 'success', + renderLevel, + renderMode, + root, + }); + }; + const callback = () => { + // won't cause component re-render because context's reference identity doesn't change + delete hydrateContext._hydration; + reportSuccess(); + }; + const onRecoverableError = (error: unknown, errorInfo?: unknown) => { + report({ + type: 'recoverable-error', + renderLevel, + renderMode, + reason: 'recoverable-hydration-error', + error, + errorInfo, + }); + }; + + report({ + type: 'start', + renderLevel, + renderMode, + }); + if (isReact18() && renderMode === 'stream') { return streamSSRHydrate(); } @@ -49,9 +124,28 @@ export function hydrateRoot( ); return ModernHydrate( wrapRuntimeContextProvider(, hydrateContext), - ); + { + onRecoverableError, + }, + ) + .then(root => { + reportSuccess(root); + return root; + }) + .catch(error => { + report({ + type: 'error', + renderLevel, + renderMode, + error, + }); + throw error; + }); } else { - return ModernRender(wrapRuntimeContextProvider(App, context)); + return reportFallback( + 'client-render', + ModernRender(wrapRuntimeContextProvider(App, context)), + ); } } @@ -60,9 +154,12 @@ export function hydrateRoot( function stringSSRHydrate() { // client render and server prefetch use same logic if (renderLevel === RenderLevel.CLIENT_RENDER) { - return ModernRender(wrapRuntimeContextProvider(App, context)); + return reportFallback( + 'client-render', + ModernRender(wrapRuntimeContextProvider(App, context)), + ); } else if (renderLevel === RenderLevel.SERVER_RENDER) { - return new Promise(resolve => { + return new Promise((resolve, reject) => { if (isReact18()) { loadableReady(() => { // callback: https://github.com/reactwg/react-18/discussions/5 @@ -71,25 +168,52 @@ export function hydrateRoot( ); ModernHydrate( wrapRuntimeContextProvider(, hydrateContext), - ).then(root => { - resolve(root); - }); + { + onRecoverableError, + }, + ) + .then(root => { + reportSuccess(root); + resolve(root); + }) + .catch(error => { + report({ + type: 'error', + renderLevel, + renderMode, + error, + }); + reject(error); + }); }); } else { loadableReady(() => { - ModernHydrate( - wrapRuntimeContextProvider(App, hydrateContext), + ModernHydrate(wrapRuntimeContextProvider(App, hydrateContext), { callback, - ).then(root => { - resolve(root); - }); + }) + .then(root => { + reportSuccess(root); + resolve(root); + }) + .catch(error => { + report({ + type: 'error', + renderLevel, + renderMode, + error, + }); + reject(error); + }); }); } }); } else { // unknown renderlevel or renderlevel is server prefetch. console.warn(`unknow render level: ${renderLevel}, execute render()`); - return ModernRender(wrapRuntimeContextProvider(App, context)); + return reportFallback( + 'unknown-render-level', + ModernRender(wrapRuntimeContextProvider(App, context)), + ); } } } diff --git a/packages/runtime/plugin-runtime/src/core/browser/index.tsx b/packages/runtime/plugin-runtime/src/core/browser/index.tsx index 8569d75eeb7c..66318963ce2a 100644 --- a/packages/runtime/plugin-runtime/src/core/browser/index.tsx +++ b/packages/runtime/plugin-runtime/src/core/browser/index.tsx @@ -5,7 +5,7 @@ import { getGlobalInternalRuntimeContext } from '../context'; import { type TRuntimeContext, getInitialContext } from '../context/runtime'; import { wrapRuntimeContextProvider } from '../react/wrapper'; import type { SSRContainer } from '../types'; -import { hydrateRoot } from './hydrate'; +import { type ModernHydrateOptions, hydrateRoot } from './hydrate'; const IS_REACT18 = process.env.IS_REACT18 === 'true'; @@ -103,15 +103,22 @@ export async function render( async function ModernHydrate( App: React.ReactElement, - callback?: () => void, + options?: ModernHydrateOptions, ) { const hydrateFunc = IS_REACT18 ? hydrateWithReact18 : hydrateWithReact17; - return hydrateFunc(App, rootElement, callback); + return hydrateFunc(App, rootElement, options); } // we should hydateRoot only when ssr if (window._SSR_DATA) { - return hydrateRoot(App, context, ModernRender, ModernHydrate); + const internalRuntimeContext = getGlobalInternalRuntimeContext(); + const hooks = internalRuntimeContext!.hooks; + return hydrateRoot(App, context, ModernRender, ModernHydrate, event => { + hooks.onHydration.call({ + ...event, + context, + }); + }); } return ModernRender(wrapRuntimeContextProvider(App, context)); } @@ -142,10 +149,12 @@ export async function renderWithReact17( export async function hydrateWithReact18( App: React.ReactElement, rootElement: HTMLElement, + options?: ModernHydrateOptions, ) { const ReactDOM = await import('react-dom/client'); const root = ReactDOM.hydrateRoot(rootElement, App, { identifierPrefix: SSR_HYDRATION_ID_PREFIX, + onRecoverableError: options?.onRecoverableError, }); return root; } @@ -153,9 +162,9 @@ export async function hydrateWithReact18( export async function hydrateWithReact17( App: React.ReactElement, rootElement: HTMLElement, - callback?: () => void, + options?: ModernHydrateOptions, ) { const ReactDOM: any = await import('react-dom'); - const root = ReactDOM.hydrate(App, rootElement, callback); + const root = ReactDOM.hydrate(App, rootElement, options?.callback); return root as any; } diff --git a/packages/runtime/plugin-runtime/src/router/cli/code/templates.ts b/packages/runtime/plugin-runtime/src/router/cli/code/templates.ts index 3b515471ffdb..858030c264e8 100644 --- a/packages/runtime/plugin-runtime/src/router/cli/code/templates.ts +++ b/packages/runtime/plugin-runtime/src/router/cli/code/templates.ts @@ -280,7 +280,11 @@ export const fileSystemRoutes = async ({ ? `/* webpackMode: "eager" */ ` : ''; - return `() => import(${importOptions}'${componentPath}').then(routeModule => handleRouteModule(routeModule, "${routeId}")).catch(handleRouteModuleError)`; + return [ + `() => import(${importOptions}'${componentPath}')`, + `.then(routeModule => handleRouteModule(routeModule, "${routeId}"))`, + `.catch(error => handleRouteModuleError(error, "${routeId}"))`, + ].join(''); }; const traverseRouteTree = async ( @@ -380,6 +384,13 @@ export const fileSystemRoutes = async ({ } } + const routeId = + route.type === 'nested' ? route.id : route.path || route._component || ''; + const finalLoader = + route.type === 'nested' && route.id && loader + ? `createRouteLoader("${route.id}", ${loader})` + : loader; + const isClientComponent = route.type === 'nested' && Boolean(route._component) && @@ -392,18 +403,25 @@ export const fileSystemRoutes = async ({ )); const shouldIncludeClientBundle = !isRscClientBundle || isClientComponent; + const finalComponent = + shouldIncludeClientBundle && route._component && routeId + ? `createRouteComponent(${JSON.stringify(routeId)}, ${component})` + : component; const finalRoute: any = { ...route, loading, - loader, + loader: finalLoader, action, config, error, children, ...(isClientComponent && { isClientComponent: true }), ...(shouldIncludeClientBundle && { lazyImport }), - ...(shouldIncludeClientBundle && route._component && { component }), + ...(shouldIncludeClientBundle && + route._component && { + component: finalComponent, + }), }; /** * All routing components with loader will add shouldRevalidate @@ -530,7 +548,7 @@ export const fileSystemRoutes = async ({ await fs.writeJSON(loadersMapFile, loadersMap); const importRuntimeRouterCode = ` - import { createShouldRevalidate, handleRouteModule, handleRouteModuleError} from '@${metaName}/runtime/routerHelper'; + import { createRouteComponent, createRouteLoader, createShouldRevalidate, handleRouteModule, handleRouteModuleError} from '@${metaName}/runtime/routerHelper'; `; const routeModulesCode = ` if(typeof document !== 'undefined'){ diff --git a/packages/runtime/plugin-runtime/src/router/runtime/hooks.ts b/packages/runtime/plugin-runtime/src/router/runtime/hooks.ts index cd3e3354e3e8..2026eac7d0f4 100644 --- a/packages/runtime/plugin-runtime/src/router/runtime/hooks.ts +++ b/packages/runtime/plugin-runtime/src/router/runtime/hooks.ts @@ -1,15 +1,92 @@ import { createAsyncInterruptHook, createSyncHook } from '@modern-js/plugin'; -import type { RouteObject } from '@modern-js/runtime-utils/router'; +import type { DataRouter, RouteObject } from '@modern-js/runtime-utils/router'; import type { TRuntimeContext } from '../../core/context/runtime'; +export type RouterCreatedEvent = { + router: DataRouter; + routes: RouteObject[]; + basename: string; + context: TRuntimeContext; +}; + +export type RouterStateChangeEvent = { + router: DataRouter; + routes: RouteObject[]; + state: DataRouter['state']; + context: TRuntimeContext; +}; + +export type RouteLoaderEvent = + | { + type: 'start'; + routeId: string; + args: unknown; + } + | { + type: 'success'; + routeId: string; + args: unknown; + result: unknown; + } + | { + type: 'redirect'; + routeId: string; + args: unknown; + response: Response; + } + | { + type: 'error'; + routeId: string; + args: unknown; + error: unknown; + }; + +export type RouteComponentEvent = + | { + type: 'module-load'; + routeId: string; + routeModule: unknown; + } + | { + type: 'module-load-error'; + routeId?: string; + error: unknown; + } + | { + type: 'render-error'; + routeId: string; + error: unknown; + componentStack?: string; + } + | { + type: 'mount'; + routeId: string; + }; + // only for inhouse use const modifyRoutes = createSyncHook<(routes: RouteObject[]) => RouteObject[]>(); const onBeforeCreateRoutes = createSyncHook<(context: TRuntimeContext) => void>(); +const onRouterCreated = createSyncHook<(event: RouterCreatedEvent) => void>(); +const onRouterStateChange = + createSyncHook<(event: RouterStateChangeEvent) => void>(); +const onRouteLoader = createSyncHook<(event: RouteLoaderEvent) => void>(); +const onRouteComponent = createSyncHook<(event: RouteComponentEvent) => void>(); -export { modifyRoutes, onBeforeCreateRoutes }; +export { + modifyRoutes, + onBeforeCreateRoutes, + onRouterCreated, + onRouterStateChange, + onRouteLoader, + onRouteComponent, +}; export type RouterExtendsHooks = { modifyRoutes: typeof modifyRoutes; onBeforeCreateRoutes: typeof onBeforeCreateRoutes; + onRouterCreated: typeof onRouterCreated; + onRouterStateChange: typeof onRouterStateChange; + onRouteLoader: typeof onRouteLoader; + onRouteComponent: typeof onRouteComponent; }; diff --git a/packages/runtime/plugin-runtime/src/router/runtime/plugin.node.tsx b/packages/runtime/plugin-runtime/src/router/runtime/plugin.node.tsx index 819f2432ac63..cc5a63d54d8d 100644 --- a/packages/runtime/plugin-runtime/src/router/runtime/plugin.node.tsx +++ b/packages/runtime/plugin-runtime/src/router/runtime/plugin.node.tsx @@ -30,6 +30,10 @@ import { type RouterExtendsHooks, modifyRoutes as modifyRoutesHook, onBeforeCreateRoutes as onBeforeCreateRoutesHook, + onRouteComponent as onRouteComponentHook, + onRouteLoader as onRouteLoaderHook, + onRouterCreated as onRouterCreatedHook, + onRouterStateChange as onRouterStateChangeHook, } from './hooks'; import { RSCStaticRouter, @@ -62,6 +66,10 @@ export const routerPlugin = ( registryHooks: { modifyRoutes: modifyRoutesHook, onBeforeCreateRoutes: onBeforeCreateRoutesHook, + onRouterCreated: onRouterCreatedHook, + onRouterStateChange: onRouterStateChangeHook, + onRouteLoader: onRouteLoaderHook, + onRouteComponent: onRouteComponentHook, }, setup: api => { let finalRouteConfig: any = {}; diff --git a/packages/runtime/plugin-runtime/src/router/runtime/plugin.tsx b/packages/runtime/plugin-runtime/src/router/runtime/plugin.tsx index 6f9b7834852b..efb285cb9136 100644 --- a/packages/runtime/plugin-runtime/src/router/runtime/plugin.tsx +++ b/packages/runtime/plugin-runtime/src/router/runtime/plugin.tsx @@ -12,6 +12,7 @@ import { useLocation, useMatches, } from '@modern-js/runtime-utils/router'; +import type { DataRouter } from '@modern-js/runtime-utils/router'; import { normalizePathname } from '@modern-js/runtime-utils/url'; import * as React from 'react'; import { useContext, useEffect, useMemo } from 'react'; @@ -27,6 +28,10 @@ import { type RouterExtendsHooks, modifyRoutes as modifyRoutesHook, onBeforeCreateRoutes as onBeforeCreateRoutesHook, + onRouteComponent as onRouteComponentHook, + onRouteLoader as onRouteLoaderHook, + onRouterCreated as onRouterCreatedHook, + onRouterStateChange as onRouterStateChangeHook, } from './hooks'; import { createClientRouterFromPayload } from './rsc-router'; import type { RouterConfig, Routes } from './types'; @@ -77,6 +82,10 @@ export const routerPlugin = ( registryHooks: { modifyRoutes: modifyRoutesHook, onBeforeCreateRoutes: onBeforeCreateRoutesHook, + onRouterCreated: onRouterCreatedHook, + onRouterStateChange: onRouterStateChangeHook, + onRouteLoader: onRouteLoaderHook, + onRouteComponent: onRouteComponentHook, }, setup: api => { const routesContainer = { @@ -151,6 +160,7 @@ export const routerPlugin = ( let cachedRouter: any = null; const RouterWrapper = (props: any) => { + const runtimeContext = useContext(InternalRuntimeContext); const routerResult = useRouterCreation( { ...props, @@ -175,12 +185,30 @@ export const routerPlugin = ( cachedRouter = routerResult.router; return cachedRouter; }, []); - const { routes } = routerResult; + const { basename: routerBasename, routes } = routerResult; routesContainer.current = routes; beforeCreateRouter = false; + useEffect(() => { + const hooks = api.getHooks(); + hooks.onRouterCreated.call({ + router, + routes, + basename: routerBasename, + context: runtimeContext, + }); + return router.subscribe((state: DataRouter['state']) => { + hooks.onRouterStateChange.call({ + router, + routes, + state, + context: runtimeContext, + }); + }); + }, [router, routes, routerBasename, runtimeContext]); + // To match the node tree about https://github.com/web-infra-dev/modern.js/blob/v2.59.0/packages/runtime/plugin-runtime/src/router/runtime/plugin.node.tsx#L150-L168 // According to react [useId generation algorithm](https://github.com/facebook/react/pull/22644), `useId` will generate id with the react node react struct. // To void hydration failed, we must guarantee that the node tree when browser hydrate must have same struct with node tree when ssr render. @@ -292,6 +320,7 @@ function useRouterCreation(props: any, options: UseRouterCreationOptions) { return { router, + basename: _basename, routes: router.routes || [], }; } catch (e) { @@ -325,6 +354,7 @@ function useRouterCreation(props: any, options: UseRouterCreationOptions) { return { router, + basename: _basename, routes: modifiedRoutes, }; }, [finalRouteConfig, props, _basename, hydrationData, getBlockNavState]); diff --git a/packages/runtime/plugin-runtime/src/router/runtime/routerHelper.ts b/packages/runtime/plugin-runtime/src/router/runtime/routerHelper.ts index 5c71df0e3e5e..e2e054e371d9 100644 --- a/packages/runtime/plugin-runtime/src/router/runtime/routerHelper.ts +++ b/packages/runtime/plugin-runtime/src/router/runtime/routerHelper.ts @@ -1,6 +1,39 @@ import type Module from 'module'; import type { ShouldRevalidateFunction } from '@modern-js/runtime-utils/router'; import { ROUTE_MODULES } from '@modern-js/utils/universal/constants'; +import * as React from 'react'; +import { getGlobalInternalRuntimeContext } from '../../core/context'; +import type { + RouteComponentEvent, + RouteLoaderEvent, + RouterExtendsHooks, +} from './hooks'; + +const getRouterHooks = () => { + try { + return getGlobalInternalRuntimeContext() + ?.hooks as Partial; + } catch { + return undefined; + } +}; + +const emitRouteLoader = (event: RouteLoaderEvent) => { + getRouterHooks()?.onRouteLoader?.call(event); +}; + +const emitRouteComponent = (event: RouteComponentEvent) => { + getRouterHooks()?.onRouteComponent?.call(event); +}; + +const isRedirectResponse = (value: unknown): value is Response => { + return ( + typeof Response !== 'undefined' && + value instanceof Response && + value.status >= 300 && + value.status < 400 + ); +}; export const createShouldRevalidate = ( routeId: string, @@ -19,10 +52,162 @@ export const handleRouteModule = (routeModule: Module, routeId: string) => { if (typeof document !== 'undefined') { (window as any)[ROUTE_MODULES][routeId] = routeModule; } + emitRouteComponent({ + type: 'module-load', + routeId, + routeModule, + }); return routeModule; }; -export const handleRouteModuleError = (error: Error) => { +export const handleRouteModuleError = (error: Error, routeId?: string) => { + emitRouteComponent({ + type: 'module-load-error', + routeId, + error, + }); console.error(error); return null; }; + +export const createRouteLoader = < + Args, + Result, + T extends (args: Args) => Result, +>( + routeId: string, + loader: T, +): T => { + const wrappedLoader = async (args: Args): Promise> => { + emitRouteLoader({ + type: 'start', + routeId, + args, + }); + try { + const result = await loader(args); + emitRouteLoader({ + type: 'success', + routeId, + args, + result, + }); + return result; + } catch (error) { + if (isRedirectResponse(error)) { + emitRouteLoader({ + type: 'redirect', + routeId, + args, + response: error, + }); + } else { + emitRouteLoader({ + type: 'error', + routeId, + args, + error, + }); + } + throw error; + } + }; + + return wrappedLoader as T; +}; + +interface RouteComponentErrorReporterProps { + routeId: string; + children?: React.ReactNode; +} + +const reportedRouteComponentRenderErrors = new Set(); + +const getRouteComponentRenderErrorKey = (routeId: string, error: unknown) => { + if (error instanceof Error) { + return `${routeId}:${error.name}:${error.message}`; + } + + return `${routeId}:${String(error)}`; +}; + +const shouldReportRouteComponentRenderError = ( + routeId: string, + error: unknown, +) => { + const key = getRouteComponentRenderErrorKey(routeId, error); + if (reportedRouteComponentRenderErrors.has(key)) { + return false; + } + + reportedRouteComponentRenderErrors.add(key); + setTimeout(() => { + reportedRouteComponentRenderErrors.delete(key); + }, 1000); + return true; +}; + +class RouteComponentErrorReporter extends React.Component { + componentDidCatch(error: unknown, errorInfo: React.ErrorInfo) { + if (shouldReportRouteComponentRenderError(this.props.routeId, error)) { + emitRouteComponent({ + type: 'render-error', + routeId: this.props.routeId, + error, + ...(errorInfo.componentStack + ? { componentStack: errorInfo.componentStack } + : {}), + }); + } + throw error; + } + + render() { + return this.props.children ?? null; + } +} + +interface RouteComponentMountReporterProps { + routeId: string; +} + +const RouteComponentMountReporter = ({ + routeId, +}: RouteComponentMountReporterProps) => { + React.useEffect(() => { + emitRouteComponent({ + type: 'mount', + routeId, + }); + }, [routeId]); + + return null; +}; + +export const createRouteComponent = < + Props extends {}, + T extends React.ComponentType, +>( + routeId: string, + Component: T, +): T => { + const RouteComponent = (props: Props) => { + return React.createElement( + RouteComponentErrorReporter, + { routeId }, + React.createElement( + React.Fragment, + null, + React.createElement(Component, props), + React.createElement(RouteComponentMountReporter, { routeId }), + ), + ); + }; + + RouteComponent.displayName = + Component.displayName || Component.name + ? `ModernRouteComponent(${Component.displayName || Component.name})` + : `ModernRouteComponent(${routeId})`; + + return RouteComponent as unknown as T; +}; diff --git a/packages/runtime/plugin-runtime/tests/router/__snapshots__/templates.test.ts.snap b/packages/runtime/plugin-runtime/tests/router/__snapshots__/templates.test.ts.snap index 6f2cf44bb40b..46c55dd5c6f2 100644 --- a/packages/runtime/plugin-runtime/tests/router/__snapshots__/templates.test.ts.snap +++ b/packages/runtime/plugin-runtime/tests/router/__snapshots__/templates.test.ts.snap @@ -8,7 +8,7 @@ exports[`fileSystemRoutes > generate code 1`] = ` - import { createShouldRevalidate, handleRouteModule, handleRouteModuleError} from '@modern-js/runtime/routerHelper'; + import { createRouteComponent, createRouteLoader, createShouldRevalidate, handleRouteModule, handleRouteModuleError} from '@modern-js/runtime/routerHelper'; import loading_0 from '@_modern_js_src/routes/loading.tsx'; @@ -39,7 +39,7 @@ import loader_1 from "@_modern_js_src/routes/layout.loader.ts"; "loading": loading_0, "id": "user/layout", "type": "nested", - "loader": loader_1, + "loader": createRouteLoader("user/layout", loader_1), "children": [ { "path": ":id", @@ -50,23 +50,23 @@ import loader_1 from "@_modern_js_src/routes/layout.loader.ts"; "_component": "@_modern_js_src/routes/user/[id]/page.tsx", "index": true, "id": "user/[id]/page", - "loader": loader_0, + "loader": createRouteLoader("user/[id]/page", loader_0), "type": "nested", - "lazyImport": () => import(/* webpackChunkName: "user/[id]/page" */ '@_modern_js_src/routes/user/[id]/page.tsx').then(routeModule => handleRouteModule(routeModule, "user/[id]/page")).catch(handleRouteModuleError), - "component": lazy(() => import(/* webpackChunkName: "user/[id]/page" */ '@_modern_js_src/routes/user/[id]/page.tsx').then(routeModule => handleRouteModule(routeModule, "user/[id]/page")).catch(handleRouteModuleError)), + "lazyImport": () => import(/* webpackChunkName: "user/[id]/page" */ '@_modern_js_src/routes/user/[id]/page.tsx').then(routeModule => handleRouteModule(routeModule, "user/[id]/page")).catch(error => handleRouteModuleError(error, "user/[id]/page")), + "component": createRouteComponent("user/[id]/page", lazy(() => import(/* webpackChunkName: "user/[id]/page" */ '@_modern_js_src/routes/user/[id]/page.tsx').then(routeModule => handleRouteModule(routeModule, "user/[id]/page")).catch(error => handleRouteModuleError(error, "user/[id]/page")))), "shouldRevalidate": createShouldRevalidate("user/[id]/page") } ], "lazyImport": null } ], - "lazyImport": () => import(/* webpackChunkName: "user/layout" */ '@_modern_js_src/routes/user/layout.tsx').then(routeModule => handleRouteModule(routeModule, "user/layout")).catch(handleRouteModuleError), - "component": lazy(() => import(/* webpackChunkName: "user/layout" */ '@_modern_js_src/routes/user/layout.tsx').then(routeModule => handleRouteModule(routeModule, "user/layout")).catch(handleRouteModuleError)), + "lazyImport": () => import(/* webpackChunkName: "user/layout" */ '@_modern_js_src/routes/user/layout.tsx').then(routeModule => handleRouteModule(routeModule, "user/layout")).catch(error => handleRouteModuleError(error, "user/layout")), + "component": createRouteComponent("user/layout", lazy(() => import(/* webpackChunkName: "user/layout" */ '@_modern_js_src/routes/user/layout.tsx').then(routeModule => handleRouteModule(routeModule, "user/layout")).catch(error => handleRouteModuleError(error, "user/layout")))), "shouldRevalidate": createShouldRevalidate("user/layout") } ], - "lazyImport": () => import(/* webpackChunkName: "layout" */ '@_modern_js_src/routes/layout.tsx').then(routeModule => handleRouteModule(routeModule, "layout")).catch(handleRouteModuleError), - "component": lazy(() => import(/* webpackChunkName: "layout" */ '@_modern_js_src/routes/layout.tsx').then(routeModule => handleRouteModule(routeModule, "layout")).catch(handleRouteModuleError)) + "lazyImport": () => import(/* webpackChunkName: "layout" */ '@_modern_js_src/routes/layout.tsx').then(routeModule => handleRouteModule(routeModule, "layout")).catch(error => handleRouteModuleError(error, "layout")), + "component": createRouteComponent("layout", lazy(() => import(/* webpackChunkName: "layout" */ '@_modern_js_src/routes/layout.tsx').then(routeModule => handleRouteModule(routeModule, "layout")).catch(error => handleRouteModuleError(error, "layout")))) }, ]; @@ -80,7 +80,7 @@ exports[`fileSystemRoutes > generate code for legacy router 1`] = ` - import { createShouldRevalidate, handleRouteModule, handleRouteModuleError} from '@modern-js/runtime/routerHelper'; + import { createRouteComponent, createRouteLoader, createShouldRevalidate, handleRouteModule, handleRouteModuleError} from '@modern-js/runtime/routerHelper'; diff --git a/packages/runtime/plugin-runtime/tests/router/lifecycleHooks.test.tsx b/packages/runtime/plugin-runtime/tests/router/lifecycleHooks.test.tsx new file mode 100644 index 000000000000..90e01d840eb0 --- /dev/null +++ b/packages/runtime/plugin-runtime/tests/router/lifecycleHooks.test.tsx @@ -0,0 +1,213 @@ +import { createSyncHook } from '@modern-js/plugin'; +import { ROUTE_MODULES } from '@modern-js/utils/universal/constants'; +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { setGlobalInternalRuntimeContext } from '../../src/core/context'; +import type { + RouteComponentEvent, + RouteLoaderEvent, +} from '../../src/router/runtime/hooks'; +import { + createRouteComponent, + createRouteLoader, + handleRouteModule, + handleRouteModuleError, +} from '../../src/router/runtime/routerHelper'; + +const setupRouterHooks = () => { + const loaderEvents: RouteLoaderEvent[] = []; + const componentEvents: RouteComponentEvent[] = []; + const onRouteLoader = createSyncHook<(event: RouteLoaderEvent) => void>(); + const onRouteComponent = + createSyncHook<(event: RouteComponentEvent) => void>(); + + onRouteLoader.tap(event => { + loaderEvents.push(event); + }); + onRouteComponent.tap(event => { + componentEvents.push(event); + }); + + setGlobalInternalRuntimeContext({ + hooks: { + onRouteLoader, + onRouteComponent, + }, + } as any); + + return { + componentEvents, + loaderEvents, + }; +}; + +class TestErrorBoundary extends React.Component< + { children: React.ReactNode }, + { hasError: boolean } +> { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + render() { + return this.state.hasError ? null : this.props.children; + } +} + +describe('router lifecycle hooks', () => { + afterEach(() => { + setGlobalInternalRuntimeContext({ + hooks: {}, + } as any); + }); + + test('emits route loader start and success events', async () => { + const { loaderEvents } = setupRouterHooks(); + const loader = createRouteLoader('routes/user', async args => { + return { + ok: true, + args, + }; + }); + const args = { request: new Request('https://modernjs.dev/user') }; + + await expect(loader(args)).resolves.toEqual({ + ok: true, + args, + }); + + expect(loaderEvents.map(event => event.type)).toEqual(['start', 'success']); + expect(loaderEvents[0]).toMatchObject({ + routeId: 'routes/user', + args, + }); + expect(loaderEvents[1]).toMatchObject({ + routeId: 'routes/user', + result: { + ok: true, + args, + }, + }); + }); + + test('emits route loader redirect events', async () => { + const { loaderEvents } = setupRouterHooks(); + const response = new Response(null, { + status: 302, + }); + const loader = createRouteLoader('routes/login', async () => { + throw response; + }); + + await expect( + loader({ request: new Request('https://modernjs.dev/login') }), + ).rejects.toBe(response); + + expect(loaderEvents.map(event => event.type)).toEqual([ + 'start', + 'redirect', + ]); + expect(loaderEvents[1]).toMatchObject({ + routeId: 'routes/login', + response, + }); + }); + + test('emits route loader error events', async () => { + const { loaderEvents } = setupRouterHooks(); + const error = new Error('loader failed'); + const loader = createRouteLoader('routes/error', async () => { + throw error; + }); + + await expect( + loader({ request: new Request('https://modernjs.dev/error') }), + ).rejects.toBe(error); + + expect(loaderEvents.map(event => event.type)).toEqual(['start', 'error']); + expect(loaderEvents[1]).toMatchObject({ + routeId: 'routes/error', + error, + }); + }); + + test('emits route component mount events', async () => { + const { componentEvents } = setupRouterHooks(); + const Component = createRouteComponent('routes/home', () => { + return
home
; + }); + + const { unmount } = render(); + + await waitFor(() => { + expect(componentEvents).toEqual([ + { + type: 'mount', + routeId: 'routes/home', + }, + ]); + }); + unmount(); + }); + + test('emits route component render error events', async () => { + const { componentEvents } = setupRouterHooks(); + const error = new Error('render failed'); + const Component = createRouteComponent('routes/home', () => { + throw error; + }); + const consoleError = rstest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + render( + + + , + ); + + await waitFor(() => { + expect(componentEvents).toHaveLength(1); + expect(componentEvents[0]).toMatchObject({ + type: 'render-error', + routeId: 'routes/home', + error, + componentStack: expect.any(String), + }); + }); + consoleError.mockRestore(); + }); + + test('emits route module load and load error events', () => { + const { componentEvents } = setupRouterHooks(); + const routeModule = { + default: () => null, + }; + const error = new Error('module failed'); + const consoleError = rstest + .spyOn(console, 'error') + .mockImplementation(() => {}); + (window as any)[ROUTE_MODULES] = {}; + + expect(handleRouteModule(routeModule as any, 'routes/lazy')).toBe( + routeModule, + ); + expect(handleRouteModuleError(error, 'routes/lazy-error')).toBe(null); + + expect(componentEvents).toEqual([ + { + type: 'module-load', + routeId: 'routes/lazy', + routeModule, + }, + { + type: 'module-load-error', + routeId: 'routes/lazy-error', + error, + }, + ]); + consoleError.mockRestore(); + }); +}); diff --git a/packages/toolkit/plugin/src/runtime/api.ts b/packages/toolkit/plugin/src/runtime/api.ts index 9f29d0947784..a0e409b20f51 100644 --- a/packages/toolkit/plugin/src/runtime/api.ts +++ b/packages/toolkit/plugin/src/runtime/api.ts @@ -87,6 +87,7 @@ export function initPluginAPI({ getRuntimeConfig, config: hooks.config.tap, onBeforeRender: hooks.onBeforeRender.tap, + onHydration: hooks.onHydration.tap, wrapRoot: hooks.wrapRoot.tap, pickContext: hooks.pickContext.tap, extendStringSSRCollectors: hooks.extendStringSSRCollectors.tap, diff --git a/packages/toolkit/plugin/src/runtime/hooks.ts b/packages/toolkit/plugin/src/runtime/hooks.ts index 025c4a7d0f70..42bbedf24113 100644 --- a/packages/toolkit/plugin/src/runtime/hooks.ts +++ b/packages/toolkit/plugin/src/runtime/hooks.ts @@ -9,6 +9,7 @@ import type { ExtendStringSSRCollectorsFn, Hooks, OnBeforeRenderFn, + OnHydrationFn, PickContextFn, StringSSRCollectorsInfo, WrapRootFn, @@ -21,6 +22,7 @@ export function initHooks(): Hooks< return { onBeforeRender: createAsyncInterruptHook>(), + onHydration: createSyncHook>(), wrapRoot: createSyncHook(), pickContext: createSyncHook>(), config: createCollectSyncHook>(), diff --git a/packages/toolkit/plugin/src/types/runtime/api.ts b/packages/toolkit/plugin/src/types/runtime/api.ts index d20972ae7e92..680c109bcf28 100644 --- a/packages/toolkit/plugin/src/types/runtime/api.ts +++ b/packages/toolkit/plugin/src/types/runtime/api.ts @@ -7,6 +7,7 @@ import type { ExtendStreamSSRFn, ExtendStringSSRCollectorsFn, OnBeforeRenderFn, + OnHydrationFn, PickContextFn, StringSSRCollectorsInfo, WrapRootFn, @@ -22,6 +23,7 @@ export type RuntimePluginAPI = Readonly< >; getRuntimeConfig: () => Readonly; onBeforeRender: PluginHookTap>; + onHydration: PluginHookTap>; wrapRoot: PluginHookTap; pickContext: PluginHookTap>; config: PluginHookTap>; diff --git a/packages/toolkit/plugin/src/types/runtime/hooks.ts b/packages/toolkit/plugin/src/types/runtime/hooks.ts index 3a9f866769d9..39e312bd73fe 100644 --- a/packages/toolkit/plugin/src/types/runtime/hooks.ts +++ b/packages/toolkit/plugin/src/types/runtime/hooks.ts @@ -17,6 +17,21 @@ export type OnBeforeRenderFn = ( interrupt: (info: any) => any, ) => Promise | any; +export type HydrationLifecycleEvent = { + type: 'start' | 'success' | 'fallback' | 'error' | 'recoverable-error'; + context: RuntimeContext; + renderLevel: unknown; + renderMode: string; + reason?: string; + root?: unknown; + error?: unknown; + errorInfo?: unknown; +}; + +export type OnHydrationFn = ( + event: HydrationLifecycleEvent, +) => void; + export type ExtendStringSSRCollectorsFn = ( context: RuntimeContext, ) => Collector; @@ -52,6 +67,7 @@ export type ConfigFn = () => RuntimeConfig; export type Hooks = { onBeforeRender: AsyncInterruptHook>; + onHydration: SyncHook>; wrapRoot: SyncHook; pickContext: SyncHook>; config: CollectSyncHook>; diff --git a/packages/toolkit/plugin/tests/runtimeHooks.test.ts b/packages/toolkit/plugin/tests/runtimeHooks.test.ts new file mode 100644 index 000000000000..b6aa1f01c9a7 --- /dev/null +++ b/packages/toolkit/plugin/tests/runtimeHooks.test.ts @@ -0,0 +1,31 @@ +import { initHooks } from '../src/runtime/hooks'; + +describe('runtime hooks', () => { + test('supports hydration lifecycle hook', () => { + const hooks = initHooks(); + const events: unknown[] = []; + const context = { + isBrowser: true, + }; + + hooks.onHydration.tap(event => { + events.push(event); + }); + + hooks.onHydration.call({ + type: 'start', + context, + renderLevel: 1, + renderMode: 'string', + }); + + expect(events).toEqual([ + { + type: 'start', + context, + renderLevel: 1, + renderMode: 'string', + }, + ]); + }); +});