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 { 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>
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -90,6 +90,14 @@ const userConfig = [
|
|||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ssg',
|
||||
validation: 'boolean',
|
||||
},
|
||||
{
|
||||
name: 'ssr',
|
||||
validation: 'boolean',
|
||||
},
|
||||
{
|
||||
name: 'webpack',
|
||||
validation: 'function',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
// TODO:publicPath 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}`);
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ import type { AppConfig } from './types';
|
|||
|
||||
const defaultAppConfig: AppConfig = {
|
||||
app: {
|
||||
rootId: 'ice-container',
|
||||
strict: true,
|
||||
},
|
||||
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 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 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 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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue