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:
水澜 2022-04-11 10:38:04 +08:00 committed by ClarkXia
parent b201704d5d
commit 525142f9d4
24 changed files with 506 additions and 287 deletions

View File

@ -2,7 +2,7 @@
import React from 'react';
import { Meta, Title, Links, Main, Scripts } from 'ice';
function Document() {
function Document(props) {
return (
<html lang="en">
<head>
@ -14,7 +14,9 @@ function Document() {
<Links />
</head>
<body>
<Main />
<Main>
{props.children}
</Main>
<Scripts />
</body>
</html>

View File

@ -2,12 +2,32 @@ import * as React from 'react';
import { Link } from 'ice';
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() {
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'],
};
}

View File

@ -18,9 +18,17 @@ export default function Home(props) {
export function getPageConfig(): PageConfig {
return {
// scripts: [
// { src: 'https://g.alicdn.com/alilog/mlog/aplus_v2.js', block: true },
// ],
title: 'Home',
meta: [
{
name: 'theme-color',
content: '#000',
},
{
name: 'title-color',
content: '#f00',
},
],
auth: ['admin'],
};
}

View File

@ -27,32 +27,29 @@ export default class AssetsManifestPlugin {
}
public createAssets(compilation: Compilation) {
const bundles = {};
const entries = {};
const pages = {};
const entrypoints = compilation.entrypoints.values();
for (const entrypoint of entrypoints) {
const entryName = entrypoint.name;
const mainFiles = getEntrypointFiles(entrypoint);
bundles[entryName] = {
isEntry: true,
files: mainFiles,
};
entries[entryName] = mainFiles;
const chunks = entrypoint?.getChildren();
chunks.forEach((chunk: any) => {
const chunkName = chunk.name;
const chunkFiles = chunk.getFiles();
bundles[chunkName] = {
isEntry: false,
files: chunkFiles,
};
pages[chunkName] = chunkFiles;
});
}
const manifest = {
publicPath: compilation.outputOptions?.publicPath,
bundles,
entries,
pages,
};
const manifestFileName = resolve(this.outputDir, this.fileName);

View File

@ -90,6 +90,14 @@ const userConfig = [
}
},
},
{
name: 'ssg',
validation: 'boolean',
},
{
name: 'ssr',
validation: 'boolean',
},
{
name: 'webpack',
validation: 'function',

View File

@ -1,10 +1,11 @@
import * as path from 'path';
import type { Plugin } from '@ice/types';
import generateHTML from './ssr/generateHtml.js';
import generateHTML from './ssr/generateHTML.js';
import { setupRenderServer } from './ssr/serverRender.js';
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 routeManifest = path.join(rootDir, '.ice/route-manifest.json');
const serverEntry = path.join(outputDir, 'server/entry.mjs');
@ -28,6 +29,8 @@ const webPlugin: Plugin = ({ registerTask, context, onHook }) => {
outDir: outputDir,
entry: serverEntry,
routeManifest,
ssg,
ssr,
});
});
const mode = command === 'start' ? 'development' : 'production';
@ -47,6 +50,8 @@ const webPlugin: Plugin = ({ registerTask, context, onHook }) => {
middleware: setupRenderServer({
serverCompiler,
routeManifest,
ssg,
ssr,
}),
});

View File

@ -6,6 +6,8 @@ interface Options {
entry: string;
routeManifest: string;
outDir: string;
ssg: boolean;
ssr: boolean;
}
export default async function generateHTML(options: Options) {
@ -13,6 +15,8 @@ export default async function generateHTML(options: Options) {
entry,
routeManifest,
outDir,
ssg,
ssr,
} = options;
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++) {
const routePath = paths[i];
const htmlContent = await serverEntry.render({
const requestContext = {
req: {
url: 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`;
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;
}
}

View File

@ -5,12 +5,16 @@ import type { Request, Response } from 'express';
interface Options {
routeManifest: string;
serverCompiler: () => Promise<string>;
ssg: boolean;
ssr: boolean;
}
export function setupRenderServer(options: Options) {
const {
routeManifest,
serverCompiler,
ssg,
ssr,
} = options;
return async (req: Request, res: Response) => {
@ -23,10 +27,17 @@ export function setupRenderServer(options: Options) {
const entry = await serverCompiler();
const serverEntry = await import(entry);
const html = await serverEntry.render({
const requestContext = {
req,
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.send(html);

View File

@ -32,6 +32,8 @@ export function createEsbuildCompiler(options: Options) {
// 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
this: undefined,
// TOOD: sync ice runtime env
'process.env.ICE_RUNTIME_SERVER': 'true',
},
plugins: [
{

View File

@ -2,5 +2,11 @@ import { runClientApp } from '@ice/runtime';
import appConfig from '@/app';
import runtimeModules from './runtimeModules';
import routes from './routes';
import Document from '@/document';
runClientApp(appConfig, runtimeModules, routes);
runClientApp({
appConfig,
runtimeModules,
routes,
Document
});

View File

@ -1,18 +1,26 @@
import { runServerApp } from '@ice/runtime';
import { runServerApp, renderDocument as runDocumentRender } from '@ice/runtime';
import appConfig from '@/app';
import runtimeModules from './runtimeModules';
import Document from '@/document';
import assetsManifest from './assets-manifest.json';
import routes from './routes';
export async function render(requestContext, documentOnly = false) {
return await runServerApp({
requestContext,
runtimeModules,
export async function render(requestContext) {
return await runServerApp(requestContext, {
appConfig,
routes,
assetsManifest,
routes,
runtimeModules,
Document,
});
}
export async function renderDocument(requestContext) {
return await runDocumentRender(requestContext, {
appConfig,
assetsManifest,
routes,
runtimeModules,
Document,
documentOnly,
});
}

View File

@ -2,16 +2,15 @@ import React, { useMemo } from 'react';
import type { Action, Location } from 'history';
import type { Navigator } from 'react-router-dom';
import AppErrorBoundary from './AppErrorBoundary.js';
import { AppContextProvider } from './AppContext.js';
import { useAppContext } from './AppContext.js';
import { createRouteElements } from './routes.js';
import type { AppContext, PageWrapper, AppRouterProps } from './types';
import type { PageWrapper, AppRouterProps } from './types';
interface Props {
action: Action;
location: Location;
navigator: Navigator;
static?: boolean;
appContext: AppContext;
AppProvider: React.ComponentType<any>;
PageWrappers: PageWrapper<{}>[];
AppRouter: React.ComponentType<AppRouterProps>;
@ -19,10 +18,16 @@ interface Props {
export default function App(props: Props) {
const {
location, action, navigator, static: staticProp = false,
appContext, AppProvider, AppRouter, PageWrappers,
location,
action,
navigator,
static: staticProp = false,
AppProvider,
AppRouter,
PageWrappers,
} = props;
const { appConfig, routes: originRoutes } = appContext;
const { appConfig, routes: originRoutes } = useAppContext();
const { strict } = appConfig.app;
const StrictMode = strict ? React.StrictMode : React.Fragment;
@ -52,16 +57,13 @@ export default function App(props: Props) {
/>
);
}
return (
<StrictMode>
<AppErrorBoundary>
<AppContextProvider
value={appContext}
>
<AppProvider>
{element}
</AppProvider>
</AppContextProvider>
<AppProvider>
{element}
</AppProvider>
</AppErrorBoundary>
</StrictMode>
);

View File

@ -1,38 +1,24 @@
import * as React from 'react';
import type { PageData, AppData } from './types';
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;
import { useAppContext } from './AppContext.js';
import { getPageAssets, getEntryAssets } from './assets.js';
export function Meta() {
const { pageData } = useDocumentContext();
const { pageData } = useAppContext();
const meta = pageData.pageConfig.meta || [];
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() {
const { pageData } = useDocumentContext();
const { pageData } = useAppContext();
const title = pageData.pageConfig.title || [];
return (
@ -41,18 +27,20 @@ export function Title() {
}
export function Links() {
const { pageAssets, entryAssets, pageData } = useDocumentContext();
const customLinks = pageData.pageConfig.links || [];
const blockLinks = customLinks.filter((link) => link.block);
const { pageData, matches, assetsManifest } = useAppContext();
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);
return (
<>
{
blockLinks.map(link => {
customLinks.map(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} />)}
@ -61,50 +49,57 @@ export function Links() {
}
export function Scripts() {
const { pageData, pageAssets, entryAssets, appData } = useDocumentContext();
const { links: customLinks = [], scripts: customScripts = [] } = pageData.pageConfig;
const { pageData, initialData, matches, assetsManifest, documentOnly } = useAppContext();
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 blockScripts = customScripts.filter(script => script.block);
const deferredScripts = customScripts.filter(script => !script.block);
const deferredLinks = customLinks.filter(link => !link.block);
const appContext = {
initialData,
pageData,
assetsManifest,
};
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;
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 => {
return <script key={script} 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} />;
return <script key={script} defer src={script} />;
})
}
</>
);
}
export function Main() {
const { html } = useDocumentContext();
export function Main(props) {
const { documentOnly } = useAppContext();
// TODO: set id from config
// eslint-disable-next-line react/self-closing-comp
return <div id="ice-container" dangerouslySetInnerHTML={{ __html: html || '' }}></div>;
}
// disable hydration warning for csr.
// document is rendered by hydration.
// initial content form "ice-container" is empty, which will not match csr result.
return (
<div id="ice-container" suppressHydrationWarning={documentOnly} >
{props.children}
</div>
);
}

View File

@ -1,19 +1,18 @@
import type { AssetsManifest, RouteMatch } from './types';
/**
* merge assets info for matched page
* @param matches
* @param assetsManifest
* @returns
*/
export function getPageAssets(matches: RouteMatch[], assetsManifest: AssetsManifest): string[] {
const { bundles, publicPath } = assetsManifest;
// TODOpublicPath from runtime
const { pages, publicPath } = assetsManifest;
let result = [];
matches.forEach(match => {
const { componentName } = match.route;
const assets = bundles[componentName];
assets && assets?.files.forEach(filePath => {
const assets = pages[componentName];
assets && assets.forEach(filePath => {
result.push(`${publicPath}${filePath}`);
});
});
@ -22,13 +21,79 @@ import type { AssetsManifest, RouteMatch } from './types';
}
export function getEntryAssets(assetsManifest: AssetsManifest): string[] {
const { bundles, publicPath } = assetsManifest;
const assets = [];
Object.keys(bundles).forEach(key => {
const { isEntry, files } = bundles[key];
if (isEntry) {
assets.push(...files);
}
const { entries, publicPath } = assetsManifest;
let result = [];
Object.values(entries).forEach(assets => {
result = result.concat(assets);
});
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}`);
}

View File

@ -2,7 +2,6 @@ import type { AppConfig } from './types';
const defaultAppConfig: AppConfig = {
app: {
rootId: 'ice-container',
strict: true,
},
router: {

View File

@ -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;
}

View File

@ -5,7 +5,7 @@ import {
import Runtime from './runtime.js';
import App from './App.js';
import runClientApp from './runClientApp.js';
import runServerApp from './runServerApp.js';
import runServerApp, { renderDocument } from './runServerApp.js';
import { useAppContext } from './AppContext.js';
import {
Meta,
@ -30,6 +30,7 @@ export {
App,
runClientApp,
runServerApp,
renderDocument,
useAppContext,
Link,
Outlet,

View File

@ -3,7 +3,7 @@ import type { Location } from 'history';
import type { RouteObject } from 'react-router-dom';
import { matchRoutes as originMatchRoutes } from 'react-router-dom';
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
const routeModules: RouteModules = {};
@ -48,7 +48,7 @@ export async function loadPageData(matches: RouteMatch[], initialContext: Initia
const { getInitialData, getPageConfig } = routeModule;
let initialData;
let pageConfig = {};
let pageConfig: PageConfig = {};
if (getInitialData) {
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
*/

View File

@ -1,28 +1,41 @@
import React, { useLayoutEffect, useState } from 'react';
import { createHashHistory, createBrowserHistory } from 'history';
import type { HashHistory, BrowserHistory } from 'history';
import { createSearchParams } from 'react-router-dom';
import Runtime from './runtime.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 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);
await loadRouteModules(matches.map(({ route: { id, load } }) => ({ id, load })));
const initialContext = getInitialContext();
let appData = (window as any).__ICE_APP_DATA__ || {};
let { initialData } = appData;
const appContextFromServer = (window as any).__ICE_APP_CONTEXT__ || {};
let { initialData, pageData, assetsManifest } = appContextFromServer;
const initialContext = getInitialContext();
if (!initialData && appConfig.app?.getInitialData) {
initialData = await appConfig.app.getInitialData(initialContext);
}
let pageData = (window as any).__ICE_PAGE_DATA__ || {};
if (!pageData) {
pageData = await loadPageData(matches, initialContext);
}
@ -32,25 +45,28 @@ export default async function runClientApp(
appConfig,
initialData,
initialPageData: pageData,
assetsManifest,
matches,
};
// TODO: provide useAppContext for runtime modules
const runtime = new Runtime(appContext);
runtimeModules.forEach(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 { appConfig } = appContext;
const { app: { rootId }, router: { type: routerType } } = appConfig;
const { router: { type: routerType } } = appConfig;
const render = runtime.getRender();
const AppProvider = runtime.composeAppProvider() || React.Fragment;
const PageWrappers = runtime.getWrapperPageRegistration();
const AppRouter = runtime.getAppRouter();
const appMountNode = document.getElementById(rootId);
const history = (routerType === 'hash' ? createHashHistory : createBrowserHistory)({ window });
render(
@ -60,8 +76,9 @@ async function render(runtime: Runtime) {
AppProvider={AppProvider}
PageWrappers={PageWrappers}
AppRouter={AppRouter}
Document={Document}
/>,
appMountNode,
document,
);
}
@ -71,16 +88,20 @@ interface BrowserEntryProps {
AppProvider: React.ComponentType<any>;
PageWrappers: PageWrapper<{}>[];
AppRouter: React.ComponentType<AppRouterProps>;
Document: React.ComponentType<{}>;
}
function BrowserEntry({ history, appContext, ...rest }: BrowserEntryProps) {
const { routes, initialPageData } = appContext;
function BrowserEntry({ history, appContext, Document, ...rest }: BrowserEntryProps) {
const { routes, initialPageData, matches: originMatches } = appContext;
const [historyState, setHistoryState] = useState({
action: history.action,
location: history.location,
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
useLayoutEffect(() => {
@ -90,28 +111,61 @@ function BrowserEntry({ history, appContext, ...rest }: BrowserEntryProps) {
throw new Error(`Routes not found in location ${location}.`);
}
loadRouteModules(matches.map(({ route: { id, load } }) => ({ id, load })))
.then(() => {
const initialContext = getInitialContext();
return loadPageData(matches, initialContext);
})
.then((pageData) => {
// just re-render once, so add pageData to historyState :(
setHistoryState({ action, location, pageData });
});
loadNextPage(matches, (pageData) => {
// just re-render once, so add pageData to historyState :(
setHistoryState({ action, location, pageData, matches });
});
});
}, []);
// update app context for the current route.
Object.assign(appContext, {
matches,
pageData,
});
return (
<App
action={action}
location={location}
navigator={history}
appContext={{
...appContext,
pageData,
}}
{...rest}
/>
<AppContextProvider value={appContext}>
<Document>
<App
action={action}
location={location}
navigator={history}
{...rest}
/>
</Document>
</AppContextProvider>
);
}
/**
* Prepare for the next pages.
* Load modulesgetPageData 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;
}

View File

@ -5,48 +5,46 @@ import { Action, createPath, parsePath } from 'history';
import { createSearchParams } from 'react-router-dom';
import Runtime from './runtime.js';
import App from './App.js';
import { DocumentContextProvider } from './Document.js';
import { loadRouteModules, loadPageData, matchRoutes } from './routes.js';
import { getPageAssets, getEntryAssets } from './assets.js';
import { AppContextProvider } from './AppContext.js';
import { loadRouteModules, loadPageData, loadPageConfig, matchRoutes } from './routes.js';
import type { AppContext, InitialContext, RouteItem, ServerContext, AppConfig, RuntimePlugin, CommonJsRuntime, AssetsManifest } from './types';
interface RunServerAppOptions {
requestContext: ServerContext;
interface RenderOptions {
appConfig: AppConfig;
assetsManifest: AssetsManifest;
routes: RouteItem[];
documentOnly: boolean;
runtimeModules: (RuntimePlugin | CommonJsRuntime)[];
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 {
requestContext,
assetsManifest,
appConfig,
runtimeModules,
routes,
Document,
documentOnly,
assetsManifest,
} = options;
const { req } = requestContext;
// 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 location = getLocation(req.url);
const matches = matchRoutes(routes, location);
// TODO: error handling
if (!matches.length) {
// TODO: Render 404
throw new Error('No matched page found.');
}
@ -56,7 +54,7 @@ export default async function runServerApp(options: RunServerAppOptions): Promis
...requestContext,
pathname: location.pathname,
query: Object.fromEntries(createSearchParams(location.search)),
path: url,
path: req.url,
};
let initialData;
@ -67,30 +65,14 @@ export default async function runServerApp(options: RunServerAppOptions): Promis
const pageData = await loadPageData(matches, initialContext);
const appContext: AppContext = {
matches,
routes,
routeData,
appConfig,
initialData,
pageData,
routeModules,
assetsManifest,
};
let initialData;
if (appConfig?.app?.getInitialData) {
initialData = await appConfig.app.getInitialData(initialContext);
}
const appContext: AppContext = {
matches,
routes,
appConfig,
initialData,
initialPageData: pageData,
// pageData and initialPageData are the same when SSR/SSG
pageData,
assetsManifest,
matches,
routes,
};
const runtime = new Runtime(appContext);
@ -98,67 +80,94 @@ export default async function runServerApp(options: RunServerAppOptions): Promis
runtime.loadModule(m);
});
const html = render(runtime, location, Document, documentOnly);
return html;
const staticNavigator = createStaticNavigator();
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(
Document,
runtime: Runtime,
location: Location,
documentOnly: boolean,
) {
const appContext = runtime.getAppContext();
const { matches, pageData = {}, assetsManifest } = appContext;
const {
routes,
assetsManifest,
appConfig,
Document,
} = options;
let html = '';
const location = getLocation(req.url);
const matches = matchRoutes(routes, location);
if (!documentOnly) {
const staticNavigator = createStaticNavigator();
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}
/>,
);
if (!matches.length) {
throw new Error('No matched page found.');
}
const pageAssets = getPageAssets(matches, assetsManifest);
const entryAssets = getEntryAssets(assetsManifest);
await loadRouteModules(matches.map(({ route: { id, load } }) => ({ id, load })));
const appData = {
initialData,
const pageConfig = loadPageConfig(matches);
const pageData = {
pageConfig,
};
const documentContext = {
appData,
const appContext: AppContext = {
assetsManifest,
appConfig,
pageData,
pageAssets,
entryAssets,
html,
matches,
routes,
documentOnly: true,
};
const result = ReactDOMServer.renderToString(
<DocumentContextProvider value={documentContext}>
<AppContextProvider value={appContext}>
<Document />
</DocumentContextProvider>,
</AppContextProvider>,
);
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() {
return {
createHref(to: To) {

View File

@ -40,9 +40,7 @@ class Runtime {
public getAppContext = () => this.appContext;
public getRender = () => {
// TODO: set ssr by process env
const isSSR = true;
return isSSR ? ReactDOM.hydrate : this.render;
return ReactDOM.hydrate;
};
public getAppRouter = () => this.AppRouter;

View File

@ -7,7 +7,6 @@ import type { usePageContext } from './PageContext';
type VoidFunction = () => void;
type AppLifecycle = 'onShow' | 'onHide' | 'onPageNotFound' | 'onShareAppMessage' | 'onUnhandledRejection' | 'onLaunch' | 'onError' | 'onTabItemClick';
type App = Partial<{
rootId?: string;
strict?: boolean;
addProvider?: ({ children }: { children: ReactNode }) => ReactNode;
getInitialData?: (ctx?: InitialContext) => Promise<any>;
@ -83,11 +82,9 @@ export interface RouteModules {
}
export interface AssetsManifest {
publicPath?: string;
bundles?: Record<string, {
files: string[];
isEntry: boolean;
}>;
publicPath: string;
entries: string[];
pages: string[];
}
export interface AppContext {
appConfig: AppConfig;
@ -97,10 +94,7 @@ export interface AppContext {
initialData?: InitialData;
pageData?: PageData;
initialPageData?: PageData;
}
export interface AppData {
initialData?: InitialData;
documentOnly?: boolean;
}
export interface PageData {

View File

@ -42,6 +42,40 @@ describe(`start ${example}`, () => {
expect(await page.$$text('h2')).toStrictEqual(['Home Page']);
}, 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 () => {
await browser.close();
});

View File

@ -104,25 +104,16 @@ export default class Browser {
page.$$eval(selector, (els, trim) => els.map((el) => {
return trim ? (el.textContent || '').replace(/^\s+|\s+$/g, '') : el.textContent
}), 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,
(el: Element, ...args: unknown[]) => {
const [] = args;
return el.getAttribute(attr)
},
attr
)
};
page.$$attr = (selector, attr) =>{
return page.$$eval(
selector,
(els, ...args: unknown[]) => {
return els.map(el => el.getAttribute(attr))
},
attr
(els, attr) => els.map(el => el.getAttribute(attr as string)),
attr,
);
}
return page;
}
}