feat: dynamic API (#6831)

* chore: save

* feat: basic dynamic

* feat: export from ice/runtime

* test: with-dynamic

* test(with-dynamic): name export

* feat: use useMounted

* chore: cmt

* chore: up lock file

* chore: use universal-env

* fix: ci

* Revert "chore: use universal-env"

This reverts commit 98f5dff99f.

* chore: optimize logic
This commit is contained in:
Homyee King 2024-03-20 14:44:21 +08:00 committed by GitHub
parent 8275f13f81
commit 25c7584326
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 361 additions and 1 deletions

View File

@ -0,0 +1 @@
chrome 55

View File

@ -0,0 +1,5 @@
import { defineConfig } from '@ice/app';
export default defineConfig(() => ({
ssr: true,
}));

View File

@ -0,0 +1,23 @@
{
"name": "@examples/with-dynamic",
"private": true,
"version": "1.0.0",
"scripts": {
"start": "ice start",
"build": "ice build"
},
"description": "ICE example with dynamic",
"author": "ICE Team",
"license": "MIT",
"dependencies": {
"@ice/app": "workspace:*",
"@ice/runtime": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tslib": "^2.4.0"
},
"devDependencies": {
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6"
}
}

View File

@ -0,0 +1,6 @@
export default {
app: {
rootId: 'app',
type: 'browser',
},
};

View File

@ -0,0 +1,6 @@
export default (props) => {
window.addEventListener('load', () => {
console.log('load');
});
return <div>{props.text}</div>;
};

View File

@ -0,0 +1,7 @@
export default () => {
return <div>normal text</div>;
};
export function NameExportComp() {
return <div>name exported</div>;
}

View File

@ -0,0 +1,22 @@
import { Meta, Title, Links, Main, Scripts } from 'ice';
function Document() {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="description" content="ICE 3 Example for plugin request." />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Title />
<Links />
</head>
<body>
<Main />
<Scripts />
</body>
</html>
);
}
export default Document;

View File

@ -0,0 +1,10 @@
import { dynamic } from '@ice/runtime';
const NonSSR = dynamic(() => import('@/components/nonssr'), {
ssr: false,
fallback: () => <div>fallback</div>,
});
export default () => {
return <NonSSR text={'hello world'} />;
};

View File

@ -0,0 +1,9 @@
import { dynamic } from '@ice/runtime';
const NonSSR = dynamic(() => import('@/components/nonssr'), {
ssr: false,
});
export default () => {
return <NonSSR text={'hello world'} />;
};

View File

@ -0,0 +1,7 @@
import { dynamic } from '@ice/runtime';
const NonSSR = dynamic(() => import('@/components/nonssr'));
export default () => {
return <NonSSR text={'hello world'} />;
};

View File

@ -0,0 +1,5 @@
import NonSsr from '@/components/nonssr';
export default () => {
return <NonSsr text={'without dynamic'} />;
};

View File

@ -0,0 +1,9 @@
import { dynamic } from '@ice/runtime';
const Normal = dynamic(import('../../components/normal'), {
fallback: () => <div>bare import fallback</div>,
});
export default () => {
return <Normal />;
};

View File

@ -0,0 +1,9 @@
import { dynamic } from '@ice/runtime';
const Normal = dynamic(() => import('../../components/normal'), {
fallback: () => <div>normal fallback</div>,
});
export default () => {
return <Normal />;
};

View File

@ -0,0 +1,13 @@
import { dynamic } from '@ice/runtime';
const Normal = dynamic(
import('../../components/normal').then((mod) => {
return {
default: mod.NameExportComp,
};
}),
);
export default () => {
return <Normal />;
};

View File

@ -0,0 +1 @@
/// <reference types="@ice/app/types" />

View File

@ -0,0 +1,44 @@
{
"compileOnSave": false,
"buildOnSave": false,
"compilerOptions": {
"baseUrl": ".",
"outDir": "build",
"module": "esnext",
"target": "es6",
"jsx": "react-jsx",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"lib": [
"es6",
"dom"
],
"sourceMap": true,
"allowJs": true,
"rootDir": "./",
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": false,
"importHelpers": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"skipLibCheck": true,
"paths": {
"@/*": [
"./src/*"
],
"ice": [
".ice"
]
}
},
"include": [
"src",
".ice", "src/pages/with-dynamic/.tsx",
],
"exclude": [
"build",
"public"
]
}

View File

@ -69,6 +69,7 @@ export const RUNTIME_EXPORTS = [
'Await',
'usePageLifecycle',
'unstable_useDocumentData',
'dynamic',
],
alias: {
usePublicAppContext: 'useAppContext',

View File

@ -0,0 +1,55 @@
import type { ReactNode } from 'react';
import React, { Suspense, lazy } from 'react';
import useMounted from './useMounted.js';
const isServer = import.meta.renderer === 'server';
type ComponentModule<P = {}> = { default: React.ComponentType<P> };
export type LoaderComponent<P = {}> = Promise<React.ComponentType<P> | ComponentModule<P>>;
export type Loader<P = {}> = (() => LoaderComponent<P>) | LoaderComponent<P>;
export interface DynamicOptions {
/** @default true */
ssr?: boolean;
/** the fallback UI to render before the actual is loaded */
fallback?: () => ReactNode;
}
// Normalize loader to return the module as form { default: Component } for `React.lazy`.
function convertModule<P>(mod: React.ComponentType<P> | ComponentModule<P>) {
return { default: (mod as ComponentModule<P>)?.default || mod };
}
const DefaultFallback = () => null;
export function dynamic<P = {}>(loader: Loader<P>, option?: DynamicOptions) {
const { ssr = true, fallback = DefaultFallback } = option || {};
let realLoader;
// convert dynamic(import('xxx')) to dynamic(() => import('xxx'))
if (loader instanceof Promise) {
realLoader = () => loader;
} else if (typeof loader === 'function') {
realLoader = loader;
}
if (!realLoader) return DefaultFallback;
const Fallback = fallback;
if (!ssr && isServer) {
return () => <Fallback />;
}
const LazyComp = lazy(() => realLoader().then(convertModule));
return (props) => {
const hasMounted = useMounted();
return ssr || hasMounted ? (
<Suspense fallback={<Fallback />}>
<LazyComp {...props} />
</Suspense>
) : (
<Fallback />
);
};
}

View File

@ -50,7 +50,7 @@ import useMounted from './useMounted.js';
import usePageLifecycle from './usePageLifecycle.js';
import { withSuspense, useSuspenseData } from './Suspense.js';
import { createRouteLoader, WrapRouteComponent, RouteErrorComponent, Await } from './routes.js';
import { dynamic } from './dynamic.js';
function useAppContext() {
console.warn('import { useAppContext } from \'@ice/runtime\'; is deprecated, please use import { useAppContext } from \'ice\'; instead.');
return useInternalAppContext();
@ -117,6 +117,7 @@ export {
callDataLoader,
getRequestContext,
history,
dynamic,
useActive,
KeepAliveOutlet,

View File

@ -792,6 +792,31 @@ importers:
specifier: ^18.0.6
version: 18.0.11
examples/with-dynamic:
dependencies:
'@ice/app':
specifier: workspace:*
version: link:../../packages/ice
'@ice/runtime':
specifier: workspace:*
version: link:../../packages/runtime
react:
specifier: ^18.2.0
version: 18.2.0
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
tslib:
specifier: ^2.4.0
version: 2.5.0
devDependencies:
'@types/react':
specifier: ^18.0.17
version: 18.0.34
'@types/react-dom':
specifier: ^18.0.6
version: 18.0.11
examples/with-entry-type:
dependencies:
'@ice/app':

View File

@ -0,0 +1,100 @@
import * as path from 'path';
import * as fs from 'fs';
import { fileURLToPath } from 'url';
import { expect, test, describe, afterAll, beforeAll } from 'vitest';
import { buildFixture, setupBrowser } from '../utils/build';
import type { Page } from '../utils/browser';
import type Browser from '../utils/browser';
// @ts-ignore
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const example = 'with-dynamic';
describe(`build ${example}`, () => {
let page: Page;
let browser: Browser;
beforeAll(async () => {
await buildFixture(example);
const res = await setupBrowser({ example });
page = res.page;
browser = res.browser;
});
describe('normal case', () => {
test('basic case', async () => {
const htmlPath = '/normal/basic.html';
await page.push(htmlPath);
const htmlContent = fs.readFileSync(path.join(__dirname, `../../examples/${example}/build${htmlPath}`), 'utf-8');
expect(htmlContent.includes('"renderMode":"SSG"')).toBe(true);
expect(htmlContent.includes('<!--$?--><template id="B:0"></template><div>normal fallback</div>')).toBe(true);
expect(htmlContent.includes('<div hidden id="S:0"><div>normal text</div>')).toBe(true);
});
test('should support call w/ a bare import', async () => {
const htmlPath = '/normal/bare-import.html';
await page.push(htmlPath);
const htmlContent = fs.readFileSync(path.join(__dirname, `../../examples/${example}/build${htmlPath}`), 'utf-8');
expect(htmlContent.includes('"renderMode":"SSG"')).toBe(true);
expect(htmlContent.includes('<!--$?--><template id="B:0"></template><div>bare import fallback</div>')).toBe(true);
expect(htmlContent.includes('<div hidden id="S:0"><div>normal text</div>')).toBe(true);
});
test('should support name export', async () => {
const htmlPath = '/normal/name-export.html';
await page.push(htmlPath);
const htmlContent = fs.readFileSync(path.join(__dirname, `../../examples/${example}/build${htmlPath}`), 'utf-8');
expect(htmlContent.includes('"renderMode":"SSG"')).toBe(true);
expect(htmlContent.includes('<div hidden id="S:0"><div>name exported</div></div>')).toBe(true);
});
});
describe('non-ssr pkg case', () => {
test('should downgrade when ssr w/o fallback', async () => {
const htmlPath = '/nonssr/ssr-no-fallback.html';
await page.push(htmlPath);
const htmlContent = fs.readFileSync(path.join(__dirname, `../../examples/${example}/build${htmlPath}`), 'utf-8');
expect(await page.$$text('#app')).toStrictEqual(['']);
expect(htmlContent.includes('"renderMode":"CSR"')).toBe(true);
expect(htmlContent.includes('"downgrade":true')).toBe(true);
});
test('should not downgrade when no ssr no fallback', async () => {
const htmlPath = '/nonssr/no-ssr-no-fallback.html';
await page.push(htmlPath);
const htmlContent = fs.readFileSync(path.join(__dirname, `../../examples/${example}/build${htmlPath}`), 'utf-8');
expect(await page.$$text('#app')).toStrictEqual(['']);
expect(htmlContent.includes('"renderMode":"SSG"')).toBe(true);
expect(htmlContent.includes('"downgrade":true')).toBe(false);
});
test('should not downgrade and display fallback when no ssr with fallback', async () => {
const htmlPath = '/nonssr/no-ssr-fallback.html';
await page.push(htmlPath);
const htmlContent = fs.readFileSync(path.join(__dirname, `../../examples/${example}/build${htmlPath}`), 'utf-8');
expect(await page.$$text('#app')).toStrictEqual(['fallback']);
expect(htmlContent.includes('"renderMode":"SSG"')).toBe(true);
expect(htmlContent.includes('"downgrade":true')).toBe(false);
});
test('should downgrade w/o using dynamic', async () => {
const htmlPath = '/nonssr/without-dynamic.html';
await page.push(htmlPath);
const htmlContent = fs.readFileSync(path.join(__dirname, `../../examples/${example}/build${htmlPath}`), 'utf-8');
expect(await page.$$text('#app')).toStrictEqual(['']);
expect(htmlContent.includes('"renderMode":"CSR"')).toBe(true);
expect(htmlContent.includes('"downgrade":true')).toBe(true);
});
});
afterAll(async () => {
await browser.close();
});
});

View File

@ -12,6 +12,7 @@ export default defineConfig({
},
test: {
testTimeout: 120000,
hookTimeout: 120000,
// To avoid error `Segmentation fault (core dumped)` in CI environment, disable threads
// ref: https://github.com/vitest-dev/vitest/issues/317
threads: false,