mirror of
https://github.com/alibaba/ice.git
synced 2024-10-23 07:04:52 +08:00
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:
parent
8275f13f81
commit
25c7584326
1
examples/with-dynamic/.browserslistrc
Normal file
1
examples/with-dynamic/.browserslistrc
Normal file
@ -0,0 +1 @@
|
||||
chrome 55
|
5
examples/with-dynamic/ice.config.mts
Normal file
5
examples/with-dynamic/ice.config.mts
Normal file
@ -0,0 +1,5 @@
|
||||
import { defineConfig } from '@ice/app';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
ssr: true,
|
||||
}));
|
23
examples/with-dynamic/package.json
Normal file
23
examples/with-dynamic/package.json
Normal 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"
|
||||
}
|
||||
}
|
6
examples/with-dynamic/src/app.tsx
Normal file
6
examples/with-dynamic/src/app.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
app: {
|
||||
rootId: 'app',
|
||||
type: 'browser',
|
||||
},
|
||||
};
|
6
examples/with-dynamic/src/components/nonssr.tsx
Normal file
6
examples/with-dynamic/src/components/nonssr.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
export default (props) => {
|
||||
window.addEventListener('load', () => {
|
||||
console.log('load');
|
||||
});
|
||||
return <div>{props.text}</div>;
|
||||
};
|
7
examples/with-dynamic/src/components/normal.tsx
Normal file
7
examples/with-dynamic/src/components/normal.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export default () => {
|
||||
return <div>normal text</div>;
|
||||
};
|
||||
|
||||
export function NameExportComp() {
|
||||
return <div>name exported</div>;
|
||||
}
|
22
examples/with-dynamic/src/document.tsx
Normal file
22
examples/with-dynamic/src/document.tsx
Normal 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;
|
10
examples/with-dynamic/src/pages/nonssr/no-ssr-fallback.tsx
Normal file
10
examples/with-dynamic/src/pages/nonssr/no-ssr-fallback.tsx
Normal 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'} />;
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
import { dynamic } from '@ice/runtime';
|
||||
|
||||
const NonSSR = dynamic(() => import('@/components/nonssr'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default () => {
|
||||
return <NonSSR text={'hello world'} />;
|
||||
};
|
@ -0,0 +1,7 @@
|
||||
import { dynamic } from '@ice/runtime';
|
||||
|
||||
const NonSSR = dynamic(() => import('@/components/nonssr'));
|
||||
|
||||
export default () => {
|
||||
return <NonSSR text={'hello world'} />;
|
||||
};
|
@ -0,0 +1,5 @@
|
||||
import NonSsr from '@/components/nonssr';
|
||||
|
||||
export default () => {
|
||||
return <NonSsr text={'without dynamic'} />;
|
||||
};
|
9
examples/with-dynamic/src/pages/normal/bare-import.tsx
Normal file
9
examples/with-dynamic/src/pages/normal/bare-import.tsx
Normal 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 />;
|
||||
};
|
9
examples/with-dynamic/src/pages/normal/basic.tsx
Normal file
9
examples/with-dynamic/src/pages/normal/basic.tsx
Normal 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 />;
|
||||
};
|
13
examples/with-dynamic/src/pages/normal/name-export.tsx
Normal file
13
examples/with-dynamic/src/pages/normal/name-export.tsx
Normal 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 />;
|
||||
};
|
1
examples/with-dynamic/src/typings.d.ts
vendored
Normal file
1
examples/with-dynamic/src/typings.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="@ice/app/types" />
|
44
examples/with-dynamic/tsconfig.json
Normal file
44
examples/with-dynamic/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
@ -69,6 +69,7 @@ export const RUNTIME_EXPORTS = [
|
||||
'Await',
|
||||
'usePageLifecycle',
|
||||
'unstable_useDocumentData',
|
||||
'dynamic',
|
||||
],
|
||||
alias: {
|
||||
usePublicAppContext: 'useAppContext',
|
||||
|
55
packages/runtime/src/dynamic.tsx
Normal file
55
packages/runtime/src/dynamic.tsx
Normal 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 />
|
||||
);
|
||||
};
|
||||
}
|
@ -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,
|
||||
|
@ -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':
|
||||
|
100
tests/integration/with-dynamic.test.ts
Normal file
100
tests/integration/with-dynamic.test.ts
Normal 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();
|
||||
});
|
||||
});
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user