mirror of https://github.com/alibaba/ice.git
refactor: hydrate document (#74)
* feat: update page config after link * Rename Document.tsx to document.tsx * chore: add comment * test: routing * fix: test utils of puppeteer page * test: update pageconfig after route * chore: add origin source * fix: meta tag * fix: add s * feat: reuse the links already loaded * refactor: hydrate document * refactor: pass data by app context * refactor: get assets in document * refactor: pass app as children * refactor: use app context * revert: document * refactor: pass document by app context * Rename document.tsx to Document.tsx * fix: hydrate error * chore: add comment * feat: toggle for ssr * refactor: entry options * refactor: merge simple functions * feat: downgrade to csr * refactor: ssr entry * fix: file name * fix: remove deadcode * Rename generateHtml.ts to generateHTML.ts * fix: remove deadcode * fix: remove deadcode * refactor: remove block scripts * feat: pass ssr flag to csr * fix: remove ssg flag * fix: spelling * refactor: pass ssr flag to hydrate * chore: add todo for runtime * fix: typo * chore: add todo for load links * chore: send html for send html * refactor: remove config for custom rootId * feat: load page assets * feat: ssr config * refactor: disable csr hydrate warning * chore: add issue link Co-authored-by: ClarkXia <xiawenwu41@gmail.com>
This commit is contained in:
parent
b201704d5d
commit
525142f9d4
|
|
@ -2,7 +2,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Meta, Title, Links, Main, Scripts } from 'ice';
|
import { Meta, Title, Links, Main, Scripts } from 'ice';
|
||||||
|
|
||||||
function Document() {
|
function Document(props) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -14,7 +14,9 @@ function Document() {
|
||||||
<Links />
|
<Links />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Main />
|
<Main>
|
||||||
|
{props.children}
|
||||||
|
</Main>
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,32 @@ import * as React from 'react';
|
||||||
import { Link } from 'ice';
|
import { Link } from 'ice';
|
||||||
|
|
||||||
export default function About() {
|
export default function About() {
|
||||||
return <><h2>About Page</h2><Link to="/">home</Link></>;
|
return (
|
||||||
|
<>
|
||||||
|
<h2>About Page</h2>
|
||||||
|
<Link to="/">home</Link>
|
||||||
|
<span className="mark">new</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPageConfig() {
|
export function getPageConfig() {
|
||||||
return {
|
return {
|
||||||
// auth: ['guest'],
|
title: 'About',
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: 'theme-color',
|
||||||
|
content: '#eee',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
links: [{
|
||||||
|
href: 'https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css',
|
||||||
|
rel: 'stylesheet',
|
||||||
|
}],
|
||||||
|
scripts: [{
|
||||||
|
src: 'https://cdn.jsdelivr.net/npm/lodash@2.4.1/dist/lodash.min.js',
|
||||||
|
}],
|
||||||
|
auth: ['admin'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,17 @@ export default function Home(props) {
|
||||||
|
|
||||||
export function getPageConfig(): PageConfig {
|
export function getPageConfig(): PageConfig {
|
||||||
return {
|
return {
|
||||||
// scripts: [
|
title: 'Home',
|
||||||
// { src: 'https://g.alicdn.com/alilog/mlog/aplus_v2.js', block: true },
|
meta: [
|
||||||
// ],
|
{
|
||||||
|
name: 'theme-color',
|
||||||
|
content: '#000',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'title-color',
|
||||||
|
content: '#f00',
|
||||||
|
},
|
||||||
|
],
|
||||||
auth: ['admin'],
|
auth: ['admin'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,32 +27,29 @@ export default class AssetsManifestPlugin {
|
||||||
}
|
}
|
||||||
|
|
||||||
public createAssets(compilation: Compilation) {
|
public createAssets(compilation: Compilation) {
|
||||||
const bundles = {};
|
const entries = {};
|
||||||
|
const pages = {};
|
||||||
|
|
||||||
const entrypoints = compilation.entrypoints.values();
|
const entrypoints = compilation.entrypoints.values();
|
||||||
|
|
||||||
for (const entrypoint of entrypoints) {
|
for (const entrypoint of entrypoints) {
|
||||||
const entryName = entrypoint.name;
|
const entryName = entrypoint.name;
|
||||||
const mainFiles = getEntrypointFiles(entrypoint);
|
const mainFiles = getEntrypointFiles(entrypoint);
|
||||||
bundles[entryName] = {
|
|
||||||
isEntry: true,
|
entries[entryName] = mainFiles;
|
||||||
files: mainFiles,
|
|
||||||
};
|
|
||||||
|
|
||||||
const chunks = entrypoint?.getChildren();
|
const chunks = entrypoint?.getChildren();
|
||||||
chunks.forEach((chunk: any) => {
|
chunks.forEach((chunk: any) => {
|
||||||
const chunkName = chunk.name;
|
const chunkName = chunk.name;
|
||||||
const chunkFiles = chunk.getFiles();
|
const chunkFiles = chunk.getFiles();
|
||||||
bundles[chunkName] = {
|
pages[chunkName] = chunkFiles;
|
||||||
isEntry: false,
|
|
||||||
files: chunkFiles,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
publicPath: compilation.outputOptions?.publicPath,
|
publicPath: compilation.outputOptions?.publicPath,
|
||||||
bundles,
|
entries,
|
||||||
|
pages,
|
||||||
};
|
};
|
||||||
|
|
||||||
const manifestFileName = resolve(this.outputDir, this.fileName);
|
const manifestFileName = resolve(this.outputDir, this.fileName);
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,14 @@ const userConfig = [
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'ssg',
|
||||||
|
validation: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ssr',
|
||||||
|
validation: 'boolean',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'webpack',
|
name: 'webpack',
|
||||||
validation: 'function',
|
validation: 'function',
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { Plugin } from '@ice/types';
|
import type { Plugin } from '@ice/types';
|
||||||
import generateHTML from './ssr/generateHtml.js';
|
import generateHTML from './ssr/generateHTML.js';
|
||||||
import { setupRenderServer } from './ssr/serverRender.js';
|
import { setupRenderServer } from './ssr/serverRender.js';
|
||||||
|
|
||||||
const webPlugin: Plugin = ({ registerTask, context, onHook }) => {
|
const webPlugin: Plugin = ({ registerTask, context, onHook }) => {
|
||||||
const { command, rootDir } = context;
|
const { command, rootDir, userConfig } = context;
|
||||||
|
const { ssg = true, ssr = true } = userConfig;
|
||||||
const outputDir = path.join(rootDir, 'build');
|
const outputDir = path.join(rootDir, 'build');
|
||||||
const routeManifest = path.join(rootDir, '.ice/route-manifest.json');
|
const routeManifest = path.join(rootDir, '.ice/route-manifest.json');
|
||||||
const serverEntry = path.join(outputDir, 'server/entry.mjs');
|
const serverEntry = path.join(outputDir, 'server/entry.mjs');
|
||||||
|
|
@ -28,6 +29,8 @@ const webPlugin: Plugin = ({ registerTask, context, onHook }) => {
|
||||||
outDir: outputDir,
|
outDir: outputDir,
|
||||||
entry: serverEntry,
|
entry: serverEntry,
|
||||||
routeManifest,
|
routeManifest,
|
||||||
|
ssg,
|
||||||
|
ssr,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const mode = command === 'start' ? 'development' : 'production';
|
const mode = command === 'start' ? 'development' : 'production';
|
||||||
|
|
@ -47,6 +50,8 @@ const webPlugin: Plugin = ({ registerTask, context, onHook }) => {
|
||||||
middleware: setupRenderServer({
|
middleware: setupRenderServer({
|
||||||
serverCompiler,
|
serverCompiler,
|
||||||
routeManifest,
|
routeManifest,
|
||||||
|
ssg,
|
||||||
|
ssr,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ interface Options {
|
||||||
entry: string;
|
entry: string;
|
||||||
routeManifest: string;
|
routeManifest: string;
|
||||||
outDir: string;
|
outDir: string;
|
||||||
|
ssg: boolean;
|
||||||
|
ssr: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function generateHTML(options: Options) {
|
export default async function generateHTML(options: Options) {
|
||||||
|
|
@ -13,6 +15,8 @@ export default async function generateHTML(options: Options) {
|
||||||
entry,
|
entry,
|
||||||
routeManifest,
|
routeManifest,
|
||||||
outDir,
|
outDir,
|
||||||
|
ssg,
|
||||||
|
ssr,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const serverEntry = await import(entry);
|
const serverEntry = await import(entry);
|
||||||
|
|
@ -21,15 +25,22 @@ export default async function generateHTML(options: Options) {
|
||||||
|
|
||||||
for (let i = 0, n = paths.length; i < n; i++) {
|
for (let i = 0, n = paths.length; i < n; i++) {
|
||||||
const routePath = paths[i];
|
const routePath = paths[i];
|
||||||
const htmlContent = await serverEntry.render({
|
const requestContext = {
|
||||||
req: {
|
req: {
|
||||||
url: routePath,
|
url: routePath,
|
||||||
path: routePath,
|
path: routePath,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
|
let html;
|
||||||
|
if (ssg || ssr) {
|
||||||
|
html = await serverEntry.render(requestContext);
|
||||||
|
} else {
|
||||||
|
html = await serverEntry.renderDocument(requestContext);
|
||||||
|
}
|
||||||
|
|
||||||
const fileName = routePath === '/' ? 'index.html' : `${routePath}.html`;
|
const fileName = routePath === '/' ? 'index.html' : `${routePath}.html`;
|
||||||
fs.writeFileSync(path.join(outDir, fileName), htmlContent);
|
fs.writeFileSync(path.join(outDir, fileName), html);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,4 +61,4 @@ function getPaths(routes: RouteItem[]): string[] {
|
||||||
});
|
});
|
||||||
|
|
||||||
return pathList;
|
return pathList;
|
||||||
}
|
}
|
||||||
|
|
@ -5,12 +5,16 @@ import type { Request, Response } from 'express';
|
||||||
interface Options {
|
interface Options {
|
||||||
routeManifest: string;
|
routeManifest: string;
|
||||||
serverCompiler: () => Promise<string>;
|
serverCompiler: () => Promise<string>;
|
||||||
|
ssg: boolean;
|
||||||
|
ssr: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupRenderServer(options: Options) {
|
export function setupRenderServer(options: Options) {
|
||||||
const {
|
const {
|
||||||
routeManifest,
|
routeManifest,
|
||||||
serverCompiler,
|
serverCompiler,
|
||||||
|
ssg,
|
||||||
|
ssr,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
return async (req: Request, res: Response) => {
|
return async (req: Request, res: Response) => {
|
||||||
|
|
@ -23,10 +27,17 @@ export function setupRenderServer(options: Options) {
|
||||||
|
|
||||||
const entry = await serverCompiler();
|
const entry = await serverCompiler();
|
||||||
const serverEntry = await import(entry);
|
const serverEntry = await import(entry);
|
||||||
const html = await serverEntry.render({
|
const requestContext = {
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
let html;
|
||||||
|
if (ssg || ssr) {
|
||||||
|
html = await serverEntry.render(requestContext);
|
||||||
|
} else {
|
||||||
|
html = await serverEntry.renderDocument(requestContext);
|
||||||
|
}
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
res.send(html);
|
res.send(html);
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ export function createEsbuildCompiler(options: Options) {
|
||||||
// ref: https://github.com/evanw/esbuild/blob/master/CHANGELOG.md#01117
|
// ref: https://github.com/evanw/esbuild/blob/master/CHANGELOG.md#01117
|
||||||
// in esm, this in the global should be undefined. Set the following config to avoid warning
|
// in esm, this in the global should be undefined. Set the following config to avoid warning
|
||||||
this: undefined,
|
this: undefined,
|
||||||
|
// TOOD: sync ice runtime env
|
||||||
|
'process.env.ICE_RUNTIME_SERVER': 'true',
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,11 @@ import { runClientApp } from '@ice/runtime';
|
||||||
import appConfig from '@/app';
|
import appConfig from '@/app';
|
||||||
import runtimeModules from './runtimeModules';
|
import runtimeModules from './runtimeModules';
|
||||||
import routes from './routes';
|
import routes from './routes';
|
||||||
|
import Document from '@/document';
|
||||||
|
|
||||||
runClientApp(appConfig, runtimeModules, routes);
|
runClientApp({
|
||||||
|
appConfig,
|
||||||
|
runtimeModules,
|
||||||
|
routes,
|
||||||
|
Document
|
||||||
|
});
|
||||||
|
|
@ -1,18 +1,26 @@
|
||||||
import { runServerApp } from '@ice/runtime';
|
import { runServerApp, renderDocument as runDocumentRender } from '@ice/runtime';
|
||||||
import appConfig from '@/app';
|
import appConfig from '@/app';
|
||||||
import runtimeModules from './runtimeModules';
|
import runtimeModules from './runtimeModules';
|
||||||
import Document from '@/document';
|
import Document from '@/document';
|
||||||
import assetsManifest from './assets-manifest.json';
|
import assetsManifest from './assets-manifest.json';
|
||||||
import routes from './routes';
|
import routes from './routes';
|
||||||
|
|
||||||
export async function render(requestContext, documentOnly = false) {
|
export async function render(requestContext) {
|
||||||
return await runServerApp({
|
return await runServerApp(requestContext, {
|
||||||
requestContext,
|
|
||||||
runtimeModules,
|
|
||||||
appConfig,
|
appConfig,
|
||||||
routes,
|
|
||||||
assetsManifest,
|
assetsManifest,
|
||||||
|
routes,
|
||||||
|
runtimeModules,
|
||||||
|
Document,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderDocument(requestContext) {
|
||||||
|
return await runDocumentRender(requestContext, {
|
||||||
|
appConfig,
|
||||||
|
assetsManifest,
|
||||||
|
routes,
|
||||||
|
runtimeModules,
|
||||||
Document,
|
Document,
|
||||||
documentOnly,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -2,16 +2,15 @@ import React, { useMemo } from 'react';
|
||||||
import type { Action, Location } from 'history';
|
import type { Action, Location } from 'history';
|
||||||
import type { Navigator } from 'react-router-dom';
|
import type { Navigator } from 'react-router-dom';
|
||||||
import AppErrorBoundary from './AppErrorBoundary.js';
|
import AppErrorBoundary from './AppErrorBoundary.js';
|
||||||
import { AppContextProvider } from './AppContext.js';
|
import { useAppContext } from './AppContext.js';
|
||||||
import { createRouteElements } from './routes.js';
|
import { createRouteElements } from './routes.js';
|
||||||
import type { AppContext, PageWrapper, AppRouterProps } from './types';
|
import type { PageWrapper, AppRouterProps } from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
action: Action;
|
action: Action;
|
||||||
location: Location;
|
location: Location;
|
||||||
navigator: Navigator;
|
navigator: Navigator;
|
||||||
static?: boolean;
|
static?: boolean;
|
||||||
appContext: AppContext;
|
|
||||||
AppProvider: React.ComponentType<any>;
|
AppProvider: React.ComponentType<any>;
|
||||||
PageWrappers: PageWrapper<{}>[];
|
PageWrappers: PageWrapper<{}>[];
|
||||||
AppRouter: React.ComponentType<AppRouterProps>;
|
AppRouter: React.ComponentType<AppRouterProps>;
|
||||||
|
|
@ -19,10 +18,16 @@ interface Props {
|
||||||
|
|
||||||
export default function App(props: Props) {
|
export default function App(props: Props) {
|
||||||
const {
|
const {
|
||||||
location, action, navigator, static: staticProp = false,
|
location,
|
||||||
appContext, AppProvider, AppRouter, PageWrappers,
|
action,
|
||||||
|
navigator,
|
||||||
|
static: staticProp = false,
|
||||||
|
AppProvider,
|
||||||
|
AppRouter,
|
||||||
|
PageWrappers,
|
||||||
} = props;
|
} = props;
|
||||||
const { appConfig, routes: originRoutes } = appContext;
|
|
||||||
|
const { appConfig, routes: originRoutes } = useAppContext();
|
||||||
const { strict } = appConfig.app;
|
const { strict } = appConfig.app;
|
||||||
const StrictMode = strict ? React.StrictMode : React.Fragment;
|
const StrictMode = strict ? React.StrictMode : React.Fragment;
|
||||||
|
|
||||||
|
|
@ -52,16 +57,13 @@ export default function App(props: Props) {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<AppErrorBoundary>
|
<AppErrorBoundary>
|
||||||
<AppContextProvider
|
<AppProvider>
|
||||||
value={appContext}
|
{element}
|
||||||
>
|
</AppProvider>
|
||||||
<AppProvider>
|
|
||||||
{element}
|
|
||||||
</AppProvider>
|
|
||||||
</AppContextProvider>
|
|
||||||
</AppErrorBoundary>
|
</AppErrorBoundary>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,24 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import type { PageData, AppData } from './types';
|
import { useAppContext } from './AppContext.js';
|
||||||
|
import { getPageAssets, getEntryAssets } from './assets.js';
|
||||||
interface DocumentContext {
|
|
||||||
html?: string;
|
|
||||||
entryAssets?: string[];
|
|
||||||
pageAssets?: string[];
|
|
||||||
pageData?: PageData;
|
|
||||||
appData?: AppData;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Context = React.createContext<DocumentContext>(null);
|
|
||||||
|
|
||||||
Context.displayName = 'DocumentContext';
|
|
||||||
|
|
||||||
export const useDocumentContext = () => {
|
|
||||||
const value = React.useContext(Context);
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentContextProvider = Context.Provider;
|
|
||||||
|
|
||||||
export function Meta() {
|
export function Meta() {
|
||||||
const { pageData } = useDocumentContext();
|
const { pageData } = useAppContext();
|
||||||
const meta = pageData.pageConfig.meta || [];
|
const meta = pageData.pageConfig.meta || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{meta.map(([name, value]) => <meta key={name} name={name} content={value} />)}
|
{meta.map(item => <meta key={item.name} {...item} />)}
|
||||||
|
<meta
|
||||||
|
name="ice-meta-count"
|
||||||
|
content={meta.length.toString()}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Title() {
|
export function Title() {
|
||||||
const { pageData } = useDocumentContext();
|
const { pageData } = useAppContext();
|
||||||
const title = pageData.pageConfig.title || [];
|
const title = pageData.pageConfig.title || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -41,18 +27,20 @@ export function Title() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Links() {
|
export function Links() {
|
||||||
const { pageAssets, entryAssets, pageData } = useDocumentContext();
|
const { pageData, matches, assetsManifest } = useAppContext();
|
||||||
const customLinks = pageData.pageConfig.links || [];
|
|
||||||
const blockLinks = customLinks.filter((link) => link.block);
|
|
||||||
|
|
||||||
|
const customLinks = pageData.pageConfig.links || [];
|
||||||
|
|
||||||
|
const pageAssets = getPageAssets(matches, assetsManifest);
|
||||||
|
const entryAssets = getEntryAssets(assetsManifest);
|
||||||
const styles = pageAssets.concat(entryAssets).filter(path => path.indexOf('.css') > -1);
|
const styles = pageAssets.concat(entryAssets).filter(path => path.indexOf('.css') > -1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{
|
{
|
||||||
blockLinks.map(link => {
|
customLinks.map(link => {
|
||||||
const { block, ...props } = link;
|
const { block, ...props } = link;
|
||||||
return <script key={link.href} {...props} />;
|
return <link key={link.href} {...props} />;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
{styles.map(style => <link key={style} rel="stylesheet" type="text/css" href={style} />)}
|
{styles.map(style => <link key={style} rel="stylesheet" type="text/css" href={style} />)}
|
||||||
|
|
@ -61,50 +49,57 @@ export function Links() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Scripts() {
|
export function Scripts() {
|
||||||
const { pageData, pageAssets, entryAssets, appData } = useDocumentContext();
|
const { pageData, initialData, matches, assetsManifest, documentOnly } = useAppContext();
|
||||||
const { links: customLinks = [], scripts: customScripts = [] } = pageData.pageConfig;
|
|
||||||
|
const pageAssets = getPageAssets(matches, assetsManifest);
|
||||||
|
const entryAssets = getEntryAssets(assetsManifest);
|
||||||
|
|
||||||
|
const { scripts: customScripts = [] } = pageData.pageConfig;
|
||||||
|
|
||||||
const scripts = pageAssets.concat(entryAssets).filter(path => path.indexOf('.js') > -1);
|
const scripts = pageAssets.concat(entryAssets).filter(path => path.indexOf('.js') > -1);
|
||||||
|
|
||||||
const blockScripts = customScripts.filter(script => script.block);
|
const appContext = {
|
||||||
const deferredScripts = customScripts.filter(script => !script.block);
|
initialData,
|
||||||
const deferredLinks = customLinks.filter(link => !link.block);
|
pageData,
|
||||||
|
assetsManifest,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<script dangerouslySetInnerHTML={{ __html: `window.__ICE_APP_DATA__=${JSON.stringify(appData)}` }} />
|
{/*
|
||||||
<script dangerouslySetInnerHTML={{ __html: `window.__ICE_PAGE_DATA__=${JSON.stringify(pageData)}` }} />
|
* disable hydration warning for csr.
|
||||||
|
* initial app data may not equal csr result.
|
||||||
|
*/}
|
||||||
|
<script suppressHydrationWarning={documentOnly} dangerouslySetInnerHTML={{ __html: `window.__ICE_APP_CONTEXT__=${JSON.stringify(appContext)}` }} />
|
||||||
{
|
{
|
||||||
blockScripts.map(script => {
|
customScripts.map(script => {
|
||||||
const { block, ...props } = script;
|
const { block, ...props } = script;
|
||||||
return <script key={script.src} {...props} />;
|
return <script key={script.src} defer {...props} />;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
{/*
|
||||||
|
* script must be deferred.
|
||||||
|
* if there are other dom after this tag, and hydrate before parsed all dom,
|
||||||
|
* hydrate will fail due to inconsistent dom nodes.
|
||||||
|
*/}
|
||||||
{
|
{
|
||||||
scripts.map(script => {
|
scripts.map(script => {
|
||||||
return <script key={script} src={script} />;
|
return <script key={script} defer src={script} />;
|
||||||
})
|
|
||||||
}
|
|
||||||
{
|
|
||||||
deferredLinks.map(link => {
|
|
||||||
const { block, ...props } = link;
|
|
||||||
return <script key={link.href} {...props} />;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
{
|
|
||||||
deferredScripts.map(script => {
|
|
||||||
const { block, ...props } = script;
|
|
||||||
return <script key={script.src} defer="true" {...props} />;
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Main() {
|
export function Main(props) {
|
||||||
const { html } = useDocumentContext();
|
const { documentOnly } = useAppContext();
|
||||||
|
|
||||||
// TODO: set id from config
|
// disable hydration warning for csr.
|
||||||
// eslint-disable-next-line react/self-closing-comp
|
// document is rendered by hydration.
|
||||||
return <div id="ice-container" dangerouslySetInnerHTML={{ __html: html || '' }}></div>;
|
// initial content form "ice-container" is empty, which will not match csr result.
|
||||||
}
|
return (
|
||||||
|
<div id="ice-container" suppressHydrationWarning={documentOnly} >
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,18 @@
|
||||||
import type { AssetsManifest, RouteMatch } from './types';
|
import type { AssetsManifest, RouteMatch } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* merge assets info for matched page
|
* merge assets info for matched page
|
||||||
* @param matches
|
|
||||||
* @param assetsManifest
|
|
||||||
* @returns
|
|
||||||
*/
|
*/
|
||||||
export function getPageAssets(matches: RouteMatch[], assetsManifest: AssetsManifest): string[] {
|
export function getPageAssets(matches: RouteMatch[], assetsManifest: AssetsManifest): string[] {
|
||||||
const { bundles, publicPath } = assetsManifest;
|
// TODO:publicPath from runtime
|
||||||
|
const { pages, publicPath } = assetsManifest;
|
||||||
|
|
||||||
let result = [];
|
let result = [];
|
||||||
|
|
||||||
matches.forEach(match => {
|
matches.forEach(match => {
|
||||||
const { componentName } = match.route;
|
const { componentName } = match.route;
|
||||||
const assets = bundles[componentName];
|
const assets = pages[componentName];
|
||||||
assets && assets?.files.forEach(filePath => {
|
assets && assets.forEach(filePath => {
|
||||||
result.push(`${publicPath}${filePath}`);
|
result.push(`${publicPath}${filePath}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -22,13 +21,79 @@ import type { AssetsManifest, RouteMatch } from './types';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEntryAssets(assetsManifest: AssetsManifest): string[] {
|
export function getEntryAssets(assetsManifest: AssetsManifest): string[] {
|
||||||
const { bundles, publicPath } = assetsManifest;
|
const { entries, publicPath } = assetsManifest;
|
||||||
const assets = [];
|
let result = [];
|
||||||
Object.keys(bundles).forEach(key => {
|
|
||||||
const { isEntry, files } = bundles[key];
|
Object.values(entries).forEach(assets => {
|
||||||
if (isEntry) {
|
result = result.concat(assets);
|
||||||
assets.push(...files);
|
});
|
||||||
}
|
|
||||||
|
return result.map(filePath => `${publicPath}${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load links for the next page.
|
||||||
|
*/
|
||||||
|
export async function loadStyleLinks(links): Promise<void> {
|
||||||
|
if (!links?.length) return;
|
||||||
|
|
||||||
|
const matchedLinks = links.filter(link => {
|
||||||
|
const existingTags = document.querySelectorAll(`link[href='${link.href}']`);
|
||||||
|
return !existingTags.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(matchedLinks.map((link) => {
|
||||||
|
return preLoadAssets('link', link);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load scripts for the next page.
|
||||||
|
*/
|
||||||
|
export async function loadScripts(scripts): Promise<void> {
|
||||||
|
if (!scripts?.length) return;
|
||||||
|
|
||||||
|
const matchedScript = scripts.filter(script => {
|
||||||
|
const existingTags = document.querySelectorAll(`script[scr='${script.src}']`);
|
||||||
|
return !existingTags.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(matchedScript.map((script) => {
|
||||||
|
return preLoadAssets('script', script);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PreLoad assets by create tag.
|
||||||
|
* Remove this tag after onload.
|
||||||
|
* Actual tag will be created by rendering Document.
|
||||||
|
*/
|
||||||
|
async function preLoadAssets(type, props): Promise<void> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let tag = document.createElement(type);
|
||||||
|
Object.assign(tag, props);
|
||||||
|
|
||||||
|
function removeTag() {
|
||||||
|
// if a navigation interrupts this prefetch React will update the <head>
|
||||||
|
// and remove the link we put in there manually, so we check if it's still
|
||||||
|
// there before trying to remove it
|
||||||
|
if (document.head.contains(tag)) {
|
||||||
|
document.head.removeChild(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tag.onload = () => {
|
||||||
|
// FIXME: Style link reloads on real DOM rendering if caching is disabled.
|
||||||
|
// ISSUE: https://github.com/ice-lab/ice-next/issues/90
|
||||||
|
removeTag();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
tag.onerror = () => {
|
||||||
|
removeTag();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.head.appendChild(tag);
|
||||||
});
|
});
|
||||||
return assets.map(filePath => `${publicPath}${filePath}`);
|
|
||||||
}
|
}
|
||||||
|
|
@ -2,7 +2,6 @@ import type { AppConfig } from './types';
|
||||||
|
|
||||||
const defaultAppConfig: AppConfig = {
|
const defaultAppConfig: AppConfig = {
|
||||||
app: {
|
app: {
|
||||||
rootId: 'ice-container',
|
|
||||||
strict: true,
|
strict: true,
|
||||||
},
|
},
|
||||||
router: {
|
router: {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { createSearchParams } from 'react-router-dom';
|
|
||||||
import type { InitialContext } from './types';
|
|
||||||
|
|
||||||
export default function getInitialContext() {
|
|
||||||
const { href, origin, pathname, search } = window.location;
|
|
||||||
const path = href.replace(origin, '');
|
|
||||||
const query = Object.fromEntries(createSearchParams(search));
|
|
||||||
const initialContext: InitialContext = {
|
|
||||||
pathname,
|
|
||||||
path,
|
|
||||||
query,
|
|
||||||
};
|
|
||||||
|
|
||||||
return initialContext;
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
import Runtime from './runtime.js';
|
import Runtime from './runtime.js';
|
||||||
import App from './App.js';
|
import App from './App.js';
|
||||||
import runClientApp from './runClientApp.js';
|
import runClientApp from './runClientApp.js';
|
||||||
import runServerApp from './runServerApp.js';
|
import runServerApp, { renderDocument } from './runServerApp.js';
|
||||||
import { useAppContext } from './AppContext.js';
|
import { useAppContext } from './AppContext.js';
|
||||||
import {
|
import {
|
||||||
Meta,
|
Meta,
|
||||||
|
|
@ -30,6 +30,7 @@ export {
|
||||||
App,
|
App,
|
||||||
runClientApp,
|
runClientApp,
|
||||||
runServerApp,
|
runServerApp,
|
||||||
|
renderDocument,
|
||||||
useAppContext,
|
useAppContext,
|
||||||
Link,
|
Link,
|
||||||
Outlet,
|
Outlet,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { Location } from 'history';
|
||||||
import type { RouteObject } from 'react-router-dom';
|
import type { RouteObject } from 'react-router-dom';
|
||||||
import { matchRoutes as originMatchRoutes } from 'react-router-dom';
|
import { matchRoutes as originMatchRoutes } from 'react-router-dom';
|
||||||
import PageWrapper from './PageWrapper.js';
|
import PageWrapper from './PageWrapper.js';
|
||||||
import type { RouteItem, RouteModules, PageWrapper as IPageWrapper, RouteMatch, InitialContext } from './types';
|
import type { RouteItem, RouteModules, PageWrapper as IPageWrapper, RouteMatch, InitialContext, PageConfig } from './types';
|
||||||
|
|
||||||
// global route modules cache
|
// global route modules cache
|
||||||
const routeModules: RouteModules = {};
|
const routeModules: RouteModules = {};
|
||||||
|
|
@ -48,7 +48,7 @@ export async function loadPageData(matches: RouteMatch[], initialContext: Initia
|
||||||
|
|
||||||
const { getInitialData, getPageConfig } = routeModule;
|
const { getInitialData, getPageConfig } = routeModule;
|
||||||
let initialData;
|
let initialData;
|
||||||
let pageConfig = {};
|
let pageConfig: PageConfig = {};
|
||||||
|
|
||||||
if (getInitialData) {
|
if (getInitialData) {
|
||||||
initialData = await getInitialData(initialContext);
|
initialData = await getInitialData(initialContext);
|
||||||
|
|
@ -66,6 +66,20 @@ export async function loadPageData(matches: RouteMatch[], initialContext: Initia
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load page config without initial data.
|
||||||
|
*/
|
||||||
|
export function loadPageConfig(matches: RouteMatch[]) {
|
||||||
|
const last = matches.length - 1;
|
||||||
|
const { route } = matches[last];
|
||||||
|
const { id } = route;
|
||||||
|
|
||||||
|
const routeModule = routeModules[id];
|
||||||
|
|
||||||
|
const { getPageConfig } = routeModule;
|
||||||
|
return getPageConfig({ initialData: null });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create elements in routes which will be consumed by react-router-dom
|
* Create elements in routes which will be consumed by react-router-dom
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,41 @@
|
||||||
import React, { useLayoutEffect, useState } from 'react';
|
import React, { useLayoutEffect, useState } from 'react';
|
||||||
import { createHashHistory, createBrowserHistory } from 'history';
|
import { createHashHistory, createBrowserHistory } from 'history';
|
||||||
import type { HashHistory, BrowserHistory } from 'history';
|
import type { HashHistory, BrowserHistory } from 'history';
|
||||||
|
import { createSearchParams } from 'react-router-dom';
|
||||||
import Runtime from './runtime.js';
|
import Runtime from './runtime.js';
|
||||||
import App from './App.js';
|
import App from './App.js';
|
||||||
import type { AppContext, AppConfig, RouteItem, AppRouterProps, PageWrapper, RuntimeModules } from './types';
|
import { AppContextProvider } from './AppContext.js';
|
||||||
|
import type { AppContext, AppConfig, RouteItem, AppRouterProps, PageWrapper, RuntimeModules, InitialContext } from './types';
|
||||||
import { loadRouteModules, loadPageData, matchRoutes } from './routes.js';
|
import { loadRouteModules, loadPageData, matchRoutes } from './routes.js';
|
||||||
import getInitialContext from './getInitialContext.js';
|
import { loadStyleLinks, loadScripts } from './assets.js';
|
||||||
|
|
||||||
|
interface RunClientAppOptions {
|
||||||
|
appConfig: AppConfig;
|
||||||
|
routes: RouteItem[];
|
||||||
|
runtimeModules: RuntimeModules;
|
||||||
|
Document: React.ComponentType<{}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function runClientApp(options: RunClientAppOptions) {
|
||||||
|
const {
|
||||||
|
appConfig,
|
||||||
|
routes,
|
||||||
|
runtimeModules,
|
||||||
|
Document,
|
||||||
|
} = options;
|
||||||
|
|
||||||
export default async function runClientApp(
|
|
||||||
appConfig: AppConfig,
|
|
||||||
runtimeModules: RuntimeModules,
|
|
||||||
routes: RouteItem[],
|
|
||||||
) {
|
|
||||||
const matches = matchRoutes(routes, window.location);
|
const matches = matchRoutes(routes, window.location);
|
||||||
await loadRouteModules(matches.map(({ route: { id, load } }) => ({ id, load })));
|
await loadRouteModules(matches.map(({ route: { id, load } }) => ({ id, load })));
|
||||||
const initialContext = getInitialContext();
|
|
||||||
|
|
||||||
let appData = (window as any).__ICE_APP_DATA__ || {};
|
const appContextFromServer = (window as any).__ICE_APP_CONTEXT__ || {};
|
||||||
let { initialData } = appData;
|
|
||||||
|
let { initialData, pageData, assetsManifest } = appContextFromServer;
|
||||||
|
|
||||||
|
const initialContext = getInitialContext();
|
||||||
if (!initialData && appConfig.app?.getInitialData) {
|
if (!initialData && appConfig.app?.getInitialData) {
|
||||||
initialData = await appConfig.app.getInitialData(initialContext);
|
initialData = await appConfig.app.getInitialData(initialContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
let pageData = (window as any).__ICE_PAGE_DATA__ || {};
|
|
||||||
if (!pageData) {
|
if (!pageData) {
|
||||||
pageData = await loadPageData(matches, initialContext);
|
pageData = await loadPageData(matches, initialContext);
|
||||||
}
|
}
|
||||||
|
|
@ -32,25 +45,28 @@ export default async function runClientApp(
|
||||||
appConfig,
|
appConfig,
|
||||||
initialData,
|
initialData,
|
||||||
initialPageData: pageData,
|
initialPageData: pageData,
|
||||||
|
assetsManifest,
|
||||||
|
matches,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: provide useAppContext for runtime modules
|
||||||
const runtime = new Runtime(appContext);
|
const runtime = new Runtime(appContext);
|
||||||
runtimeModules.forEach(m => {
|
runtimeModules.forEach(m => {
|
||||||
runtime.loadModule(m);
|
runtime.loadModule(m);
|
||||||
});
|
});
|
||||||
|
|
||||||
render(runtime);
|
render(runtime, Document);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function render(runtime: Runtime) {
|
async function render(runtime: Runtime, Document: React.ComponentType<{}>) {
|
||||||
const appContext = runtime.getAppContext();
|
const appContext = runtime.getAppContext();
|
||||||
const { appConfig } = appContext;
|
const { appConfig } = appContext;
|
||||||
const { app: { rootId }, router: { type: routerType } } = appConfig;
|
const { router: { type: routerType } } = appConfig;
|
||||||
const render = runtime.getRender();
|
const render = runtime.getRender();
|
||||||
const AppProvider = runtime.composeAppProvider() || React.Fragment;
|
const AppProvider = runtime.composeAppProvider() || React.Fragment;
|
||||||
const PageWrappers = runtime.getWrapperPageRegistration();
|
const PageWrappers = runtime.getWrapperPageRegistration();
|
||||||
const AppRouter = runtime.getAppRouter();
|
const AppRouter = runtime.getAppRouter();
|
||||||
const appMountNode = document.getElementById(rootId);
|
|
||||||
const history = (routerType === 'hash' ? createHashHistory : createBrowserHistory)({ window });
|
const history = (routerType === 'hash' ? createHashHistory : createBrowserHistory)({ window });
|
||||||
|
|
||||||
render(
|
render(
|
||||||
|
|
@ -60,8 +76,9 @@ async function render(runtime: Runtime) {
|
||||||
AppProvider={AppProvider}
|
AppProvider={AppProvider}
|
||||||
PageWrappers={PageWrappers}
|
PageWrappers={PageWrappers}
|
||||||
AppRouter={AppRouter}
|
AppRouter={AppRouter}
|
||||||
|
Document={Document}
|
||||||
/>,
|
/>,
|
||||||
appMountNode,
|
document,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,16 +88,20 @@ interface BrowserEntryProps {
|
||||||
AppProvider: React.ComponentType<any>;
|
AppProvider: React.ComponentType<any>;
|
||||||
PageWrappers: PageWrapper<{}>[];
|
PageWrappers: PageWrapper<{}>[];
|
||||||
AppRouter: React.ComponentType<AppRouterProps>;
|
AppRouter: React.ComponentType<AppRouterProps>;
|
||||||
|
Document: React.ComponentType<{}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BrowserEntry({ history, appContext, ...rest }: BrowserEntryProps) {
|
function BrowserEntry({ history, appContext, Document, ...rest }: BrowserEntryProps) {
|
||||||
const { routes, initialPageData } = appContext;
|
const { routes, initialPageData, matches: originMatches } = appContext;
|
||||||
|
|
||||||
const [historyState, setHistoryState] = useState({
|
const [historyState, setHistoryState] = useState({
|
||||||
action: history.action,
|
action: history.action,
|
||||||
location: history.location,
|
location: history.location,
|
||||||
pageData: initialPageData,
|
pageData: initialPageData,
|
||||||
|
matches: originMatches,
|
||||||
});
|
});
|
||||||
const { action, location, pageData } = historyState;
|
|
||||||
|
const { action, location, pageData, matches } = historyState;
|
||||||
|
|
||||||
// listen the history change and update the state which including the latest action and location
|
// listen the history change and update the state which including the latest action and location
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
|
@ -90,28 +111,61 @@ function BrowserEntry({ history, appContext, ...rest }: BrowserEntryProps) {
|
||||||
throw new Error(`Routes not found in location ${location}.`);
|
throw new Error(`Routes not found in location ${location}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadRouteModules(matches.map(({ route: { id, load } }) => ({ id, load })))
|
loadNextPage(matches, (pageData) => {
|
||||||
.then(() => {
|
// just re-render once, so add pageData to historyState :(
|
||||||
const initialContext = getInitialContext();
|
setHistoryState({ action, location, pageData, matches });
|
||||||
return loadPageData(matches, initialContext);
|
});
|
||||||
})
|
|
||||||
.then((pageData) => {
|
|
||||||
// just re-render once, so add pageData to historyState :(
|
|
||||||
setHistoryState({ action, location, pageData });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// update app context for the current route.
|
||||||
|
Object.assign(appContext, {
|
||||||
|
matches,
|
||||||
|
pageData,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<App
|
<AppContextProvider value={appContext}>
|
||||||
action={action}
|
<Document>
|
||||||
location={location}
|
<App
|
||||||
navigator={history}
|
action={action}
|
||||||
appContext={{
|
location={location}
|
||||||
...appContext,
|
navigator={history}
|
||||||
pageData,
|
{...rest}
|
||||||
}}
|
/>
|
||||||
{...rest}
|
</Document>
|
||||||
/>
|
</AppContextProvider>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare for the next pages.
|
||||||
|
* Load modules、getPageData and preLoad the custom assets.
|
||||||
|
*/
|
||||||
|
async function loadNextPage(matches, callback) {
|
||||||
|
await loadRouteModules(matches.map(({ route: { id, load } }) => ({ id, load })));
|
||||||
|
|
||||||
|
const initialContext = getInitialContext();
|
||||||
|
const pageData = await loadPageData(matches, initialContext);
|
||||||
|
|
||||||
|
const { pageConfig } = pageData;
|
||||||
|
await Promise.all([
|
||||||
|
loadStyleLinks(pageConfig.links),
|
||||||
|
loadScripts(pageConfig.scripts),
|
||||||
|
]);
|
||||||
|
|
||||||
|
callback(pageData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialContext() {
|
||||||
|
const { href, origin, pathname, search } = window.location;
|
||||||
|
const path = href.replace(origin, '');
|
||||||
|
const query = Object.fromEntries(createSearchParams(search));
|
||||||
|
const initialContext: InitialContext = {
|
||||||
|
pathname,
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
};
|
||||||
|
|
||||||
|
return initialContext;
|
||||||
}
|
}
|
||||||
|
|
@ -5,48 +5,46 @@ import { Action, createPath, parsePath } from 'history';
|
||||||
import { createSearchParams } from 'react-router-dom';
|
import { createSearchParams } from 'react-router-dom';
|
||||||
import Runtime from './runtime.js';
|
import Runtime from './runtime.js';
|
||||||
import App from './App.js';
|
import App from './App.js';
|
||||||
import { DocumentContextProvider } from './Document.js';
|
import { AppContextProvider } from './AppContext.js';
|
||||||
import { loadRouteModules, loadPageData, matchRoutes } from './routes.js';
|
import { loadRouteModules, loadPageData, loadPageConfig, matchRoutes } from './routes.js';
|
||||||
import { getPageAssets, getEntryAssets } from './assets.js';
|
|
||||||
import type { AppContext, InitialContext, RouteItem, ServerContext, AppConfig, RuntimePlugin, CommonJsRuntime, AssetsManifest } from './types';
|
import type { AppContext, InitialContext, RouteItem, ServerContext, AppConfig, RuntimePlugin, CommonJsRuntime, AssetsManifest } from './types';
|
||||||
|
|
||||||
interface RunServerAppOptions {
|
interface RenderOptions {
|
||||||
requestContext: ServerContext;
|
|
||||||
appConfig: AppConfig;
|
appConfig: AppConfig;
|
||||||
|
assetsManifest: AssetsManifest;
|
||||||
routes: RouteItem[];
|
routes: RouteItem[];
|
||||||
documentOnly: boolean;
|
|
||||||
runtimeModules: (RuntimePlugin | CommonJsRuntime)[];
|
runtimeModules: (RuntimePlugin | CommonJsRuntime)[];
|
||||||
Document: React.ComponentType<{}>;
|
Document: React.ComponentType<{}>;
|
||||||
assetsManifest: AssetsManifest;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function runServerApp(options: RunServerAppOptions): Promise<string> {
|
export default async function runServerApp(requestContext: ServerContext, options: RenderOptions): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await renderServerApp(requestContext, options);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('renderServerApp error: ', error);
|
||||||
|
return await renderDocument(requestContext, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render App by SSR.
|
||||||
|
*/
|
||||||
|
export async function renderServerApp(requestContext: ServerContext, options: RenderOptions): Promise<string> {
|
||||||
|
const { req } = requestContext;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
requestContext,
|
assetsManifest,
|
||||||
appConfig,
|
appConfig,
|
||||||
runtimeModules,
|
runtimeModules,
|
||||||
routes,
|
routes,
|
||||||
Document,
|
Document,
|
||||||
documentOnly,
|
|
||||||
assetsManifest,
|
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const { req } = requestContext;
|
const location = getLocation(req.url);
|
||||||
// ref: https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/server.tsx
|
|
||||||
const locationProps = parsePath(url);
|
|
||||||
|
|
||||||
const location: Location = {
|
|
||||||
pathname: locationProps.pathname || '/',
|
|
||||||
search: locationProps.search || '',
|
|
||||||
hash: locationProps.hash || '',
|
|
||||||
state: null,
|
|
||||||
key: 'default',
|
|
||||||
};
|
|
||||||
|
|
||||||
const matches = matchRoutes(routes, location);
|
const matches = matchRoutes(routes, location);
|
||||||
|
|
||||||
// TODO: error handling
|
|
||||||
if (!matches.length) {
|
if (!matches.length) {
|
||||||
|
// TODO: Render 404
|
||||||
throw new Error('No matched page found.');
|
throw new Error('No matched page found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,7 +54,7 @@ export default async function runServerApp(options: RunServerAppOptions): Promis
|
||||||
...requestContext,
|
...requestContext,
|
||||||
pathname: location.pathname,
|
pathname: location.pathname,
|
||||||
query: Object.fromEntries(createSearchParams(location.search)),
|
query: Object.fromEntries(createSearchParams(location.search)),
|
||||||
path: url,
|
path: req.url,
|
||||||
};
|
};
|
||||||
|
|
||||||
let initialData;
|
let initialData;
|
||||||
|
|
@ -67,30 +65,14 @@ export default async function runServerApp(options: RunServerAppOptions): Promis
|
||||||
const pageData = await loadPageData(matches, initialContext);
|
const pageData = await loadPageData(matches, initialContext);
|
||||||
|
|
||||||
const appContext: AppContext = {
|
const appContext: AppContext = {
|
||||||
matches,
|
|
||||||
routes,
|
|
||||||
routeData,
|
|
||||||
appConfig,
|
appConfig,
|
||||||
initialData,
|
|
||||||
pageData,
|
|
||||||
routeModules,
|
|
||||||
assetsManifest,
|
assetsManifest,
|
||||||
};
|
|
||||||
|
|
||||||
let initialData;
|
|
||||||
if (appConfig?.app?.getInitialData) {
|
|
||||||
initialData = await appConfig.app.getInitialData(initialContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
const appContext: AppContext = {
|
|
||||||
matches,
|
|
||||||
routes,
|
|
||||||
appConfig,
|
|
||||||
initialData,
|
initialData,
|
||||||
initialPageData: pageData,
|
initialPageData: pageData,
|
||||||
// pageData and initialPageData are the same when SSR/SSG
|
// pageData and initialPageData are the same when SSR/SSG
|
||||||
pageData,
|
pageData,
|
||||||
assetsManifest,
|
matches,
|
||||||
|
routes,
|
||||||
};
|
};
|
||||||
|
|
||||||
const runtime = new Runtime(appContext);
|
const runtime = new Runtime(appContext);
|
||||||
|
|
@ -98,67 +80,94 @@ export default async function runServerApp(options: RunServerAppOptions): Promis
|
||||||
runtime.loadModule(m);
|
runtime.loadModule(m);
|
||||||
});
|
});
|
||||||
|
|
||||||
const html = render(runtime, location, Document, documentOnly);
|
const staticNavigator = createStaticNavigator();
|
||||||
return html;
|
const AppProvider = runtime.composeAppProvider() || React.Fragment;
|
||||||
|
const PageWrappers = runtime.getWrapperPageRegistration();
|
||||||
|
const AppRouter = runtime.getAppRouter();
|
||||||
|
|
||||||
|
const result = ReactDOMServer.renderToString(
|
||||||
|
<AppContextProvider value={appContext}>
|
||||||
|
<Document>
|
||||||
|
<App
|
||||||
|
action={Action.Pop}
|
||||||
|
location={location}
|
||||||
|
navigator={staticNavigator}
|
||||||
|
static
|
||||||
|
AppProvider={AppProvider}
|
||||||
|
PageWrappers={PageWrappers}
|
||||||
|
AppRouter={AppRouter}
|
||||||
|
/>
|
||||||
|
</Document>
|
||||||
|
</AppContextProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: send html in render function.
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default runServerApp;
|
/**
|
||||||
|
* Render Document for CSR.
|
||||||
|
*/
|
||||||
|
export async function renderDocument(requestContext: ServerContext, options: RenderOptions): Promise<string> {
|
||||||
|
const { req } = requestContext;
|
||||||
|
|
||||||
async function render(
|
const {
|
||||||
Document,
|
routes,
|
||||||
runtime: Runtime,
|
assetsManifest,
|
||||||
location: Location,
|
appConfig,
|
||||||
documentOnly: boolean,
|
Document,
|
||||||
) {
|
} = options;
|
||||||
const appContext = runtime.getAppContext();
|
|
||||||
const { matches, pageData = {}, assetsManifest } = appContext;
|
|
||||||
|
|
||||||
let html = '';
|
const location = getLocation(req.url);
|
||||||
|
const matches = matchRoutes(routes, location);
|
||||||
|
|
||||||
if (!documentOnly) {
|
if (!matches.length) {
|
||||||
const staticNavigator = createStaticNavigator();
|
throw new Error('No matched page found.');
|
||||||
const AppProvider = runtime.composeAppProvider() || React.Fragment;
|
|
||||||
const PageWrappers = runtime.getWrapperPageRegistration();
|
|
||||||
const AppRouter = runtime.getAppRouter();
|
|
||||||
|
|
||||||
html = ReactDOMServer.renderToString(
|
|
||||||
<App
|
|
||||||
action={Action.Pop}
|
|
||||||
location={location}
|
|
||||||
navigator={staticNavigator}
|
|
||||||
static
|
|
||||||
appContext={appContext}
|
|
||||||
AppProvider={AppProvider}
|
|
||||||
PageWrappers={PageWrappers}
|
|
||||||
AppRouter={AppRouter}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageAssets = getPageAssets(matches, assetsManifest);
|
await loadRouteModules(matches.map(({ route: { id, load } }) => ({ id, load })));
|
||||||
const entryAssets = getEntryAssets(assetsManifest);
|
|
||||||
|
|
||||||
const appData = {
|
const pageConfig = loadPageConfig(matches);
|
||||||
initialData,
|
|
||||||
|
const pageData = {
|
||||||
|
pageConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
const documentContext = {
|
const appContext: AppContext = {
|
||||||
appData,
|
assetsManifest,
|
||||||
|
appConfig,
|
||||||
pageData,
|
pageData,
|
||||||
pageAssets,
|
matches,
|
||||||
entryAssets,
|
routes,
|
||||||
html,
|
documentOnly: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = ReactDOMServer.renderToString(
|
const result = ReactDOMServer.renderToString(
|
||||||
<DocumentContextProvider value={documentContext}>
|
<AppContextProvider value={appContext}>
|
||||||
<Document />
|
<Document />
|
||||||
</DocumentContextProvider>,
|
</AppContextProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ref: https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/server.tsx
|
||||||
|
*/
|
||||||
|
function getLocation(url) {
|
||||||
|
const locationProps = parsePath(url);
|
||||||
|
|
||||||
|
const location: Location = {
|
||||||
|
pathname: locationProps.pathname || '/',
|
||||||
|
search: locationProps.search || '',
|
||||||
|
hash: locationProps.hash || '',
|
||||||
|
state: null,
|
||||||
|
key: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
function createStaticNavigator() {
|
function createStaticNavigator() {
|
||||||
return {
|
return {
|
||||||
createHref(to: To) {
|
createHref(to: To) {
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,7 @@ class Runtime {
|
||||||
public getAppContext = () => this.appContext;
|
public getAppContext = () => this.appContext;
|
||||||
|
|
||||||
public getRender = () => {
|
public getRender = () => {
|
||||||
// TODO: set ssr by process env
|
return ReactDOM.hydrate;
|
||||||
const isSSR = true;
|
|
||||||
return isSSR ? ReactDOM.hydrate : this.render;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public getAppRouter = () => this.AppRouter;
|
public getAppRouter = () => this.AppRouter;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import type { usePageContext } from './PageContext';
|
||||||
type VoidFunction = () => void;
|
type VoidFunction = () => void;
|
||||||
type AppLifecycle = 'onShow' | 'onHide' | 'onPageNotFound' | 'onShareAppMessage' | 'onUnhandledRejection' | 'onLaunch' | 'onError' | 'onTabItemClick';
|
type AppLifecycle = 'onShow' | 'onHide' | 'onPageNotFound' | 'onShareAppMessage' | 'onUnhandledRejection' | 'onLaunch' | 'onError' | 'onTabItemClick';
|
||||||
type App = Partial<{
|
type App = Partial<{
|
||||||
rootId?: string;
|
|
||||||
strict?: boolean;
|
strict?: boolean;
|
||||||
addProvider?: ({ children }: { children: ReactNode }) => ReactNode;
|
addProvider?: ({ children }: { children: ReactNode }) => ReactNode;
|
||||||
getInitialData?: (ctx?: InitialContext) => Promise<any>;
|
getInitialData?: (ctx?: InitialContext) => Promise<any>;
|
||||||
|
|
@ -83,11 +82,9 @@ export interface RouteModules {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssetsManifest {
|
export interface AssetsManifest {
|
||||||
publicPath?: string;
|
publicPath: string;
|
||||||
bundles?: Record<string, {
|
entries: string[];
|
||||||
files: string[];
|
pages: string[];
|
||||||
isEntry: boolean;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
export interface AppContext {
|
export interface AppContext {
|
||||||
appConfig: AppConfig;
|
appConfig: AppConfig;
|
||||||
|
|
@ -97,10 +94,7 @@ export interface AppContext {
|
||||||
initialData?: InitialData;
|
initialData?: InitialData;
|
||||||
pageData?: PageData;
|
pageData?: PageData;
|
||||||
initialPageData?: PageData;
|
initialPageData?: PageData;
|
||||||
}
|
documentOnly?: boolean;
|
||||||
|
|
||||||
export interface AppData {
|
|
||||||
initialData?: InitialData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageData {
|
export interface PageData {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,40 @@ describe(`start ${example}`, () => {
|
||||||
expect(await page.$$text('h2')).toStrictEqual(['Home Page']);
|
expect(await page.$$text('h2')).toStrictEqual(['Home Page']);
|
||||||
}, 120000);
|
}, 120000);
|
||||||
|
|
||||||
|
test('should update pageConfig during client routing', async () => {
|
||||||
|
const { devServer, port } = await startFixture(example);
|
||||||
|
const res = await setupStartBrowser({ server: devServer, port });
|
||||||
|
page = res.page;
|
||||||
|
browser = res.browser;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await page.title()
|
||||||
|
).toBe('Home');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await page.$$attr('meta[name="theme-color"]', 'content')
|
||||||
|
).toStrictEqual(['#000']);
|
||||||
|
|
||||||
|
await page.click('a[href="/about"]');
|
||||||
|
await page.waitForNetworkIdle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await page.title()
|
||||||
|
).toBe('About');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await page.$$attr('meta[name="theme-color"]', 'content')
|
||||||
|
).toStrictEqual(['#eee']);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await page.$$eval('link[href*="bootstrap"]', (els) => els.length)
|
||||||
|
).toBe(1);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await page.$$eval('script[src*="lodash"]', (els) => els.length)
|
||||||
|
).toBe(1);
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -104,25 +104,16 @@ export default class Browser {
|
||||||
page.$$eval(selector, (els, trim) => els.map((el) => {
|
page.$$eval(selector, (els, trim) => els.map((el) => {
|
||||||
return trim ? (el.textContent || '').replace(/^\s+|\s+$/g, '') : el.textContent
|
return trim ? (el.textContent || '').replace(/^\s+|\s+$/g, '') : el.textContent
|
||||||
}), trim);
|
}), trim);
|
||||||
page.$attr = (selector, attr) => {
|
|
||||||
return page.$eval(
|
page.$attr = (selector, attr) =>
|
||||||
|
page.$eval(selector, (el, attr) => el.getAttribute(attr as string), attr);
|
||||||
|
|
||||||
|
page.$$attr = (selector, attr) =>
|
||||||
|
page.$$eval(
|
||||||
selector,
|
selector,
|
||||||
(el: Element, ...args: unknown[]) => {
|
(els, attr) => els.map(el => el.getAttribute(attr as string)),
|
||||||
const [] = args;
|
attr,
|
||||||
return el.getAttribute(attr)
|
|
||||||
},
|
|
||||||
attr
|
|
||||||
)
|
|
||||||
};
|
|
||||||
page.$$attr = (selector, attr) =>{
|
|
||||||
return page.$$eval(
|
|
||||||
selector,
|
|
||||||
(els, ...args: unknown[]) => {
|
|
||||||
return els.map(el => el.getAttribute(attr))
|
|
||||||
},
|
|
||||||
attr
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue