Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/openruntime-router-hooks.md
Original file line number Diff line number Diff line change
@@ -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
10 changes: 8 additions & 2 deletions packages/runtime/plugin-runtime/rstest.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand All @@ -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: [
{
Expand Down
162 changes: 143 additions & 19 deletions packages/runtime/plugin-runtime/src/core/browser/hydrate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement | Root>,
ModernHydrate: (
App: React.ReactElement,
callback?: () => void,
options?: ModernHydrateOptions,
) => Promise<HTMLElement | Root>,
reportHydration?: HydrationReporter,
) {
const hydrateContext: TRuntimeContext & { __hydration?: boolean } = {
...context,
Expand All @@ -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<HydrationReporter>[0]) => {
reportHydration?.(event);
};
const reportFallback = async (
reason: string,
render: Promise<HTMLElement | Root>,
) => {
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();
}
Expand All @@ -49,9 +124,28 @@ export function hydrateRoot(
);
return ModernHydrate(
wrapRuntimeContextProvider(<SSRApp />, 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)),
);
}
}

Expand All @@ -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<Root | HTMLElement>(resolve => {
return new Promise<Root | HTMLElement>((resolve, reject) => {
if (isReact18()) {
loadableReady(() => {
// callback: https://github.com/reactwg/react-18/discussions/5
Expand All @@ -71,25 +168,52 @@ export function hydrateRoot(
);
ModernHydrate(
wrapRuntimeContextProvider(<SSRApp />, 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)),
);
}
}
}
21 changes: 15 additions & 6 deletions packages/runtime/plugin-runtime/src/core/browser/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -142,20 +149,22 @@ 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;
}

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;
}
26 changes: 22 additions & 4 deletions packages/runtime/plugin-runtime/src/router/cli/code/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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) &&
Expand All @@ -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
Expand Down Expand Up @@ -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'){
Expand Down
Loading
Loading