diff --git a/examples/with-dynamic/.browserslistrc b/examples/with-dynamic/.browserslistrc
new file mode 100644
index 000000000..7637baddc
--- /dev/null
+++ b/examples/with-dynamic/.browserslistrc
@@ -0,0 +1 @@
+chrome 55
\ No newline at end of file
diff --git a/examples/with-dynamic/ice.config.mts b/examples/with-dynamic/ice.config.mts
new file mode 100644
index 000000000..a1def9fd7
--- /dev/null
+++ b/examples/with-dynamic/ice.config.mts
@@ -0,0 +1,5 @@
+import { defineConfig } from '@ice/app';
+
+export default defineConfig(() => ({
+ ssr: true,
+}));
diff --git a/examples/with-dynamic/package.json b/examples/with-dynamic/package.json
new file mode 100644
index 000000000..e254ad798
--- /dev/null
+++ b/examples/with-dynamic/package.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/examples/with-dynamic/src/app.tsx b/examples/with-dynamic/src/app.tsx
new file mode 100644
index 000000000..74015d9cf
--- /dev/null
+++ b/examples/with-dynamic/src/app.tsx
@@ -0,0 +1,6 @@
+export default {
+ app: {
+ rootId: 'app',
+ type: 'browser',
+ },
+};
diff --git a/examples/with-dynamic/src/components/nonssr.tsx b/examples/with-dynamic/src/components/nonssr.tsx
new file mode 100644
index 000000000..08637b1df
--- /dev/null
+++ b/examples/with-dynamic/src/components/nonssr.tsx
@@ -0,0 +1,6 @@
+export default (props) => {
+ window.addEventListener('load', () => {
+ console.log('load');
+ });
+ return
{props.text}
;
+};
diff --git a/examples/with-dynamic/src/components/normal.tsx b/examples/with-dynamic/src/components/normal.tsx
new file mode 100644
index 000000000..0f1d72344
--- /dev/null
+++ b/examples/with-dynamic/src/components/normal.tsx
@@ -0,0 +1,7 @@
+export default () => {
+ return normal text
;
+};
+
+export function NameExportComp() {
+ return name exported
;
+}
diff --git a/examples/with-dynamic/src/document.tsx b/examples/with-dynamic/src/document.tsx
new file mode 100644
index 000000000..61e35a7ec
--- /dev/null
+++ b/examples/with-dynamic/src/document.tsx
@@ -0,0 +1,22 @@
+import { Meta, Title, Links, Main, Scripts } from 'ice';
+
+function Document() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default Document;
diff --git a/examples/with-dynamic/src/pages/nonssr/no-ssr-fallback.tsx b/examples/with-dynamic/src/pages/nonssr/no-ssr-fallback.tsx
new file mode 100644
index 000000000..c63a950ff
--- /dev/null
+++ b/examples/with-dynamic/src/pages/nonssr/no-ssr-fallback.tsx
@@ -0,0 +1,10 @@
+import { dynamic } from '@ice/runtime';
+
+const NonSSR = dynamic(() => import('@/components/nonssr'), {
+ ssr: false,
+ fallback: () => fallback
,
+});
+
+export default () => {
+ return ;
+};
diff --git a/examples/with-dynamic/src/pages/nonssr/no-ssr-no-fallback.tsx b/examples/with-dynamic/src/pages/nonssr/no-ssr-no-fallback.tsx
new file mode 100644
index 000000000..416bf4f4c
--- /dev/null
+++ b/examples/with-dynamic/src/pages/nonssr/no-ssr-no-fallback.tsx
@@ -0,0 +1,9 @@
+import { dynamic } from '@ice/runtime';
+
+const NonSSR = dynamic(() => import('@/components/nonssr'), {
+ ssr: false,
+});
+
+export default () => {
+ return ;
+};
diff --git a/examples/with-dynamic/src/pages/nonssr/ssr-no-fallback.tsx b/examples/with-dynamic/src/pages/nonssr/ssr-no-fallback.tsx
new file mode 100644
index 000000000..e6fcc6dcd
--- /dev/null
+++ b/examples/with-dynamic/src/pages/nonssr/ssr-no-fallback.tsx
@@ -0,0 +1,7 @@
+import { dynamic } from '@ice/runtime';
+
+const NonSSR = dynamic(() => import('@/components/nonssr'));
+
+export default () => {
+ return ;
+};
diff --git a/examples/with-dynamic/src/pages/nonssr/without-dynamic.tsx b/examples/with-dynamic/src/pages/nonssr/without-dynamic.tsx
new file mode 100644
index 000000000..0a9c79c32
--- /dev/null
+++ b/examples/with-dynamic/src/pages/nonssr/without-dynamic.tsx
@@ -0,0 +1,5 @@
+import NonSsr from '@/components/nonssr';
+
+export default () => {
+ return ;
+};
diff --git a/examples/with-dynamic/src/pages/normal/bare-import.tsx b/examples/with-dynamic/src/pages/normal/bare-import.tsx
new file mode 100644
index 000000000..58e201019
--- /dev/null
+++ b/examples/with-dynamic/src/pages/normal/bare-import.tsx
@@ -0,0 +1,9 @@
+import { dynamic } from '@ice/runtime';
+
+const Normal = dynamic(import('../../components/normal'), {
+ fallback: () => bare import fallback
,
+});
+
+export default () => {
+ return ;
+};
diff --git a/examples/with-dynamic/src/pages/normal/basic.tsx b/examples/with-dynamic/src/pages/normal/basic.tsx
new file mode 100644
index 000000000..03292de0f
--- /dev/null
+++ b/examples/with-dynamic/src/pages/normal/basic.tsx
@@ -0,0 +1,9 @@
+import { dynamic } from '@ice/runtime';
+
+const Normal = dynamic(() => import('../../components/normal'), {
+ fallback: () => normal fallback
,
+});
+
+export default () => {
+ return ;
+};
diff --git a/examples/with-dynamic/src/pages/normal/name-export.tsx b/examples/with-dynamic/src/pages/normal/name-export.tsx
new file mode 100644
index 000000000..cfdeb4335
--- /dev/null
+++ b/examples/with-dynamic/src/pages/normal/name-export.tsx
@@ -0,0 +1,13 @@
+import { dynamic } from '@ice/runtime';
+
+const Normal = dynamic(
+ import('../../components/normal').then((mod) => {
+ return {
+ default: mod.NameExportComp,
+ };
+ }),
+);
+
+export default () => {
+ return ;
+};
diff --git a/examples/with-dynamic/src/typings.d.ts b/examples/with-dynamic/src/typings.d.ts
new file mode 100644
index 000000000..1f6ba4ffa
--- /dev/null
+++ b/examples/with-dynamic/src/typings.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/with-dynamic/tsconfig.json b/examples/with-dynamic/tsconfig.json
new file mode 100644
index 000000000..895d41392
--- /dev/null
+++ b/examples/with-dynamic/tsconfig.json
@@ -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"
+ ]
+}
\ No newline at end of file
diff --git a/packages/ice/src/constant.ts b/packages/ice/src/constant.ts
index bf7a6969f..02a1d5468 100644
--- a/packages/ice/src/constant.ts
+++ b/packages/ice/src/constant.ts
@@ -69,6 +69,7 @@ export const RUNTIME_EXPORTS = [
'Await',
'usePageLifecycle',
'unstable_useDocumentData',
+ 'dynamic',
],
alias: {
usePublicAppContext: 'useAppContext',
diff --git a/packages/runtime/src/dynamic.tsx b/packages/runtime/src/dynamic.tsx
new file mode 100644
index 000000000..77de22cb1
--- /dev/null
+++ b/packages/runtime/src/dynamic.tsx
@@ -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 = { default: React.ComponentType
};
+
+export type LoaderComponent
= Promise | ComponentModule>;
+
+export type Loader
= (() => LoaderComponent
) | LoaderComponent
;
+
+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
(mod: React.ComponentType
| ComponentModule
) {
+ return { default: (mod as ComponentModule
)?.default || mod };
+}
+
+const DefaultFallback = () => null;
+
+export function dynamic
(loader: Loader
, 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 () => ;
+ }
+
+ const LazyComp = lazy(() => realLoader().then(convertModule));
+ return (props) => {
+ const hasMounted = useMounted();
+
+ return ssr || hasMounted ? (
+ }>
+
+
+ ) : (
+
+ );
+ };
+}
diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts
index c62c946ed..f5306cc11 100644
--- a/packages/runtime/src/index.ts
+++ b/packages/runtime/src/index.ts
@@ -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,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e5db4cdad..9ceb044d9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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':
diff --git a/tests/integration/with-dynamic.test.ts b/tests/integration/with-dynamic.test.ts
new file mode 100644
index 000000000..2aff930b9
--- /dev/null
+++ b/tests/integration/with-dynamic.test.ts
@@ -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('
normal fallback
')).toBe(true);
+ expect(htmlContent.includes('normal text
')).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('
bare import fallback
')).toBe(true);
+ expect(htmlContent.includes('
normal text
')).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('
')).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();
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index b82be6202..388f195e2 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -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,