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 ( + + + + + + + + <Links /> + </head> + <body> + <Main /> + <Scripts /> + </body> + </html> + ); +} + +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: () => <div>fallback</div>, +}); + +export default () => { + return <NonSSR text={'hello world'} />; +}; 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 <NonSSR text={'hello world'} />; +}; 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 <NonSSR text={'hello world'} />; +}; 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 <NonSsr text={'without dynamic'} />; +}; 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: () => <div>bare import fallback</div>, +}); + +export default () => { + return <Normal />; +}; 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: () => <div>normal fallback</div>, +}); + +export default () => { + return <Normal />; +}; 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 <Normal />; +}; 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 @@ +/// <reference types="@ice/app/types" /> 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<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 /> + ); + }; +} 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('<!--$?--><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(); + }); +}); 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,