From f56497f694e870ea8be3e0189e62d35bcafc8ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B0=B4=E6=BE=9C?= Date: Tue, 25 Apr 2023 10:47:40 +0800 Subject: [PATCH] feat: async data loader (#6137) * chore: refactor unfinished * feat: support create router * refactor: render mode * fix: code splitting false * feat: add location for icestark * chore: remove console * test: examples * fix: dataloader is undefined * fix: test * fix: test case * fix: test case * fix: types * fix: test case * fix: lock * feat: async data loader * fix: update lock * fix: hydration * fix: router * fix: router * chore: log * fix: hmr * fix: test * fix: test * feat: await * fix: await component * fix: lint * refactor: type * fix: type * fix: app data loader * fix: test * fix: test * test: async data * test: async data * docs: async data loader * fix: lint * refactor: loader config * fix: test * fix: compat with old useage --------- Co-authored-by: ClarkXia --- .../src/pages/with-defer-loader.tsx | 35 +++++ .../src/pages/with-defer-loaders.tsx | 52 ++++++++ .../with-data-loader/src/pages/with-ssr.tsx | 45 +++++++ packages/ice/src/constant.ts | 1 + packages/runtime/src/appData.ts | 16 ++- packages/runtime/src/dataLoader.ts | 86 ++++++------- packages/runtime/src/index.ts | 8 +- packages/runtime/src/routes.tsx | 121 +++++++++++++++--- packages/runtime/src/types.ts | 13 +- packages/runtime/tests/routes.test.tsx | 26 +++- packages/runtime/tests/runClientApp.test.tsx | 26 ++-- packages/runtime/tests/runServerApp.test.tsx | 4 +- pnpm-lock.yaml | 32 +++-- tests/integration/with-data-loader.test.ts | 8 ++ website/docs/guide/basic/data-loader.md | 87 ++++++++++++- 15 files changed, 455 insertions(+), 105 deletions(-) create mode 100644 examples/with-data-loader/src/pages/with-defer-loader.tsx create mode 100644 examples/with-data-loader/src/pages/with-defer-loaders.tsx create mode 100644 examples/with-data-loader/src/pages/with-ssr.tsx diff --git a/examples/with-data-loader/src/pages/with-defer-loader.tsx b/examples/with-data-loader/src/pages/with-defer-loader.tsx new file mode 100644 index 000000000..0396facb9 --- /dev/null +++ b/examples/with-data-loader/src/pages/with-defer-loader.tsx @@ -0,0 +1,35 @@ +import { useData, defineDataLoader, Await } from 'ice'; +import styles from './index.module.css'; + +export default function Home() { + const data = useData(); + + return ( + <> +

With dataLoader

+ Loading item info...

} errorElement={

Error loading!

}> + {(itemInfo) => { + return

Item id is {itemInfo.id}

; + }} +
+ + ); +} + +export function pageConfig() { + return { + title: 'Home', + }; +} + +export const dataLoader = defineDataLoader(async () => { + const promise = new Promise((resolve) => { + setTimeout(() => { + resolve({ + id: 1233, + }); + }, 100); + }); + return await promise; +}, { defer: true }); + diff --git a/examples/with-data-loader/src/pages/with-defer-loaders.tsx b/examples/with-data-loader/src/pages/with-defer-loaders.tsx new file mode 100644 index 000000000..06f7eb5cd --- /dev/null +++ b/examples/with-data-loader/src/pages/with-defer-loaders.tsx @@ -0,0 +1,52 @@ +import { useData, defineDataLoader, Await } from 'ice'; +import styles from './index.module.css'; + +export default function Home() { + const data = useData(); + + return ( + <> +

With dataLoader

+ Loading item info...

} errorElement={

Error loading!

}> + {(itemInfo) => { + return

Item id is {itemInfo.id}

; + }} +
+ Loading item info...

} errorElement={

Error loading!

}> + {(itemInfo) => { + return

Item price is {itemInfo.price}

; + }} +
+ + ); +} + +export function pageConfig() { + return { + title: 'Home', + }; +} + +export const dataLoader = defineDataLoader([ + async () => { + const promise = new Promise((resolve) => { + setTimeout(() => { + resolve({ + id: 1233, + }); + }, 100); + }); + return await promise; + }, + async () => { + const promise = new Promise((resolve) => { + setTimeout(() => { + resolve({ + price: 9.99, + }); + }, 2000); + }); + return await promise; + }, +], { defer: true }); + diff --git a/examples/with-data-loader/src/pages/with-ssr.tsx b/examples/with-data-loader/src/pages/with-ssr.tsx new file mode 100644 index 000000000..16c363547 --- /dev/null +++ b/examples/with-data-loader/src/pages/with-ssr.tsx @@ -0,0 +1,45 @@ +import { useData, defineDataLoader, defineServerDataLoader, Await } from 'ice'; +import styles from './index.module.css'; + +export default function Home() { + const data = useData(); + + return ( + <> +

With dataLoader

+ Loading item info...

} errorElement={

Error loading!

}> + {(itemInfo) => { + return

Item id is {itemInfo.id}

; + }} +
+ + ); +} + +export function pageConfig() { + return { + title: 'Home', + }; +} + +export const dataLoader = defineDataLoader(async () => { + const promise = new Promise((resolve) => { + setTimeout(() => { + resolve({ + id: 1233, + }); + }, 100); + }); + return await promise; +}, { defer: true }); + +export const serverDataLoader = defineServerDataLoader(async () => { + const promise = new Promise((resolve) => { + setTimeout(() => { + resolve({ + id: 1233, + }); + }, 100); + }); + return await promise; +}); \ No newline at end of file diff --git a/packages/ice/src/constant.ts b/packages/ice/src/constant.ts index caeb9b6b1..ecb0e9d1b 100644 --- a/packages/ice/src/constant.ts +++ b/packages/ice/src/constant.ts @@ -61,6 +61,7 @@ export const RUNTIME_EXPORTS = [ 'ClientOnly', 'withSuspense', 'useSuspenseData', + 'Await', 'defineDataLoader', 'defineServerDataLoader', 'defineStaticDataLoader', diff --git a/packages/runtime/src/appData.ts b/packages/runtime/src/appData.ts index 351b3a6ad..57fb28e2f 100644 --- a/packages/runtime/src/appData.ts +++ b/packages/runtime/src/appData.ts @@ -12,15 +12,21 @@ async function getAppData(appExport: AppExport, requestContext?: RequestContext) return await globalLoader.getData('__app'); } - if (appExport?.dataLoader) { - return await appExport.dataLoader(requestContext); + const appDataLoaderConfig = appExport?.dataLoader; + + if (!appDataLoaderConfig) { + return null; } - const loader = appExport?.dataLoader; + let loader; - if (!loader) return null; + if (typeof appDataLoaderConfig === 'function' || Array.isArray(appDataLoaderConfig)) { + loader = appDataLoaderConfig; + } else { + loader = appDataLoaderConfig.loader; + } - await callDataLoader(loader, requestContext); + return await callDataLoader(loader, requestContext); } export { diff --git a/packages/runtime/src/dataLoader.ts b/packages/runtime/src/dataLoader.ts index dcb80936b..ffb1d2913 100644 --- a/packages/runtime/src/dataLoader.ts +++ b/packages/runtime/src/dataLoader.ts @@ -1,16 +1,18 @@ -import type { RequestContext, RenderMode, DataLoaderConfig, DataLoaderResult, RuntimeModules, AppExport, StaticRuntimePlugin, CommonJsRuntime, StaticDataLoader } from './types.js'; import getRequestContext from './requestContext.js'; - +import type { + RequestContext, RenderMode, AppExport, + RuntimeModules, StaticRuntimePlugin, CommonJsRuntime, + Loader, DataLoaderResult, StaticDataLoader, DataLoaderConfig, DataLoaderOptions, +} from './types.js'; interface Loaders { [routeId: string]: DataLoaderConfig; } interface CachedResult { value: any; - status: string; } -interface LoaderOptions { +interface Options { fetcher: Function; runtimeModules: RuntimeModules['statics']; appExport: AppExport; @@ -20,16 +22,24 @@ export interface LoadRoutesDataOptions { renderMode: RenderMode; } -export function defineDataLoader(dataLoaderConfig: DataLoaderConfig): DataLoaderConfig { - return dataLoaderConfig; +export function defineDataLoader(dataLoader: Loader, options?: DataLoaderOptions): DataLoaderConfig { + return { + loader: dataLoader, + options, + }; } -export function defineServerDataLoader(dataLoaderConfig: DataLoaderConfig): DataLoaderConfig { - return dataLoaderConfig; +export function defineServerDataLoader(dataLoader: Loader, options?: DataLoaderOptions): DataLoaderConfig { + return { + loader: dataLoader, + options, + }; } -export function defineStaticDataLoader(dataLoaderConfig: DataLoaderConfig): DataLoaderConfig { - return dataLoaderConfig; +export function defineStaticDataLoader(dataLoader: Loader): DataLoaderConfig { + return { + loader: dataLoader, + }; } /** @@ -124,12 +134,13 @@ export function loadDataByCustomFetcher(config: StaticDataLoader) { /** * Handle for different dataLoader. */ -export function callDataLoader(dataLoader: DataLoaderConfig, requestContext: RequestContext): DataLoaderResult { +export function callDataLoader(dataLoader: Loader, requestContext: RequestContext): DataLoaderResult { if (Array.isArray(dataLoader)) { const loaders = dataLoader.map(loader => { return typeof loader === 'object' ? loadDataByCustomFetcher(loader) : loader(requestContext); }); - return Promise.all(loaders); + + return loaders; } if (typeof dataLoader === 'object') { @@ -156,7 +167,6 @@ function loadInitialDataInClient(loaders: Loaders) { if (dataFromSSR) { cache.set(renderMode === 'SSG' ? `${id}_ssg` : id, { value: dataFromSSR, - status: 'RESOLVED', }); if (renderMode === 'SSR') { @@ -164,15 +174,15 @@ function loadInitialDataInClient(loaders: Loaders) { } } - const dataLoader = loaders[id]; + const dataLoaderConfig = loaders[id]; - if (dataLoader) { + if (dataLoaderConfig) { const requestContext = getRequestContext(window.location); - const loader = callDataLoader(dataLoader, requestContext); + const { loader } = dataLoaderConfig; + const promise = callDataLoader(loader, requestContext); cache.set(id, { - value: loader, - status: 'LOADING', + value: promise, }); } }); @@ -183,7 +193,7 @@ function loadInitialDataInClient(loaders: Loaders) { * Load initial data and register global loader. * In order to load data, JavaScript modules, CSS and other assets in parallel. */ -async function init(dataloaderConfig: Loaders, options: LoaderOptions) { +async function init(loaders: Loaders, options: Options) { const { fetcher, runtimeModules, @@ -208,57 +218,39 @@ async function init(dataloaderConfig: Loaders, options: LoaderOptions) { } try { - loadInitialDataInClient(dataloaderConfig); + loadInitialDataInClient(loaders); } catch (error) { console.error('Load initial data error: ', error); } (window as any).__ICE_DATA_LOADER__ = { - getData: async (id, options: LoadRoutesDataOptions) => { + getData: (id, options: LoadRoutesDataOptions) => { let result; - // first render for ssg use data from build time. - // second render for ssg will use data from data loader. + // First render for ssg use data from build time, second render for ssg will use data from data loader. const cacheKey = `${id}${options?.renderMode === 'SSG' ? '_ssg' : ''}`; + + // In CSR, all dataLoader is called by global data loader to avoid bundle dataLoader in page bundle duplicate. result = cache.get(cacheKey); // Always fetch new data after cache is been used. cache.delete(cacheKey); // Already send data request. if (result) { - const { status, value } = result; - - if (status === 'RESOLVED') { - return result; - } - - try { - if (Array.isArray(value)) { - return await Promise.all(value); - } - - return await value; - } catch (error) { - console.error('DataLoader: getData error.\n', error); - - return { - message: 'DataLoader: getData error.', - error, - }; - } + return result.value; } - const dataLoader = dataloaderConfig[id]; + const dataLoaderConfig = loaders[id]; // No data loader. - if (!dataLoader) { + if (!dataLoaderConfig) { return null; } // Call dataLoader. - // In CSR, all dataLoader is called by global data loader to avoid bundle dataLoader in page bundle duplicate. const requestContext = getRequestContext(window.location); - return await callDataLoader(dataLoader, requestContext); + const { loader } = dataLoaderConfig; + return callDataLoader(loader, requestContext); }, }; } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 879bdc5f7..38991adb3 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -16,7 +16,7 @@ import type { RouteWrapper, RenderMode, DistType, - DataLoaderConfig, + Loader, RouteWrapperConfig, } from './types.js'; import Runtime from './runtime.js'; @@ -50,7 +50,7 @@ import KeepAliveOutlet from './KeepAliveOutlet.js'; import ClientOnly from './ClientOnly.js'; import useMounted from './useMounted.js'; import { withSuspense, useSuspenseData } from './Suspense.js'; -import { createRouteLoader, WrapRouteComponent, RouteErrorComponent } from './routes.js'; +import { createRouteLoader, WrapRouteComponent, RouteErrorComponent, Await } from './routes.js'; export { getAppConfig, @@ -92,6 +92,8 @@ export { withSuspense, useSuspenseData, + Await, + createRouteLoader, WrapRouteComponent, RouteErrorComponent, @@ -109,7 +111,7 @@ export type { RouteWrapper, RenderMode, DistType, - DataLoaderConfig, + Loader, RunClientAppOptions, MetaType, TitleType, diff --git a/packages/runtime/src/routes.tsx b/packages/runtime/src/routes.tsx index 46db33fa5..83db91842 100644 --- a/packages/runtime/src/routes.tsx +++ b/packages/runtime/src/routes.tsx @@ -1,6 +1,8 @@ -import React from 'react'; -import { useRouteError } from 'react-router-dom'; -import type { RouteItem, RouteModules, RenderMode, DataLoaderConfig, RequestContext, ComponentModule } from './types.js'; +import React, { Suspense } from 'react'; +import { useRouteError, defer, Await as ReactRouterAwait } from 'react-router-dom'; +// eslint-disable-next-line camelcase +import type { UNSAFE_DeferredData } from '@remix-run/router'; +import type { RouteItem, RouteModules, RenderMode, RequestContext, ComponentModule, DataLoaderConfig } from './types.js'; import RouteWrapper from './RouteWrapper.js'; import { useAppContext } from './AppContext.js'; import { callDataLoader } from './dataLoader.js'; @@ -89,12 +91,25 @@ export function RouteErrorComponent() { return <>; } +export function Await(props) { + return ( + + + {props.children} + + + ); +} + /** * Create loader function for route module. */ interface LoaderData { - data: any; - pageConfig: any; + data?: any; + pageConfig?: any; } export interface RouteLoaderOptions { @@ -104,28 +119,94 @@ export interface RouteLoaderOptions { renderMode: RenderMode; } -export function createRouteLoader(options: RouteLoaderOptions): () => Promise { - return async () => { - const { dataLoader, pageConfig, staticDataLoader, serverDataLoader } = options.module; - const { requestContext, renderMode, routeId } = options; +// eslint-disable-next-line camelcase +type LoaderFunction = () => LoaderData | UNSAFE_DeferredData | Promise; + +export function createRouteLoader(options: RouteLoaderOptions): LoaderFunction { + const { dataLoader, pageConfig, staticDataLoader, serverDataLoader } = options.module; + const { requestContext, renderMode, routeId } = options; + + let dataLoaderConfig: DataLoaderConfig; + if (renderMode === 'SSG') { + dataLoaderConfig = staticDataLoader; + } else if (renderMode === 'SSR') { + dataLoaderConfig = serverDataLoader || dataLoader; + } else { + dataLoaderConfig = dataLoader; + } + + if (!dataLoaderConfig) { + return () => { + return { + pageConfig: pageConfig ? pageConfig({}) : {}, + }; + }; + } + + let loader; + let loaderOptions; + + // Compat dataLoaderConfig not return by defineDataLoader. + if (typeof dataLoaderConfig === 'function' || Array.isArray(dataLoaderConfig)) { + loader = dataLoaderConfig; + } else { + loader = dataLoaderConfig.loader; + loaderOptions = dataLoaderConfig.options; + } + + const getData = () => { const hasGlobalLoader = typeof window !== 'undefined' && (window as any).__ICE_DATA_LOADER__; const globalLoader = hasGlobalLoader ? (window as any).__ICE_DATA_LOADER__ : null; let routeData: any; if (globalLoader) { - routeData = await globalLoader.getData(routeId, { renderMode }); + routeData = globalLoader.getData(routeId, { renderMode }); } else { - let loader: DataLoaderConfig; - if (renderMode === 'SSG') { - loader = staticDataLoader; - } else if (renderMode === 'SSR') { - loader = serverDataLoader || dataLoader; - } else { - loader = dataLoader; - } - routeData = loader && await callDataLoader(loader, requestContext); + routeData = callDataLoader(loader, requestContext); } + return routeData; + }; + + // Async dataLoader. + if (loaderOptions?.defer) { + return async () => { + const promise = getData(); + + return defer({ + data: promise, + // Call pageConfig without data. + pageConfig: pageConfig ? pageConfig({}) : {}, + }); + }; + } + + // Await dataLoader before render. + return async () => { + const result = getData(); + + let routeData; + try { + if (Array.isArray(result)) { + routeData = await Promise.all(result); + } else if (result instanceof Promise) { + routeData = await result; + } else { + routeData = result; + } + } catch (error) { + console.error('DataLoader: getData error.\n', error); + + routeData = { + message: 'DataLoader: getData error.', + error, + }; + } + const routeConfig = pageConfig ? pageConfig({ data: routeData }) : {}; - const loaderData = { data: routeData, pageConfig: routeConfig }; + const loaderData = { + data: routeData, + pageConfig: routeConfig, + }; + // CSR and load next route data. if (typeof window !== 'undefined') { await updateRoutesConfig(loaderData); diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index c1250b114..b4c03ed90 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -31,7 +31,7 @@ export type RouteConfig = T & { export interface AppExport { default?: AppConfig; [key: string]: any; - dataLoader?: DataLoader; + dataLoader?: DataLoaderConfig; } export type DataLoaderResult = (Promise | RouteData) | RouteData; @@ -49,7 +49,7 @@ export interface StaticDataLoader { // route.defineDataLoader // route.defineServerDataLoader // route.defineStaticDataLoader -export type DataLoaderConfig = DataLoader | StaticDataLoader | Array; +export type Loader = DataLoader | StaticDataLoader | Array; // route.pageConfig export type PageConfig = (args: { data?: RouteData }) => RouteConfig; @@ -71,6 +71,15 @@ export interface RoutesData { [routeId: string]: RouteData; } +export interface DataLoaderOptions { + defer?: boolean; +} + +export interface DataLoaderConfig { + loader: Loader; + options?: DataLoaderOptions; +} + export interface LoadersData { [routeId: string]: LoaderData; } diff --git a/packages/runtime/tests/routes.test.tsx b/packages/runtime/tests/routes.test.tsx index 5012d87bc..abcde3b6d 100644 --- a/packages/runtime/tests/routes.test.tsx +++ b/packages/runtime/tests/routes.test.tsx @@ -28,14 +28,20 @@ describe('routes', () => { const homeItem = { default: () => <>, pageConfig: () => ({ title: 'home' }), - dataLoader: async () => ({ type: 'getData' }), - serverDataLoader: async () => ({ type: 'getServerData' }), - staticDataLoader: async () => ({ type: 'getStaticData' }), + dataLoader: { loader: async () => ({ type: 'getData' }) }, + serverDataLoader: { loader: async () => ({ type: 'getServerData' }) }, + staticDataLoader: { loader: async () => ({ type: 'getStaticData' }) }, }; const aboutItem = { default: () => <>, pageConfig: () => ({ title: 'about' }), }; + const InfoItem = { + default: () => <>, + pageConfig: () => ({ title: 'home' }), + dataLoader: { loader: async () => ({ type: 'getAsyncData' }), options: { defer: true } }, + }; + const homeLazyItem = { Component: homeItem.default, loader: createRouteLoader({ @@ -145,6 +151,19 @@ describe('routes', () => { }); }); + it('load async route', async () => { + const { data: deferredResult } = await createRouteLoader({ + routeId: 'home', + module: InfoItem, + })(); + + const data = await deferredResult.data; + + expect(data).toStrictEqual({ + type: 'getAsyncData', + }); + }); + it('load route data for SSG', async () => { const routesDataSSG = await createRouteLoader({ routeId: 'home', @@ -226,7 +245,6 @@ describe('routes', () => { })(); expect(routesDataCSR).toStrictEqual({ - data: undefined, pageConfig: { title: 'about', }, diff --git a/packages/runtime/tests/runClientApp.test.tsx b/packages/runtime/tests/runClientApp.test.tsx index c2658b547..63c889813 100644 --- a/packages/runtime/tests/runClientApp.test.tsx +++ b/packages/runtime/tests/runClientApp.test.tsx @@ -92,7 +92,9 @@ describe('run client app', () => { ); }, pageConfig: () => ({ title: 'home' }), - dataLoader: async () => ({ data: 'test' }), + dataLoader: { + loader: async () => ({ data: 'test' }), + }, }; const basicRoutes = [ { @@ -110,8 +112,10 @@ describe('run client app', () => { it('run with static runtime', async () => { await runClientApp({ app: { - dataLoader: async () => { - return { msg: staticMsg }; + dataLoader: { + loader: async () => { + return { msg: staticMsg }; + }, }, }, // @ts-ignore don't need to pass params in test case. @@ -261,9 +265,11 @@ describe('run client app', () => { let executed = false; await runClientApp({ app: { - dataLoader: async () => { - executed = true; - return { msg: '-getAppData' }; + dataLoader: { + loader: async () => { + executed = true; + return { msg: '-getAppData' }; + }, }, }, // @ts-ignore don't need to pass params in test case. @@ -290,9 +296,11 @@ describe('run client app', () => { await runClientApp({ app: { - dataLoader: async () => { - executed = true; - return { msg: 'app' }; + dataLoader: { + loader: async () => { + executed = true; + return { msg: 'app' }; + }, }, }, // @ts-ignore don't need to pass params in test case. diff --git a/packages/runtime/tests/runServerApp.test.tsx b/packages/runtime/tests/runServerApp.test.tsx index bfad032c4..2ef60ee19 100644 --- a/packages/runtime/tests/runServerApp.test.tsx +++ b/packages/runtime/tests/runServerApp.test.tsx @@ -14,7 +14,9 @@ describe('run server app', () => { const homeItem = { default: () =>
home
, pageConfig: () => ({ title: 'home' }), - dataLoader: async () => ({ data: 'test' }), + dataLoader: { + loader: async () => ({ data: 'test' }), + }, }; const basicRoutes = [ { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8dac2cef..4fba48243 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7457,6 +7457,7 @@ packages: '@types/prop-types': 15.7.5 '@types/scheduler': 0.16.2 csstype: 3.1.1 + dev: true /@types/react/18.0.28: resolution: {integrity: sha512-RD0ivG1kEztNBdoAK7lekI9M+azSnitIn85h4iOiaLjaTrMjzslhaqCGaI4IyCJ1RljWiLCEu4jyrLLgqxBTew==} @@ -9622,8 +9623,8 @@ packages: engines: {node: '>=10'} hasBin: true dependencies: - JSONStream: 1.3.5 is-text-path: 1.0.1 + JSONStream: 1.3.5 lodash: 4.17.21 meow: 8.1.2 split2: 3.2.2 @@ -9653,6 +9654,7 @@ packages: resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} dependencies: is-what: 3.14.1 + dev: false /copy-text-to-clipboard/3.0.1: resolution: {integrity: sha512-rvVsHrpFcL4F2P8ihsoLdFHmd404+CMg71S756oRSeQgqk51U3kicGdnvfkrxva0xXH92SjGS62B0XIJsbh+9Q==} @@ -10752,6 +10754,7 @@ packages: requiresBuild: true dependencies: prr: 1.0.1 + dev: false optional: true /error-ex/1.3.2: @@ -13065,6 +13068,7 @@ packages: engines: {node: '>=0.10.0'} hasBin: true requiresBuild: true + dev: false optional: true /image-size/1.0.2: @@ -13589,6 +13593,7 @@ packages: /is-what/3.14.1: resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==} + dev: false /is-whitespace-character/1.0.4: resolution: {integrity: sha512-SDweEzfIZM0SJV0EUga669UTKlmL0Pq8Lno0QDQsPnvECB3IM2aP0gdx5TrU0A01MAPfViaZiI2V1QMZLaKK5w==} @@ -14870,6 +14875,7 @@ packages: source-map: 0.6.1 transitivePeerDependencies: - supports-color + dev: false /leven/3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} @@ -15168,6 +15174,7 @@ packages: dependencies: pify: 4.0.1 semver: 5.7.1 + dev: false optional: true /make-dir/3.1.0: @@ -15554,6 +15561,7 @@ packages: sax: 1.2.4 transitivePeerDependencies: - supports-color + dev: false optional: true /negotiator/0.6.3: @@ -16006,6 +16014,7 @@ packages: /parse-node-version/1.0.1: resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} engines: {node: '>= 0.10'} + dev: false /parse-numeric-range/1.3.0: resolution: {integrity: sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==} @@ -17337,6 +17346,7 @@ packages: nanoid: 3.3.4 picocolors: 1.0.0 source-map-js: 1.0.2 + dev: false /postcss/8.4.21: resolution: {integrity: sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==} @@ -17490,6 +17500,7 @@ packages: /prr/1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + dev: false optional: true /pseudomap/1.0.2: @@ -18624,12 +18635,6 @@ packages: /react-dev-utils/12.0.1_a37q6j7dwawz22saey2vgkpwqm: resolution: {integrity: sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==} engines: {node: '>=14'} - peerDependencies: - typescript: '>=2.7' - webpack: '>=4' - peerDependenciesMeta: - typescript: - optional: true dependencies: '@babel/code-frame': 7.18.6 address: 1.2.2 @@ -18660,7 +18665,9 @@ packages: transitivePeerDependencies: - eslint - supports-color + - typescript - vue-template-compiler + - webpack /react-dom/17.0.2_react@17.0.2: resolution: {integrity: sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==} @@ -18671,6 +18678,7 @@ packages: object-assign: 4.1.1 react: 17.0.2 scheduler: 0.20.2 + dev: false /react-dom/18.2.0_react@18.2.0: resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} @@ -18806,6 +18814,7 @@ packages: /react-refresh/0.14.0: resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==} engines: {node: '>=0.10.0'} + dev: false /react-router-config/5.1.1_2dl5roaqnyqqppnjni7uetnb3a: resolution: {integrity: sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg==} @@ -18903,6 +18912,7 @@ packages: dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 + dev: false /react/18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} @@ -19518,6 +19528,7 @@ packages: chokidar: 3.5.3 immutable: 4.2.4 source-map-js: 1.0.2 + dev: false /sax/1.2.4: resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==} @@ -19534,6 +19545,7 @@ packages: dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 + dev: false /scheduler/0.21.0: resolution: {integrity: sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==} @@ -20563,6 +20575,7 @@ packages: serialize-javascript: 6.0.1 terser: 5.14.2 webpack: 5.76.2_w34or7orauknzckzea4nxxqrru + dev: true /terser-webpack-plugin/5.3.5_webpack@5.76.2: resolution: {integrity: sha512-AOEDLDxD2zylUGf/wxHxklEkOe2/r+seuyOWujejFrIxHf11brA1/dWQNIgXa1c6/Wkxgu7zvv0JhOWfc2ELEA==} @@ -21810,7 +21823,7 @@ packages: mime-types: 2.1.35 range-parser: 1.2.1 schema-utils: 4.0.0 - webpack: 5.76.2_w34or7orauknzckzea4nxxqrru + webpack: 5.76.2_esbuild@0.17.16 /webpack-dev-server/4.11.1_webpack@5.76.2: resolution: {integrity: sha512-lILVz9tAUy1zGFwieuaQtYiadImb5M3d+H+L1zDYalYoDl0cksAB1UNyuE5MMWJrG6zR1tXkCP2fitl7yoUJiw==} @@ -21850,7 +21863,7 @@ packages: serve-index: 1.9.1 sockjs: 0.3.24 spdy: 4.0.2 - webpack: 5.76.2_w34or7orauknzckzea4nxxqrru + webpack: 5.76.2_esbuild@0.17.16 webpack-dev-middleware: 5.3.3_webpack@5.76.2 ws: 8.12.1 transitivePeerDependencies: @@ -22151,6 +22164,7 @@ packages: - '@swc/core' - esbuild - uglify-js + dev: true /webpackbar/5.0.2_webpack@5.76.2: resolution: {integrity: sha512-BmFJo7veBDgQzfWXl/wwYXr/VFus0614qZ8i9znqcl9fnEdiVkdbi0TedLQ6xAK92HZHDJ0QmyQ0fmuZPAgCYQ==} diff --git a/tests/integration/with-data-loader.test.ts b/tests/integration/with-data-loader.test.ts index b5ae58132..1449443f0 100644 --- a/tests/integration/with-data-loader.test.ts +++ b/tests/integration/with-data-loader.test.ts @@ -49,6 +49,14 @@ describe(`start ${example}`, () => { expect(timeStampForRouter3).not.toStrictEqual(timeStampForRouter1); }); + test('should work with deferred data loader', async () => { + await page.push('/with-defer-loader'); + await page.waitForNetworkIdle(); + const data = (await page.$$text('#itemId'))[0]; + + expect(data).toEqual('1233'); + }); + afterAll(async () => { await browser.close(); }); diff --git a/website/docs/guide/basic/data-loader.md b/website/docs/guide/basic/data-loader.md index 5c0602178..55d688e23 100644 --- a/website/docs/guide/basic/data-loader.md +++ b/website/docs/guide/basic/data-loader.md @@ -71,6 +71,51 @@ export const dataLoader = defineDataLoader(async () => { 受小程序环境限制,通过 `dataLoader` 定义的应用级数据加载将在 `App` 的 `onLaunch` 生命周期中进行,页面级数据加载则会在 `Page` 的 `onLoad` 生命周期中,二者均会阻塞页面的 UI 渲染。如果这不是你想要的效果,请按照常规方式进行数据请求。(比如在组件首次 `useEffect` 时发起数据请求) ::: + +## 异步消费数据 + +默认情况下,页面会等待数据请求完成后,再开始渲染,在数据接口比较快的情况下,这可以避免页面的二次渲染。 + +如果数据接口较慢,也可以选择先渲染不依赖于动态数据的部分,待数据回来后,再重新渲染依赖数据的页面内容。 + +具体做法如下: +- 1. 在定义 dataLoader 时标记 defer: true +- 2. 在消费数据时,使用 Await 组件包裹依赖于数据的页面内容 + +```tsx title="src/pages/index.tsx" +import { useData, defineDataLoader, Await } from 'ice'; + +// 页面组件的 UI 实现 +export default function Home() { + const data = useData(); + + return ( + <> +
Hello ICE
+ loading...} errorElement={
Error!
} /> + { + (data) =>
{JSON.stringify(data)}
+ } +
+ + ); +}; + +// 在定义 dataLoader 时标记 defer: true +export const dataLoader = defineDataLoader(async () => { + const data = await fetch('https://example.com/api/xxx'); + return data; +}, { defer: true }); +``` + +注意: +- 当 dataLoader 被声明为异步时,useData 返回的内容不可直接消费,需由 Await 组件处理 + +Await 组件接收三个参数 +* resolve 数据请求对象 +* fallback 数据加载过程中展示的 UI +* errorElement 请求失败时展示的 UI + ## 静态 dataLoader 当开发者希望通过统一的发送函数处理静态配置以完成 `dataLoader` 时,可以通过自定义 `fetcher` 以完成发送逻辑的统一封装,在 `dataLoader` 中只需要传递一份配置即可。 @@ -186,7 +231,7 @@ export default function Home(props) { import { useData, defineDataLoader } from 'ice'; export default function Home() { - const [useInfo, itemInfo] = useData(); + const [userInfo, itemInfo] = useData(); return ( <> @@ -196,6 +241,40 @@ export default function Home() { ); }; +export const dataLoader = defineDataLoader([ + async () => { + const userInfo = await fetch('https://example.com/api/userInfo'); + return userInfo; + }, + async (ctx) => { + const itemInfo = await fetch(`https://example.com/api/itemInfo${ctx?.query?.itemId}`); + return itemInfo; + }, +]); +``` + +多个数据请求的情况下,`useData` 获取的数据也对应的为数组,数组元素和 `dataLoader` 中定义的数据请求的返回值一一对应。 + +如果 dataLoader 被声明为异步,消费时可以分别 Await 不同的数据,这样可以做到先返回的数据,先渲染。 + +```tsx +import { useData, defineDataLoader } from 'ice'; + +export default function Home() { + const [userInfo, itemInfo] = useData(); + + return ( + <> + + { (data) =>
Hello {data?.name}
} +
+ + { (data) =>
{JSON.stringify(data)}
} +
+ + ); +}; + export const dataLoader = defineDataLoader([ async () => { const useInfo = await fetch('https://example.com/api/userInfo'); @@ -205,7 +284,5 @@ export const dataLoader = defineDataLoader([ const itemInfo = await fetch(`https://example.com/api/itemInfo${ctx?.query?.itemId}`); return itemInfo; }, -]); -``` - -多个数据请求的情况下,`useData` 获取的数据也对应的为数组,数组元素和 `dataLoader` 中定义的数据请求的返回值一一对应。 +], { defer: true }); +``` \ No newline at end of file