diff --git a/packages/cli/plugin-data-loader/src/cli/createRequest.ts b/packages/cli/plugin-data-loader/src/cli/createRequest.ts index ee875a516faf..920247ac4982 100644 --- a/packages/cli/plugin-data-loader/src/cli/createRequest.ts +++ b/packages/cli/plugin-data-loader/src/cli/createRequest.ts @@ -71,3 +71,45 @@ export const createRequest = (routeId: string, method = 'get') => { return res; }; }; + +export const createActionRequest = (routeId: string) => { + return async ({ + params, + request, + }: { + params: Record; + request: Request; + }) => { + const url = getRequestUrl({ params, request, routeId }); + + const init: RequestInit = { + signal: request.signal, + }; + if (request.method !== 'GET') { + init.method = request.method; + + const contentType = request.headers.get('Content-Type'); + if (contentType && /\bapplication\/json\b/.test(contentType)) { + init.headers = { 'Content-Type': contentType }; + init.body = JSON.stringify(await request.json()); + } else if (contentType && /\btext\/plain\b/.test(contentType)) { + init.headers = { 'Content-Type': contentType }; + init.body = await request.text(); + } else if ( + contentType && + /\bapplication\/x-www-form-urlencoded\b/.test(contentType) + ) { + // eslint-disable-next-line node/prefer-global/url-search-params + init.body = new URLSearchParams(await request.text()); + } else { + init.body = await request.formData(); + } + } + + const res: Response = await fetch(url, init); + if (!res.ok) { + throw res; + } + return res; + }; +}; diff --git a/packages/cli/plugin-data-loader/src/cli/generateClient.ts b/packages/cli/plugin-data-loader/src/cli/generateClient.ts index 29efcdc3a700..6869ffcd7378 100644 --- a/packages/cli/plugin-data-loader/src/cli/generateClient.ts +++ b/packages/cli/plugin-data-loader/src/cli/generateClient.ts @@ -1,60 +1,44 @@ import path from 'path'; export const generateClient = ({ - mapFile, - loaderId, + inline, + action, + routeId, }: { - mapFile: string; - loaderId?: string; + inline: boolean; + action?: boolean; + routeId: string; }) => { - delete require.cache[mapFile]; - const loadersMap: Record< - string, - { - routeId: string; - filePath: string; - inline: boolean; - } - > = require(mapFile); let requestCode = ``; - let exportsCode = ``; const requestCreatorPath = path .join(__dirname, './createRequest') .replace('/cjs/cli/', '/esm/cli/') .replace(/\\/g, '/'); const importCode = ` - import { createRequest } from '${requestCreatorPath}'; + import { createRequest, createActionRequest } from '${requestCreatorPath}'; `; - if (!loaderId) { - requestCode = Object.keys(loadersMap) - .map(loaderId => { - const { routeId } = loadersMap[loaderId]; - return ` - const ${loaderId} = createRequest('${routeId}'); + if (inline) { + if (action) { + requestCode = ` + export const loader = createRequest('${routeId}'); + export const action = createActionRequest('${routeId}') + `; + } else { + requestCode = ` + export const loader = createRequest('${routeId}'); `; - }) - .join(''); - - exportsCode = `export {`; - for (const loader of Object.keys(loadersMap)) { - exportsCode += `${loader},`; } - exportsCode += '}'; } else { - const loader = loadersMap[loaderId]; requestCode = ` - const loader = createRequest('${loader.routeId}'); - `; - - exportsCode = `export default loader;`; + export default createRequest('${routeId}'); + `; } const generatedCode = ` ${importCode} ${requestCode} - ${exportsCode} `; return generatedCode; diff --git a/packages/cli/plugin-data-loader/src/cli/loader.ts b/packages/cli/plugin-data-loader/src/cli/loader.ts index 16e70d5acae2..d4b6832c0323 100644 --- a/packages/cli/plugin-data-loader/src/cli/loader.ts +++ b/packages/cli/plugin-data-loader/src/cli/loader.ts @@ -5,8 +5,11 @@ import { generateClient } from './generateClient'; type Context = { mapFile: string; - loaderId?: string; + loaderId: string; clientData?: boolean; + action: boolean; + inline: boolean; + routeId: string; }; export default async function loader( @@ -27,20 +30,16 @@ export default async function loader( const options = resourceQuery .slice(1) .split('&') - .map(item => { - return item.split('='); - }) .reduce((pre, cur) => { - const [key, value] = cur; - if (!key || !value) { - return pre; + const [key, value] = cur.split('='); + if (key && value) { + // eslint-disable-next-line no-nested-ternary + pre[key] = value === 'true' ? true : value === 'false' ? false : value; } - pre[key] = value; return pre; - }, {} as Record) as Context; + }, {} as Record); - // if we can not parse mapFile from resourceQuery, it means the with no need for data-loader handle. - if (!options.mapFile) { + if (!options.loaderId) { return source; } @@ -63,8 +62,10 @@ export default async function loader( } const code = generateClient({ - mapFile: options.mapFile, - loaderId: options.loaderId, + inline: options.inline, + action: options.action, + routeId: options.routeId, }); + return code; } diff --git a/packages/cli/plugin-data-loader/src/runtime/index.ts b/packages/cli/plugin-data-loader/src/runtime/index.ts index 4976bb27c3ac..300a7e6c25cb 100644 --- a/packages/cli/plugin-data-loader/src/runtime/index.ts +++ b/packages/cli/plugin-data-loader/src/runtime/index.ts @@ -79,20 +79,28 @@ const createLoaderHeaders = ( return headers; }; -const createLoaderRequest = (context: ServerContext) => { +const createRequest = (context: ServerContext) => { const origin = `${context.protocol}://${context.host}`; // eslint-disable-next-line node/prefer-global/url const url = new URL(context.url, origin); const controller = new AbortController(); - const init = { + const init: { + [key: string]: unknown; + } = { method: context.method, headers: createLoaderHeaders(context.headers), signal: controller.signal, }; - return new Request(url.href, init); + if (!['GET', 'HEAD'].includes(context.method.toUpperCase())) { + init.body = context.req; + } + + const request = new Request(url.href, init); + + return request; }; const sendLoaderResponse = async ( @@ -122,7 +130,7 @@ export const handleRequest = async ({ serverRoutes: ServerRoute[]; routes: NestedRoute[]; }) => { - const { method, query } = context; + const { query } = context; const routeId = query[LOADER_ID_PARAM] as string; const entry = matchEntry(context.path, serverRoutes); @@ -131,10 +139,6 @@ export const handleRequest = async ({ return; } - if (method.toLowerCase() !== 'get') { - throw new Error('CSR data loader request only support http GET method'); - } - const basename = entry.urlPath; const end = time(); const { res, logger, reporter } = context; @@ -142,7 +146,8 @@ export const handleRequest = async ({ const { queryRoute } = createStaticHandler(routes, { basename, }); - const request = createLoaderRequest(context); + + const request = createRequest(context); const requestContext = createRequestContext(); // initial requestContext // 1. inject reporter @@ -187,7 +192,12 @@ export const handleRequest = async ({ } } catch (error) { const message = error instanceof ErrorResponse ? error.data : String(error); - logger?.error(message); + if (error instanceof Error) { + logger?.error(error); + } else { + logger?.error(message); + } + response = new NodeResponse(message, { status: 500, headers: { @@ -198,6 +208,5 @@ export const handleRequest = async ({ const cost = end(); reporter.reportTiming(`${LOADER_REPORTER_NAME}-navigation`, cost); - await sendLoaderResponse(res, response); }; diff --git a/packages/cli/plugin-data-loader/tests/__snapshots__/loader.test.ts.snap b/packages/cli/plugin-data-loader/tests/__snapshots__/loader.test.ts.snap index be1b83efd262..b85366f84147 100644 --- a/packages/cli/plugin-data-loader/tests/__snapshots__/loader.test.ts.snap +++ b/packages/cli/plugin-data-loader/tests/__snapshots__/loader.test.ts.snap @@ -1,19 +1,29 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`data loader basic usage 1`] = ` +"async function loader() { + return 'request profile page'; +} +async function loader2() { + return 'request profile layout'; +} +const loader3 = async () => { + return { + message: 'hello user', + }; +}; + +// src/routes/layout.tsx +const loader4 = async () => { + return { + message: 'from server', + }; +}; +export { + loader as loader_0, + loader2 as loader_1, + loader3 as loader_2, + loader4 as loader_3, +}; " - - import { createRequest } from '/packages/cli/plugin-data-loader/src/cli/createRequest'; - - - const loader_0 = createRequest('main_user/profile/page'); - - const loader_1 = createRequest('main_user/profile/layout'); - - const loader_2 = createRequest('main_user/layout'); - - const loader_3 = createRequest('main_layout'); - - export {loader_0,loader_1,loader_2,loader_3,} - " `; diff --git a/packages/document/main-doc/docs/en/apis/app/hooks/src/routes.mdx b/packages/document/main-doc/docs/en/apis/app/hooks/src/routes.mdx index a6f20bdb1730..07ce02e47653 100644 --- a/packages/document/main-doc/docs/en/apis/app/hooks/src/routes.mdx +++ b/packages/document/main-doc/docs/en/apis/app/hooks/src/routes.mdx @@ -54,7 +54,7 @@ The `routes/[id]/page.tsx` file will be converted to the `/:id` route. Except fo In the component, you can use [useParams](/apis/app/runtime/router/router#useparams) to obtain the corresponding named parameter. -When using the [loader](/guides/basic-features/data-fetch#the-loader-function) function to obtain data, `params` will be passed as an input parameter to the `loader` function, and the corresponding parameter can be obtained through the attribute of `params`. +When using the [loader](/guides/basic-features/data/data-fetch#the-loader-function) function to obtain data, `params` will be passed as an input parameter to the `loader` function, and the corresponding parameter can be obtained through the attribute of `params`. ## Layout Component diff --git a/packages/document/main-doc/docs/en/guides/advanced-features/rspack-start.mdx b/packages/document/main-doc/docs/en/guides/advanced-features/rspack-start.mdx index 9ab19c5cd117..f9539d80ed2a 100644 --- a/packages/document/main-doc/docs/en/guides/advanced-features/rspack-start.mdx +++ b/packages/document/main-doc/docs/en/guides/advanced-features/rspack-start.mdx @@ -26,7 +26,7 @@ After the project is created, you can experience the project by running `pnpm ru When using Rspack as the bundler, the following Features are temporarily unavailable as some of the capabilities are still under development and we will provide support in the future. - Storybook Devtool -- The usage of [useLoader](/guides/basic-features/data-fetch.html) in Client Side Rendering +- The usage of [useLoader](/guides/basic-features/data/data-fetch.html) in Client Side Rendering ::: diff --git a/packages/document/main-doc/docs/en/guides/advanced-features/ssr.mdx b/packages/document/main-doc/docs/en/guides/advanced-features/ssr.mdx index fab6fbb1eadb..a7cdc0179967 100644 --- a/packages/document/main-doc/docs/en/guides/advanced-features/ssr.mdx +++ b/packages/document/main-doc/docs/en/guides/advanced-features/ssr.mdx @@ -22,8 +22,8 @@ export default defineConfig({ Modern.js provides a Data Loader that simplifies data fetching for developers working with SSR and CSR. Each routing module, such as `layout.tsx` and `page.tsx`, can define its own Data Loader: -```ts title="src/routes/page.loader.ts" -export default () => { +```ts title="src/routes/page.data.ts" +export const loader = () => { return { message: 'Hello World', }; @@ -49,7 +49,7 @@ Developers should still be mindful of data fallback, including `null` values or 1. When requesting a page through client-side routing, Modern.js sends an HTTP request. The server receives the request and executes the corresponding Data Loader function for the page, then returns the execution result as a response to the browser. -2. When using Data Loader, data is fetched before rendering. Modern.js also supports obtaining data during component rendering. For more related content, please refer to [Data Fetch](/guides/basic-features/data-fetch). +2. When using Data Loader, data is fetched before rendering. Modern.js also supports obtaining data during component rendering. For more related content, please refer to [Data Fetch](/guides/basic-features/data/data-fetch). ::: @@ -158,10 +158,10 @@ Using SPR in Modern.js is very simple. Just add the PreRender component to your Here is a simulated component that uses the useLoaderData API. The request in the Data Loader takes 2 seconds to consume. -```tsx title="page.loader.ts" +```tsx title="page.data.ts" import { useLoaderData } from '@modern-js/runtime/router'; -export default async () => { +export const loader = async () => { await new Promise((resolve, reject) => { setTimeout(() => { resolve(null); @@ -307,9 +307,9 @@ export const loader = () => { The two methods mentioned above will both bring some mental burden to developers. In real business scenarios, we found that most of the mixed Node/Web code appears in data requests. -Therefore, Modern.js has designed a [Data Fetch](/guides/basic-features/data-fetch) to separate CSR and SSR code based on [Nested Routing](/guides/basic-features/routes). +Therefore, Modern.js has designed a [Data Fetch](/guides/basic-features/data/data-fetch) to separate CSR and SSR code based on [Nested Routing](/guides/basic-features/routes). -We can separate **data requests from component code** by using independent files. Write the component logic in `routes/page.tsx` and write the data request logic in `routes/page.loader.ts`. +We can separate **data requests from component code** by using independent files. Write the component logic in `routes/page.tsx` and write the data request logic in `routes/page.data.ts`. ```ts title="routes/page.tsx" export default Page = () => { @@ -317,9 +317,9 @@ export default Page = () => { } ``` -```ts title="routes/page.loader.tsx" +```ts title="routes/page.data.tsx" import fse from 'fs-extra'; -export default () => { +export const loader = () => { const file = fse.readFileSync('./myfile'); return { ... @@ -329,7 +329,7 @@ export default () => { ## Remote Request -When initiating interface requests in SSR, developers sometimes encapsulate isomorphic request tools themselves. For some interfaces that require passing user cookies, developers can use the ['useRuntimeContext'](/guides/basic-features/data-fetch#route-loader) API to get the request header for implementation. +When initiating interface requests in SSR, developers sometimes encapsulate isomorphic request tools themselves. For some interfaces that require passing user cookies, developers can use the ['useRuntimeContext'](/guides/basic-features/data/data-fetch#route-loader) API to get the request header for implementation. It should be noted that the obtained request header is for HTML requests, which may not be suitable for API requests. Therefore, ** don't passed through all request headers **. @@ -361,7 +361,7 @@ The streaming SSR of Modern.js is implemented based on React Router, and the mai ### Return async data -```ts title="page.loader.ts" +```ts title="page.data.ts" import { defer, type LoaderFunctionArgs } from '@modern-js/runtime/router'; interface User { @@ -373,7 +373,7 @@ export interface Data { data: User; } -export default ({ params }: LoaderFunctionArgs) => { +export const loader = ({ params }: LoaderFunctionArgs) => { const userId = params.id; const user = new Promise(resolve => { @@ -393,7 +393,7 @@ export default ({ params }: LoaderFunctionArgs) => { `defer` can receive both asynchronous and synchronous data at the same time. For example: -```ts title="page.loader.ts" +```ts title="page.data.ts" // skip some codes export default ({ params }: LoaderFunctionArgs) => { @@ -430,7 +430,7 @@ With the `` component, you can retrieve the data asynchronously returned ```tsx title="page.tsx" import { Await, useLoaderData } from '@modern-js/runtime/router'; import { Suspense } from 'react'; -import type { Data } from './page.loader'; +import type { Data } from './page.data'; const Page = () => { const data = useLoaderData() as Data; @@ -461,7 +461,7 @@ export default Page; :::warning Warning When importing types from a Data Loader file, it is necessary to use the `import type` syntax to ensure that only type information is imported. This can avoid packaging Data Loader code into the bundle file of the front-end product. -Therefore, the import method here is: `import type { Data } from './page.loader'`; +Therefore, the import method here is: `import type { Data } from './page.data'`; ::: You can also retrieve asynchronous data returned by the Data Loader using `useAsyncValue`. For example: @@ -504,10 +504,10 @@ export default Page; The `errorElement` property of the `` component can be used to handle errors thrown when the Data Loader executes or when a child component renders. For example, we intentionally throw an error in the Data Loader function: -```ts title="page.loader.ts" +```ts title="page.data.ts" import { defer } from '@modern-js/runtime/router'; -export default () => { +export const loader = () => { const data = new Promise((resolve, reject) => { setTimeout(() => { reject(new Error('error occurs')); diff --git a/packages/document/main-doc/docs/en/guides/basic-features/data/_category_.json b/packages/document/main-doc/docs/en/guides/basic-features/data/_category_.json new file mode 100644 index 000000000000..b52051a66e81 --- /dev/null +++ b/packages/document/main-doc/docs/en/guides/basic-features/data/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Data solution", + "position": 3 +} diff --git a/packages/document/main-doc/docs/en/guides/basic-features/data-fetch.mdx b/packages/document/main-doc/docs/en/guides/basic-features/data/data-fetch.mdx similarity index 87% rename from packages/document/main-doc/docs/en/guides/basic-features/data-fetch.mdx rename to packages/document/main-doc/docs/en/guides/basic-features/data/data-fetch.mdx index 14c16f260336..d930b0a1a245 100644 --- a/packages/document/main-doc/docs/en/guides/basic-features/data-fetch.mdx +++ b/packages/document/main-doc/docs/en/guides/basic-features/data/data-fetch.mdx @@ -14,13 +14,19 @@ It should be noted that these APIs do not help applications initiate requests, b Modern.js recommends using [conventional routing](/guides/basic-features/routes) for routing management. Through Modern.js's [conventional (nested) routing](/guides/basic-features/routes#conventional-routing), each routing component (`layout.ts` or `page.ts`) can have a same-named `loader` file. The `loader` file needs to export a function that will be executed before the component is rendered to provide data for the routing component. :::info -Modern.js v1 supports fetching data via [useLoader](), which is no longer the recommended usage. We do not recommend mixing the two except during the migration process. +Modern.js v1 supports fetching data via [useLoader](), which is no longer the recommended usage. We do not recommend mixing the two except during the migration process. + +::: + +:::warning +- In the previous version, Modern.js Data Loader was defined in the `loader` file. In later versions, we recommend defining it in the `data` file, while we will maintain compatibility with the `loader` file. +- In the `data` file, the corresponding `loader` needs to be exported with a name。 ::: ### Basic Example -Routing components such as `layout.ts` or `page.ts` can define a same-named `loader` file. The function exported by the `loader` file provides the data required by the component, and then the data is obtained in the routing component through the `useLoaderData` function, as shown in the following example: +Routing components such as `layout.ts` or `page.ts` can define a same-named `loader` file. The function exported by the `data` file provides the data required by the component, and then the data is obtained in the routing component through the `useLoaderData` function, as shown in the following example: ```bash . @@ -28,16 +34,16 @@ Routing components such as `layout.ts` or `page.ts` can define a same-named `loa ├── layout.tsx └── user ├── layout.tsx - ├── layout.loader.ts + ├── layout.data.ts ├── page.tsx - └── page.loader.ts + └── page.data.ts ``` Define the following code in the file: ```ts title="routes/user/page.tsx" import { useLoaderData } from '@modern-js/runtime/router'; -import type { ProfileData } from './page.loader.ts'; +import type { ProfileData } from './page.data.ts'; export default function UserPage() { const profileData = useLoaderData() as ProfileData; @@ -45,19 +51,19 @@ export default function UserPage() { } ``` -```ts title="routes/user/page.loader.ts" +```ts title="routes/user/page.data.ts" export type ProfileData = { /* some types */ }; -export default async (): Promise => { +export const loader = async (): Promise => { const res = await fetch('https://api/user/profile'); return await res.json(); }; ``` :::caution -Here, routing components and `loader` files share a type, so the `import type` syntax should be used. +Here, routing components and `data` files share a type, so the `import type` syntax should be used. ::: @@ -81,7 +87,7 @@ The `loader` function has two input parameters: When the route file is accessed through `[]`, it is used as [dynamic routing](/guides/basic-features/routes#dynamic-routing), and the dynamic routing fragment is passed as a parameter to the `loader` function: ```tsx -// routes/user/[id]/page.loader.tsx +// routes/user/[id]/page.data.tsx import { LoaderFunctionArgs } from '@modern-js/runtime/router'; export default async ({ params }: LoaderFunctionArgs) => { @@ -100,10 +106,10 @@ When accessing `/user/123`, the parameter of the `loader` function is `{ params: A common usage scenario is to get query parameters through `request`: ```tsx -// routes/user/[id]/page.loader.ts +// routes/user/[id]/page.data.ts import { LoaderFunctionArgs } from '@modern-js/runtime/router'; -export default async ({ request }: LoaderFunctionArgs) => { +export const loader = async ({ request }: LoaderFunctionArgs) => { const url = new URL(request.url); const userId = url.searchParams.get('id'); return queryUser(userId); @@ -152,8 +158,8 @@ function loader() { In the `loader` function, errors can be handled by throwing an `error` or a `response`. When an error is thrown in the `loader` function, Modern.js will stop executing the code in the current `loader` and switch the front-end UI to the defined [`ErrorBoundary`](/guides/basic-features/routes#error-handling) component: ```tsx -// routes/user/profile/page.loader.tsx -export default async function loader() { +// routes/user/profile/page.data.tsx +export const loader = async function loader() { const res = await fetch('https://api/user/profile'); if (!res.ok) { throw res; @@ -185,7 +191,7 @@ In many cases, child components need to access data in the parent component `loa import { useRouteLoaderData } from '@modern-js/runtime/router'; export default function UserLayout() { - // Get the data returned by the loader in routes/user/layout.loader.ts + // Get the data returned by the loader in routes/user/layout.data.ts const data = useRouteLoaderData('user/layout'); return (
@@ -219,12 +225,12 @@ If you want to get the data returned by the `loader` in `entry1/routes/layout.ts This feature is currently experimental and the API may change in the future. ::: -Create `user/layout.loader.ts` and add the following code: +Create `user/layout.data.ts` and add the following code: -```ts title="routes/user/layout.loader.ts" +```ts title="routes/user/layout.data.ts" import { defer } from '@modern-js/runtime/router'; -const loader = () => +export const loader = () => defer({ userInfo: new Promise(resolve => { setTimeout(() => { @@ -236,7 +242,6 @@ const loader = () => }), }); -export default loader; ``` Add the following code in `user/layout.tsx`: @@ -286,24 +291,27 @@ Currently, there is no such restriction under CSR, but we strongly recommend tha ```ts // This won't work! -export default () => { - return { - user: {}, - method: () => {}, - }; +export const loader = async () => { + const res = fetch('https://api/user/profile'); + return res.json(); }; + +import { loader } from './page.data.ts'; +export default function RouteComp() { + const data = loader(); +} ``` 2. Modern.js will call the `loader` function for you, so you should not call the `loader` function yourself: ```tsx // This won't work! -export default async () => { +export const loader = async () => { const res = fetch('https://api/user/profile'); return res.json(); }; -import loader from './page.loader.ts'; +import { loader } from './page.data.ts'; export default function RouteComp() { const data = loader(); } @@ -315,7 +323,7 @@ export default function RouteComp() { // Not allowed // routes/layout.tsx import { useLoaderData } from '@modern-js/runtime/router'; -import { ProfileData } from './page.loader.ts'; // should use "import type" instead +import { ProfileData } from './page.data.ts'; // should use "import type" instead export const fetch = wrapFetch(fetch); @@ -324,7 +332,7 @@ export default function UserPage() { return
{profileData}
; } -// routes/layout.loader.ts +// routes/layout.data.ts import { fetch } from './layout.tsx'; // should not be imported from the routing component export type ProfileData = { /* some types */ diff --git a/packages/document/main-doc/docs/en/guides/basic-features/data/data-write.mdx b/packages/document/main-doc/docs/en/guides/basic-features/data/data-write.mdx new file mode 100644 index 000000000000..c4827b65b00f --- /dev/null +++ b/packages/document/main-doc/docs/en/guides/basic-features/data/data-write.mdx @@ -0,0 +1,241 @@ +--- +title: Data writing +sidebar_position: 4 +--- + +# Data writing + +In the Data Loader chapter, the way Modern.js fetch data is introduced. You may encounter two problems.: +1. How to update the data in Data Loader? +2. How to write new data to the server? + +EdenX's solution for this is DataAction. + +## Basic Example + +Data Action, like Data Loader, is also based on convention routing. Through Modern.js's [nested routing](/guides/basic-features/routes#routing-file-convention), each routing component (`layout.ts`, `page.ts` or `$.tsx`) can have a `data` file with the same name, and a function named `action` can be exported in the `data` file. +```bash +. +└── routes + └── user + ├── layout.tsx + └── layout.data.ts +``` +Define the following code in the file: +```ts title="routes/user/layout.data.ts" +import type { ActionFunction } from '@modern-js/runtime/router'; + +export const action: ActionFunction = ({ request }) => { + const newUser = await request.json(); + const name = newUser.name; + return updateUserProfile(name); +} +``` + +```tsx title="routes/user/layout.tsx" +import { + useFetcher, + useLoaderData, + useParams, + Outlet +} from '@modern-js/runtime/router'; + +export default () => { + const userInfo = useLoaderData(); + const { submit } = useFetcher(); + const editUser = () => { + const newUser = { + name: 'Modern.js' + } + return submit(newUser, { + method: 'post', + encType: 'application/json', + }) + } + return ( +
+ +
+ {userInfo} +
+ +
+ ) +} +``` + +Here, when the submit is executed, the defined action function will be triggered; in the action function, the submitted data can be obtained through request (request.json, request.formData, etc.), and the data can be obtained, and then the data can be sent to the server. + +After the action function is executed, the loader function code will be executed and the corresponding data and views will be updated. + +![action flow](https://lf3-static.bytednsdoc.com/obj/eden-cn/ulkl/ljhwZthlaukjlkulzlp/action-flow.png) + + + +## Why provide Data Action? + +Data Action is mainly provided in Modern.js to keep the state of the UI and the server in sync, which can reduce the burden of state management. + +The traditional state management method will hold the state on the client side and remotely respectively:: + +![traditional state manage](https://lf3-static.bytednsdoc.com/obj/eden-cn/ulkl/ljhwZthlaukjlkulzlp/action-state-manage.png) + +In Modern.js, we hope to help developers automatically synchronize the state of the client and server through Loader and Action:: + +![state manage](https://lf3-static.bytednsdoc.com/obj/eden-cn/ulkl/ljhwZthlaukjlkulzlp/action-state-manage1.png) + +If the data shared by the components in the project are the state of the main server, there is no need to introduce a client state management library in the project, request data through Data Loader, through [`useRouteLoaderData`](/guides/basic-fe Atures/data/data-fetch.md) shares data in subcomponents, + +Modify and synchronize the state of the server through Data Action. + + + +## `action` function + +Like the `loader` function, the `action` function has two parameters, `params` and `request`: + +### `params` + +When the routing file passes through `[]`, it will be used as [dynamic routing](/guides/basic-features/routes#dynamic routing), and the dynamic routing fragment will be passed into the `action` function as a parameter:: + +```tsx +// routes/user/[id]/page.data.ts +import { ActionFunctionArgs } from '@modern-js/runtime/router'; + +export const action = async ({ params }: ActionFunctionArgs) => { + const { id } = params; + const res = await fetch(`https://api/user/${id}`); + return res.json(); +}; +``` + +When accessing `/user/123`, the parameter of the `action` function is `{ params: { id: '123' } }`. + + +### `request` + +Through `request`, you can fetch data submitted by the client in the action function, such as `request.json()`, `request.formData()`, `request.json()`, etc. + +For the specific API, please refer to [data type] (#data-type). + +```tsx +// routes/user/[id]/page.data.ts +import { ActionFunctionArgs } from '@modern-js/runtime/router'; + +export const action = async ({ request }: ActionFunctionArgs) => { + const newUser = await request.json(); + return updateUser(newUser); +}; +``` + +### Return Value + +The return value of the `action` function can be any serializable value or a [Fetch Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) instance, + +The data in the response can be accessed through [`useActionData`](https://reactrouter.com/en/main/hooks/use-action-data). + + +## useSubmit 和 useFetcher + +### Differences + +You can use [`useSubmit`](https://reactrouter.com/en/main/hooks/use-submit) or [`useFetcher`](https://reactrouter.com/en/main/hooks/use-fetcher) calls action, and the difference between them is through + +`useSubmit` calls action, which will trigger the browser's navigation, and `useFetcher` will not trigger the browser's navigation. + +useSubmit: + +```ts +const submit = useSubmit(); +submit(null, { method: "post", action: "/logout" }); +``` + +useFetcher: +```ts +const { submit } = useFetcher(); +submit(null, { method: "post", action: "/logout" }); +``` + +The `submit` function has two input parameters, `method` and `action`. `method` is equivalent to `method` at the time of form submission. In most scenarios where data is written,the `method` can be passed into `post`. + +`action` is used to specify which routing component `action` is triggered. If the `action` parameter is not passed in, the action of the current routing component will be triggered by default, that is, +the execution of submit in the `user/page.tsx` component or subcomponent will trigger the action defined in `user/page.data.ts`. + + +:::info +For more information about these two APIs, please refer to the relevant documents: +- [`useSubmit`](https://reactrouter.com/en/main/hooks/use-submit) +- [`useFetcher`](https://reactrouter.com/en/main/hooks/use-fetcher) + +::: + + +### Type of data + +The first parameter of the `submit` function can accept different types of values. +Such as `FormData`: +```ts +let formData = new FormData(); +formData.append("cheese", "gouda"); +submit(formData); +// In the action, you can get the data by request.json +``` + +Or the value of type `URLSearchParams`: +```ts +let searchParams = new URLSearchParams(); +searchParams.append("cheese", "gouda"); +submit(searchParams); +// In the action, you can get the data by request.json +``` + +Or any acceptable value of the `URLSearchParams` constructor: +```ts +submit("cheese=gouda&toasted=yes"); +submit([ + ["cheese", "gouda"], + ["toasted", "yes"], +]); +// In the action, you can get the data by request.json +``` + +By default, if the first parameter in the `submit` function is an object, the corresponding data will be encoded as `formData`: + +```ts +submit( + { key: "value" }, + { + method: "post", + encType: "application/x-www-form-urlencoded", + } +); + +// In the action, you can get the data by request.formData +``` + +it can also be specified as json encoding: + +```tsx +submit( + { key: "value" }, + { method: "post", encType: "application/json" } +); + +submit('{"key":"value"}', { + method: "post", + encType: "application/json", +}); + +// In the action, you can get the data by request.json +``` + +or submit plain text: +```ts +submit("value", { method: "post", encType: "text/plain" }); +// In the action, you can get the data by request.text +``` + + +## CSR 和 SSR + +Like Data Loader, in the SSR project, Data Action is executed on the server (the framework will automatically send a request to trigger Data Action), while in the CSR project, Data Action is executed on the client. diff --git a/packages/document/main-doc/docs/en/guides/basic-features/routes.mdx b/packages/document/main-doc/docs/en/guides/basic-features/routes.mdx index 5c2882e2c21e..a1b868f80d81 100644 --- a/packages/document/main-doc/docs/en/guides/basic-features/routes.mdx +++ b/packages/document/main-doc/docs/en/guides/basic-features/routes.mdx @@ -186,7 +186,7 @@ The `routes/[id]/page.tsx` file will be converted to the `/:id` route. Except fo In the component, you can use [useParams](/apis/app/runtime/router/router#useparams) to get the corresponding named parameter. -In the loader, params will be passed as the input parameter of the [loader function](/guides/basic-features/data-fetch#loader-function), and you can get the parameter value through `params.xxx`. +In the loader, params will be passed as the input parameter of the [loader function](/guides/basic-features/data/data-fetch#loader-function), and you can get the parameter value through `params.xxx`. ### Dynamic Optional Routing @@ -206,7 +206,7 @@ The `routes/user/[id$]/page.tsx` file will be converted to the `/user/:id?` rout In the component, you can use [useParams](/apis/app/runtime/router/router#useparams) to get the corresponding named parameter. -In the loader, params will be passed as the input parameter of the [loader function](/guides/basic-features/data-fetch#loader-function), and you can get the parameter value through `params.xxx`. +In the loader, params will be passed as the input parameter of the [loader function](/guides/basic-features/data/data-fetch#loader-function), and you can get the parameter value through `params.xxx`. ### Catch-all Routing @@ -351,12 +351,12 @@ Similarly, when the route jumps from `/` or `/blog` to `/blog/123`, if the JS Ch ### Redirect -You can use a [Data Loader](/guides/basic-features/data-fetch) file to redirect a route. For example, if you have a `routes/user/page.tsx` file and want to redirect the corresponding route, you can create a `routes/user/page.loader.ts` file: +You can use a [Data Loader](/guides/basic-features/data/data-fetch) file to redirect a route. For example, if you have a `routes/user/page.tsx` file and want to redirect the corresponding route, you can create a `routes/user/page.data.ts` file: -```ts title="routes/user/page.loader.ts" +```ts title="routes/user/page.data.ts" import { redirect } from '@modern-js/runtime/router'; -export default () => { +export const loader = () => { const user = await getUser(); if (!user) { return redirect('/login'); @@ -502,7 +502,7 @@ To further improve the user experience and reduce loading time, Modern.js suppor :::info - This feature is currently only supported in Webpack projects and not yet supported in Rspack projects. -- Preloading data currently only preloads the data returned by the [Data Loader](/guides/basic-features/data-fetch) in SSR projects. +- Preloading data currently only preloads the data returned by the [Data Loader](/guides/basic-features/data/data-fetch) in SSR projects. ::: diff --git a/packages/document/main-doc/docs/en/tutorials/first-app/c05-loader.mdx b/packages/document/main-doc/docs/en/tutorials/first-app/c05-loader.mdx index e77879b20c50..7aafaf4604b9 100644 --- a/packages/document/main-doc/docs/en/tutorials/first-app/c05-loader.mdx +++ b/packages/document/main-doc/docs/en/tutorials/first-app/c05-loader.mdx @@ -18,7 +18,7 @@ pnpm add faker@5 pnpm add @types/faker@5 -D ``` -Create `src/routes/page.loader.ts`: +Create `src/routes/page.data.ts`: ```tsx import { name, internet } from 'faker'; @@ -32,7 +32,7 @@ type LoaderData = { }[]; }; -export default async (): Promise => { +export const loader = async (): Promise => { const data = new Array(20).fill(0).map(() => { const firstName = name.firstName(); return { diff --git a/packages/document/main-doc/docs/en/tutorials/first-app/c06-model.mdx b/packages/document/main-doc/docs/en/tutorials/first-app/c06-model.mdx index c69f6a1caf39..9e02e2141544 100644 --- a/packages/document/main-doc/docs/en/tutorials/first-app/c06-model.mdx +++ b/packages/document/main-doc/docs/en/tutorials/first-app/c06-model.mdx @@ -195,9 +195,9 @@ const Item = ({ export default Item; ``` -Next, we add `src/routes.page.loader` and modify `src/routes/page.tsx` to pass more parameters to the `` component: +Next, we add `src/routes.page.data` and modify `src/routes/page.tsx` to pass more parameters to the `` component: -```tsx title="src/routes/page.loader.ts" +```tsx title="src/routes/page.data.ts" export type LoaderData = { code: number; data: { @@ -207,7 +207,7 @@ export type LoaderData = { }[]; }; -export default async (): Promise => { +export const loader = async (): Promise => { const data = new Array(20).fill(0).map(() => { const firstName = name.firstName(); return { @@ -233,7 +233,7 @@ import { List } from 'antd'; import { name, internet } from 'faker'; import Item from '../components/Item'; import contacts from '../models/contacts'; -import type { LoaderData } from './page.loader'; +import type { LoaderData } from './page.data'; function Index() { const { data } = useLoaderData() as LoaderData; diff --git a/packages/document/main-doc/docs/en/tutorials/first-app/c07-container.mdx b/packages/document/main-doc/docs/en/tutorials/first-app/c07-container.mdx index fd4327f14cc8..afb7447c034b 100644 --- a/packages/document/main-doc/docs/en/tutorials/first-app/c07-container.mdx +++ b/packages/document/main-doc/docs/en/tutorials/first-app/c07-container.mdx @@ -15,7 +15,7 @@ Because the two pages need to share the same set of state (point of contact tabu Modern.js support obtaining data through Data Loader in `layout.tsx`, we first move the data acquisition part of the code to `src/routes/layout.tsx`: -```ts title="src/routes/layout.loader.ts" +```ts title="src/routes/layout.data.ts" export type LoaderData = { code: number; data: { @@ -25,7 +25,7 @@ export type LoaderData = { }[]; }; -export default async (): Promise => { +export const loader = async (): Promise => { const data = new Array(20).fill(0).map(() => { const firstName = name.firstName(); return { @@ -58,7 +58,7 @@ import 'tailwindcss/base.css'; import 'tailwindcss/components.css'; import 'tailwindcss/utilities.css'; import '../styles/utils.css'; -import type { LoaderData } from './layout.loader'; +import type { LoaderData } from './layout.data'; export default function Layout() { const { data } = useLoaderData() as LoaderData; diff --git a/packages/document/main-doc/docs/zh/apis/app/hooks/src/routes.mdx b/packages/document/main-doc/docs/zh/apis/app/hooks/src/routes.mdx index 042c30c4f050..c6033284e055 100644 --- a/packages/document/main-doc/docs/zh/apis/app/hooks/src/routes.mdx +++ b/packages/document/main-doc/docs/zh/apis/app/hooks/src/routes.mdx @@ -54,7 +54,7 @@ sidebar_position: 2 在组件中,可以通过 [useParams](/apis/app/runtime/router/router#useparams) 获取对应命名的参数。 -在使用 [loader](/guides/basic-features/data-fetch#loader-函数) 函数获取数据时,`params` 会作为 `loader` 函数的入参,通过 `params` 的属性可以获取到对应的参数。 +在使用 [loader](/guides/basic-features/data/data-fetch#loader-函数) 函数获取数据时,`params` 会作为 `loader` 函数的入参,通过 `params` 的属性可以获取到对应的参数。 ## 布局组件 diff --git a/packages/document/main-doc/docs/zh/guides/advanced-features/rspack-start.mdx b/packages/document/main-doc/docs/zh/guides/advanced-features/rspack-start.mdx index 3b77f267fb5b..918498bc9ad7 100644 --- a/packages/document/main-doc/docs/zh/guides/advanced-features/rspack-start.mdx +++ b/packages/document/main-doc/docs/zh/guides/advanced-features/rspack-start.mdx @@ -26,7 +26,7 @@ import InitRspackApp from '@site-docs/components/init-rspack-app'; 在使用 Rspack 作为打包工具时,由于部分能力尚在开发中,以下 features 暂时无法使用,我们将在未来提供支持: - Storybook 调试 -- 客户端渲染(CSR)使用 [useLoader](/guides/basic-features/data-fetch.html) +- 客户端渲染(CSR)使用 [useLoader](/guides/basic-features/data/data-fetch.html) ::: diff --git a/packages/document/main-doc/docs/zh/guides/advanced-features/ssr.mdx b/packages/document/main-doc/docs/zh/guides/advanced-features/ssr.mdx index e27041f50e17..6a162157eda8 100644 --- a/packages/document/main-doc/docs/zh/guides/advanced-features/ssr.mdx +++ b/packages/document/main-doc/docs/zh/guides/advanced-features/ssr.mdx @@ -22,8 +22,8 @@ export default defineConfig({ Modern.js 中提供了 Data Loader,方便开发者在 SSR、CSR 下同构的获取数据。每个路由模块,如 `layout.tsx` 和 `page.tsx` 都可以定义自己的 Data Loader: -```ts title="src/routes/page.loader.ts" -export default () => { +```ts title="src/routes/page.data.ts" +export const loader = () => { return { message: 'Hello World', }; @@ -48,7 +48,7 @@ Modern.js 打破传统的 SSR 开发模式,提供了用户无感的 SSR 开发 1. 当以客户端路由的方式请求页面时,Modern.js 会发送一个 HTTP 请求,服务端接收到请求后执行页面对应的 Data Loader 函数,然后将执行结果作为请求的响应返回浏览器。 -2. 使用 Data Loader 时,数据获取发生在渲染前,Modern.js 也仍然支持在组件渲染时获取数据。更多相关内容可以查看[数据获取](/guides/basic-features/data-fetch)。 +2. 使用 Data Loader 时,数据获取发生在渲染前,Modern.js 也仍然支持在组件渲染时获取数据。更多相关内容可以查看[数据获取](/guides/basic-features/data/data-fetch)。 ::: @@ -147,8 +147,8 @@ SPR 利用预渲染与缓存技术,为 SSR 页面提供静态 Web 的响应性 这里模拟一个使用 `useLoaderData` API 的组件,Data Loader 中的请求需要消耗 2s 时间。 -```tsx title="page.loader.ts" -export default async () => { +```tsx title="page.data.ts" +export const loader = async () => { await new Promise((resolve, reject) => { setTimeout(() => { resolve(null); @@ -297,9 +297,9 @@ export const loader = () => { 上述两种方式,都会为开发者带来一些心智负担。在真实的业务中,我们发现大多数的 Node / Web 代码混用都出现在数据请求中。 -因此,Modern.js 基于[嵌套路由](/guides/basic-features/routes)开发设计了[更简单的方案](/guides/basic-features/data-fetch)来分离 CSR 和 SSR 的代码。 +因此,Modern.js 基于[嵌套路由](/guides/basic-features/routes)开发设计了[更简单的方案](/guides/basic-features/data/data-fetch)来分离 CSR 和 SSR 的代码。 -我们可以通过独立文件来分离**数据请求**与**组件代码**。在 `routes/page.tsx` 中编写组件逻辑,在 `routes/page.loader.ts` 中编写数据请求逻辑。 +我们可以通过独立文件来分离**数据请求**与**组件代码**。在 `routes/page.tsx` 中编写组件逻辑,在 `routes/page.data.ts` 中编写数据请求逻辑。 ```ts title="routes/page.tsx" export default Page = () => { @@ -307,9 +307,9 @@ export default Page = () => { } ``` -```ts title="routes/page.loader.tsx" +```ts title="routes/page.data.tsx" import fse from 'fs-extra'; -export default () => { +export const loader = () => { const file = fse.readFileSync('./myfile'); return { ... @@ -319,7 +319,7 @@ export default () => { ## 接口请求 -在 SSR 中发起接口请求时,开发者有时自己封装了同构的请求工具。部分接口需要传递用户 Cookie,开发者可以通过 [`useRuntimeContext`](/guides/basic-features/data-fetch#route-loader) API 获取到请求头来实现。 +在 SSR 中发起接口请求时,开发者有时自己封装了同构的请求工具。部分接口需要传递用户 Cookie,开发者可以通过 [`useRuntimeContext`](/guides/basic-features/data/data-fetch#route-loader) API 获取到请求头来实现。 需要注意的是,此时获取到的是 HTML 请求的请求头,不一定适用于接口请求,因此**千万不能**透传所有请求头。并且,一些后端接口,或是通用网关,会根据请求头中的信息做校验,全量透传容易出现各种难以排查的问题,推荐**按需透传**。 @@ -349,7 +349,7 @@ Modern.js 的流式渲染基于 React Router 实现,主要涉及 API 有: ### 异步获取数据 -```ts title="page.loader.ts" +```ts title="page.data.ts" import { defer, type LoaderFunctionArgs } from '@modern-js/runtime/router'; interface User { @@ -361,7 +361,7 @@ export interface Data { data: User; } -export default ({ params }: LoaderFunctionArgs) => { +export const loader = ({ params }: LoaderFunctionArgs) => { const userId = params.id; const user = new Promise(resolve => { @@ -382,10 +382,10 @@ export default ({ params }: LoaderFunctionArgs) => { `defer` 还可以同时接收异步数据和同步数据。例如: -```ts title="page.loader.ts" +```ts title="page.data.ts" // 省略部分代码 -export default ({ params }: LoaderFunctionArgs) => { +export const loader = ({ params }: LoaderFunctionArgs) => { const userId = params.id; const user = new Promise(resolve => { @@ -419,7 +419,7 @@ export default ({ params }: LoaderFunctionArgs) => { ```tsx title="page.tsx" import { Await, useLoaderData } from '@modern-js/runtime/router'; import { Suspense } from 'react'; -import type { Data } from './page.loader'; +import type { Data } from './page.data'; const Page = () => { const data = useLoaderData() as Data; @@ -452,7 +452,7 @@ export default Page; :::warning 注意 从 Data Loader 文件导入类型时,需要使用 import type 语法,保证只导入类型信息,这样可以避免 Data Loader 的代码打包到前端产物的 bundle 文件中。 -所以,这里的导入方式为:`import type { Data } from './page.loader'`; +所以,这里的导入方式为:`import type { Data } from './page.data'`; ::: diff --git a/packages/document/main-doc/docs/zh/guides/basic-features/data/_category_.json b/packages/document/main-doc/docs/zh/guides/basic-features/data/_category_.json new file mode 100644 index 000000000000..2f5f663fcc1b --- /dev/null +++ b/packages/document/main-doc/docs/zh/guides/basic-features/data/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "数据管理", + "position": 3 +} diff --git a/packages/document/main-doc/docs/zh/guides/basic-features/data-fetch.mdx b/packages/document/main-doc/docs/zh/guides/basic-features/data/data-fetch.md similarity index 87% rename from packages/document/main-doc/docs/zh/guides/basic-features/data-fetch.mdx rename to packages/document/main-doc/docs/zh/guides/basic-features/data/data-fetch.md index a5a6e81b2bd2..e31a31cf16fb 100644 --- a/packages/document/main-doc/docs/zh/guides/basic-features/data-fetch.mdx +++ b/packages/document/main-doc/docs/zh/guides/basic-features/data/data-fetch.md @@ -11,16 +11,22 @@ Modern.js 中提供了开箱即用的数据获取能力,开发者可以通过 ## Data Loader(推荐) -Modern.js 推荐使用约定式路由做路由的管理,通过 Modern.js 的[约定式(嵌套)路由](/guides/basic-features/routes#约定式路由),每个路由组件(`layout.ts` 或 `page.ts`)可以有一个同名的 `loader` 文件,该 `loader` 文件需要导出一个函数,函数会在组件渲染之前执行,为路由组件提供数据。 +Modern.js 推荐使用约定式路由做路由的管理,通过 Modern.js 的[约定式(嵌套)路由](/guides/basic-features/routes#约定式路由),每个路由组件(`layout.ts` 或 `page.ts`)可以有一个同名的 `data` 文件,该 `data` 文件可以导出一个 `loader` 函数,函数会在组件渲染之前执行,为路由组件提供数据。 :::info Modern.js v1 支持通过 [useLoader](#useloader(旧版)) 获取数据,这已经不是我们推荐的用法,除迁移过程外,不推荐两者混用。 ::: +:::warning +- 在之前的版本中,Modern.js Data Loader 是定义在 `loader` 文件中的,在之后的版本中,我们推荐定义在 `data` 文件中,同时我们会保持对 `loader` 文件的兼容。 +- 在 `data` 文件中,对应的 `loader` 需要具名导出。 + +::: + ### 基础示例 -路由组件如 `layout.ts` 或 `page.ts`,可以定义同名的 `loader` 文件,`loader` 文件中导出一个函数,该函数提供组件所需的数据,然后在路由组件中通过 `useLoaderData` 函数获取数据,如下面示例: +路由组件如 `layout.ts` 或 `page.ts`,可以定义同名的 `data` 文件,`data` 文件中导出一个 `loader` 函数,该函数提供组件所需的数据,然后在路由组件中通过 `useLoaderData` 函数获取数据,如下面示例: ```bash . @@ -28,16 +34,16 @@ Modern.js v1 支持通过 [useLoader](#useloader(旧版)) 获取数据,这 ├── layout.tsx └── user ├── layout.tsx - ├── layout.loader.ts + ├── layout.data.ts ├── page.tsx - └── page.loader.ts + └── page.data.ts ``` 在文件中定义以下代码: ```ts title="routes/user/page.tsx" import { useLoaderData } from '@modern-js/runtime/router'; -import type { ProfileData } from './page.loader.ts'; +import type { ProfileData } from './page.data.ts'; export default function UserPage() { const profileData = useLoaderData() as ProfileData; @@ -45,19 +51,19 @@ export default function UserPage() { } ``` -```ts title="routes/user/page.loader.ts" +```ts title="routes/user/page.data.ts" export type ProfileData = { /* some types */ }; -export default async (): Promise => { +export const loader = async (): Promise => { const res = await fetch('https://api/user/profile'); return await res.json(); }; ``` :::caution -这里路由组件和 `loader` 文件共享类型,要使用 `import type` 语法。 +这里路由组件和 `data` 文件共享类型,要使用 `import type` 语法。 ::: @@ -81,10 +87,10 @@ export default async (): Promise => { 当路由文件通过 `[]` 时,会作为[动态路由](/guides/basic-features/routes#动态路由),动态路由片段会作为参数传入 `loader` 函数: ```tsx -// routes/user/[id]/page.loader.ts +// routes/user/[id]/page.data.ts import { LoaderFunctionArgs } from '@modern-js/runtime/router'; -export default async ({ params }: LoaderFunctionArgs) => { +export const loader = async ({ params }: LoaderFunctionArgs) => { const { id } = params; const res = await fetch(`https://api/user/${id}`); return res.json(); @@ -100,10 +106,10 @@ export default async ({ params }: LoaderFunctionArgs) => { 一个常见的使用场景是通过 `request` 获取查询参数: ```tsx -// routes/user/[id]/page.loader.ts +// routes/user/[id]/page.data.ts import { LoaderFunctionArgs } from '@modern-js/runtime/router'; -export default async ({ request }: LoaderFunctionArgs) => { +export const loader = async ({ request }: LoaderFunctionArgs) => { const url = new URL(request.url); const userId = url.searchParams.get('id'); return queryUser(userId); @@ -142,7 +148,7 @@ const loader = async (): Promise => { Modern.js 对 `fetch` API 做了 polyfill, 用于发起请求,该 API 与浏览器的 `fetch` API 一致,但是在服务端也能使用该 API 发起请求,这意味着不管是 CSR 还是 SSR,都可以使用统一的 `fetch` API 进行数据获取: ```tsx -async function loader() { +export async function loader() { const res = await fetch('https://api/user/profile'); } ``` @@ -152,8 +158,8 @@ async function loader() { 在 `loader` 函数中,可以通过 `throw error` 或者 `throw response` 的方式处理错误,当 `loader` 函数中有错误被抛出时,Modern.js 会停止执行当前 `loader` 中的代码,并将前端 UI 切换到定义的 [`ErrorBoundary`](/guides/basic-features/routes#错误处理) 组件: ```tsx -// routes/user/profile/page.loader.ts -export default async function loader() { +// routes/user/profile/page.data.ts +export async function loader() { const res = await fetch('https://api/user/profile'); if (!res.ok) { throw res; @@ -184,8 +190,8 @@ export default ErrorBoundary; // routes/user/profile/page.tsx import { useRouteLoaderData } from '@modern-js/runtime/router'; -export default function UserLayout() { - // 获取 routes/user/layout.loader.ts 中 `loader` 返回的数据 +export function UserLayout() { + // 获取 routes/user/layout.data.ts 中 `loader` 返回的数据 const data = useRouteLoaderData('user/layout'); return (
@@ -220,12 +226,12 @@ export default function UserLayout() { ::: -创建 `user/layout.loader.ts`,并添加以下代码: +创建 `user/layout.data.ts`,并添加以下代码: -```ts title="routes/user/layout.loader.ts" +```ts title="routes/user/layout.data.ts" import { defer } from '@modern-js/runtime/router'; -const loader = () => +export const loader = () => defer({ userInfo: new Promise(resolve => { setTimeout(() => { @@ -236,8 +242,6 @@ const loader = () => }, 1000); }), }); - -export default loader; ``` 在 `user/layout.tsx` 中添加以下代码: @@ -298,12 +302,12 @@ export default () => { ```ts // This won't work! -export default async () => { +export const loader = async () => { const res = fetch('https://api/user/profile'); return res.json(); }; -import loader from './page.loader.ts'; +import { loader } from './page.data.ts'; export default function RouteComp() { const data = loader(); } @@ -315,7 +319,7 @@ export default function RouteComp() { // Not allowed // routes/layout.tsx import { useLoaderData } from '@modern-js/runtime/router'; -import { ProfileData } from './page.loader.ts'; // should use "import type" instead +import { ProfileData } from './page.data.ts'; // should use "import type" instead export const fetch = wrapFetch(fetch); @@ -324,13 +328,13 @@ export default function UserPage() { return
{profileData}
; } -// routes/layout.loader.ts +// routes/layout.data.ts import { fetch } from './layout.tsx'; // should not be imported from the routing component export type ProfileData = { /* some types */ }; -export default async (): Promise => { +export const loader = async (): Promise => { const res = await fetch('https://api/user/profile'); return await res.json(); }; diff --git a/packages/document/main-doc/docs/zh/guides/basic-features/data/data-write.mdx b/packages/document/main-doc/docs/zh/guides/basic-features/data/data-write.mdx new file mode 100644 index 000000000000..4c0e44540fa1 --- /dev/null +++ b/packages/document/main-doc/docs/zh/guides/basic-features/data/data-write.mdx @@ -0,0 +1,236 @@ +--- +title: 数据写入 +sidebar_position: 4 +--- + +# 数据写入 + +在 Data Loader 章节中,介绍了 Modern.js 获取数据的方式,你可能会遇到两个问题: +1. 如何更新 Data Loader 中的数据? +2. 如何将新的数据传递到服务器? + +EdenX 对此的解决方案是 DataAction。 + +## 基本示例 + +Data Action 和 Data Loader 一样,也是基于约定式路由的,通过 Modern.js 的[约定式(嵌套)路由](/guides/basic-features/routes#约定式路由),每个路由组件(`layout.ts`,`page.ts` 或 `$.tsx`)可以有一个同名的 `data` 文件,`data` 文件中可以导出一个命名为 `action` 的函数。 +```bash +. +└── routes + └── user + ├── layout.tsx + └── layout.data.ts +``` +在文件中定义以下代码: +```ts title="routes/user/layout.data.ts" +import type { ActionFunction } from '@modern-js/runtime/router'; + +export const action: ActionFunction = ({ request }) => { + const newUser = await request.json(); + const name = newUser.name; + return updateUserProfile(name); +} +``` + +```tsx title="routes/user/layout.tsx" +import { + useFetcher, + useLoaderData, + useParams, + Outlet +} from '@modern-js/runtime/router'; + +export default () => { + const userInfo = useLoaderData(); + const { submit } = useFetcher(); + const editUser = () => { + const newUser = { + name: 'Modern.js' + } + return submit(newUser, { + method: 'post', + encType: 'application/json', + }) + } + return ( +
+ +
+ {userInfo} +
+ +
+ ) +} +``` + +这里当执行 submit 后,会触发定义的 action 函数;在 action 函数中,可以通过 request (request.json,request.formData)获取提交的数据,获取数据后,再发送数据到服务端。 + +而 action 函数执行完,会执行 loader 函数代码,并更新对应的数据和视图。 + +![action flow](https://lf3-static.bytednsdoc.com/obj/eden-cn/ulkl/ljhwZthlaukjlkulzlp/action-flow.png) + + + +## 为什么要提供 Data Action + +Modern.js 中提供 Data Action 主要是为了使 UI 和服务器的状态能保持同步,通过这种方式可以减少状态管理的负担, +传统的状态管理方式,会在客户端和远程分别持有状态: + +![traditional state manage](https://lf3-static.bytednsdoc.com/obj/eden-cn/ulkl/ljhwZthlaukjlkulzlp/action-state-manage.png) + +而在 Modern.js 中,我们希望通过 Loader 和 Action 帮助开发者自动的同步客户端和服务端的状态: + +![state manage](https://lf3-static.bytednsdoc.com/obj/eden-cn/ulkl/ljhwZthlaukjlkulzlp/action-state-manage1.png) + +如果项目中组件共享的数据主要服务端的状态,则无需在项目引入客户端状态管理库,使用 Data Loader 请求数据,通过 [`useRouteLoaderData`](/guides/basic-features/data/data-fetch.md) 在子组件中共享数据, +通过 Data Actino 修改和同步服务端的状态。 + + + +## `action` 函数 + +与 `loader` 函数一样,`action` 函数有两个入参,`params` 和 `request`: + +### `params` + +当路由文件通过 `[]` 时,会作为[动态路由](/guides/basic-features/routes#动态路由),动态路由片段会作为参数传入 `action` 函数: + +```tsx +// routes/user/[id]/page.data.ts +import { ActionFunctionArgs } from '@modern-js/runtime/router'; + +export const action = async ({ params }: ActionFunctionArgs) => { + const { id } = params; + const res = await fetch(`https://api/user/${id}`); + return res.json(); +}; +``` + +当访问 `/user/123` 时,`action` 函数的参数为 `{ params: { id: '123' } }`。 + + +### `request` + +`request` 是一个 [Fetch Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) 实例。 + +通过 `request`,可以在 action 函数中获取到客户端提交的数据,如 `request.json()`,`request.formData()`,`request.json()` 等, +具体应该使用哪个 API,请参考[数据类型](#数据类型)。 + +```tsx +// routes/user/[id]/page.data.ts +import { ActionFunctionArgs } from '@modern-js/runtime/router'; + +export const action = async ({ request }: ActionFunctionArgs) => { + const newUser = await request.json(); + return updateUser(newUser); +}; +``` + +### 返回值 + +`action` 函数的返回值可以是任何可序列化的内容,也可以是一个 [Fetch Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) 实例, +可以通过 [`useActionData`](https://reactrouter.com/en/main/hooks/use-action-data) 访问 response 中内容。 + + +## useSubmit 和 useFetcher + +### 区别 + +你可以通过 [`useSubmit`](https://reactrouter.com/en/main/hooks/use-submit) 或 [`useFetcher`](https://reactrouter.com/en/main/hooks/use-fetcher) 调用 action,它们的区别是通过 +`useSubmit` 调用 action,会触发浏览器的导航,通过 `useFetcher` 则不会触发浏览器的导航。 + +useSubmit: + +```ts +const submit = useSubmit(); +submit(null, { method: "post", action: "/logout" }); +``` + +useFetcher: +```ts +const { submit } = useFetcher(); +submit(null, { method: "post", action: "/logout" }); +``` + +`submit` 函数有两个入参,`method` 和 `action`,`method` 相当于表单提交时的 `method`,大部分写入数据的场景下,`method` 可以传入 `post`, +`action` 用来指定触发哪个路由组件的 `action`,如果未传入 `action` 入参,默认会触发当前路由组件的 action,即 `user/page.tsx` 组件或子组件中执行 submit, +会触发 `user/page.data.ts` 中定义的 action。 + +:::info +这两个 API 更多的信息可参考相关文档: +- [`useSubmit`](https://reactrouter.com/en/main/hooks/use-submit) +- [`useFetcher`](https://reactrouter.com/en/main/hooks/use-fetcher) + +::: + + +### 数据类型 + +`submit` 函数的第一个入参,可以接受不同类型的值。 +如 `FormData`: +```ts +let formData = new FormData(); +formData.append("cheese", "gouda"); +submit(formData); +// In the action, you can get the data by request.json +``` + +或 `URLSearchParams` 类型的值: +```ts +let searchParams = new URLSearchParams(); +searchParams.append("cheese", "gouda"); +submit(searchParams); +// In the action, you can get the data by request.json +``` + +或任意 `URLSearchParams` 构造函数可接受的值 +```ts +submit("cheese=gouda&toasted=yes"); +submit([ + ["cheese", "gouda"], + ["toasted", "yes"], +]); +// In the action, you can get the data by request.json +``` + +默认情况下,如果 `submit` 函数中的第一个入参是一个对象,对应的数据会被 encode 为 `formData`: + +```ts +submit( + { key: "value" }, + { + method: "post", + encType: "application/x-www-form-urlencoded", + } +); + +// In the action, you can get the data by request.formData +``` + +也可以指定为 json 编码: + +```tsx +submit( + { key: "value" }, + { method: "post", encType: "application/json" } +); + +submit('{"key":"value"}', { + method: "post", + encType: "application/json", +}); + +// In the action, you can get the data by request.json +``` + +或提交纯文本: +```ts +submit("value", { method: "post", encType: "text/plain" }); +// In the action, you can get the data by request.text +``` + + +## CSR 和 SSR + +与 Data Loader 一样,SSR 项目中,Data Action 是在服务端执行的(框架会自动发请求触发 Data Action),而在 CSR 项目中,Data Action 是在客户端执行的。 diff --git a/packages/document/main-doc/docs/zh/guides/basic-features/routes.mdx b/packages/document/main-doc/docs/zh/guides/basic-features/routes.mdx index 14d458e55545..29ea477eeda1 100644 --- a/packages/document/main-doc/docs/zh/guides/basic-features/routes.mdx +++ b/packages/document/main-doc/docs/zh/guides/basic-features/routes.mdx @@ -189,7 +189,7 @@ export default () => { 在组件中,可以通过 [useParams](/apis/app/runtime/router/router#useparams) 获取对应命名的参数。 -在 loader 中,params 会作为 [loader](/guides/basic-features/data-fetch#loader-函数) 的入参,通过 `params.xxx` 可以获取。 +在 loader 中,params 会作为 [loader](/guides/basic-features/data/data-fetch#loader-函数) 的入参,通过 `params.xxx` 可以获取。 ### 动态可选路由 @@ -209,7 +209,7 @@ export default () => { 在组件中,可以通过 [useParams](/apis/app/runtime/router/router#useparams) 获取对应命名的参数。 -在 loader 中,params 会作为 [loader](/guides/basic-features/data-fetch#loader-函数) 的入参,通过 `params.xxx` 可以获取。 +在 loader 中,params 会作为 [loader](/guides/basic-features/data/data-fetch#loader-函数) 的入参,通过 `params.xxx` 可以获取。 ### 通配路由 @@ -352,12 +352,12 @@ Modern.js 建议必须有根 Layout 和根 Loading。 ### 路由重定向 -可以通过创建 [`Data Loader`](/guides/basic-features/data-fetch) 文件做路由的重定向,如有文件 `routes/user/page.tsx`,想对这个文件对应的路由做重定向,可以创建 `routes/user/page.loader.ts` 文件: +可以通过创建 [`Data Loader`](/guides/basic-features/data/data-fetch) 文件做路由的重定向,如有文件 `routes/user/page.tsx`,想对这个文件对应的路由做重定向,可以创建 `routes/user/page.data.ts` 文件: -```ts title="routes/user/page.loader.ts" +```ts title="routes/user/page.data.ts" import { redirect } from '@modern-js/runtime/router'; -export default () => { +export const loader () => { const user = await getUser(); if (!user) { return redirect('/login'); @@ -504,7 +504,7 @@ export const init = (context: RuntimeContext) => { :::info - 该功能目前仅在 Webpack 项目中支持,Rspack 项目暂不支持。 -- 对数据的预加载目前只会预加载 SSR 项目中 [Data Loader](/guides/basic-features/data-fetch) 中返回的数据。 +- 对数据的预加载目前只会预加载 SSR 项目中 [Data Loader](/guides/basic-features/data/data-fetch) 中返回的数据。 ::: diff --git a/packages/document/main-doc/docs/zh/tutorials/first-app/c05-loader.mdx b/packages/document/main-doc/docs/zh/tutorials/first-app/c05-loader.mdx index d31f5a3b5f31..eb9ab66fa8e2 100644 --- a/packages/document/main-doc/docs/zh/tutorials/first-app/c05-loader.mdx +++ b/packages/document/main-doc/docs/zh/tutorials/first-app/c05-loader.mdx @@ -18,7 +18,7 @@ pnpm add faker@5 pnpm add @types/faker@5 -D ``` -创建 `src/routes/page.loader.ts`: +创建 `src/routes/page.data.ts`: ```tsx import { name, internet } from 'faker'; @@ -32,7 +32,7 @@ type LoaderData = { }[]; }; -export default async (): Promise => { +export const loader = async (): Promise => { const data = new Array(20).fill(0).map(() => { const firstName = name.firstName(); return { diff --git a/packages/document/main-doc/docs/zh/tutorials/first-app/c06-model.mdx b/packages/document/main-doc/docs/zh/tutorials/first-app/c06-model.mdx index b749bf74d52e..68bde2cc4a74 100644 --- a/packages/document/main-doc/docs/zh/tutorials/first-app/c06-model.mdx +++ b/packages/document/main-doc/docs/zh/tutorials/first-app/c06-model.mdx @@ -192,9 +192,9 @@ const Item = ({ export default Item; ``` -接下来,我们修改 `src/routes/page.tsx` 和 `src/routes/page.loader.ts`,为 `` 组件传递更多参数: +接下来,我们修改 `src/routes/page.tsx` 和 `src/routes/page.data.ts`,为 `` 组件传递更多参数: -```ts title="src/routes/page.loader.ts" +```ts title="src/routes/page.data.ts" export type LoaderData = { code: number; data: { @@ -204,7 +204,7 @@ export type LoaderData = { }[]; }; -export default async (): Promise => { +export const loader async (): Promise => { const data = new Array(20).fill(0).map(() => { const firstName = name.firstName(); return { diff --git a/packages/document/main-doc/docs/zh/tutorials/first-app/c07-container.mdx b/packages/document/main-doc/docs/zh/tutorials/first-app/c07-container.mdx index 097986f2dbfe..c1d2eb22c7c9 100644 --- a/packages/document/main-doc/docs/zh/tutorials/first-app/c07-container.mdx +++ b/packages/document/main-doc/docs/zh/tutorials/first-app/c07-container.mdx @@ -15,7 +15,7 @@ import { Tabs, Tab as TabItem } from "@theme"; Modern.js 支持在 `layout.tsx` 通过 Data Loader 获取数据,我们先数据获取这部分代码移动到 `src/routes/layout.tsx` 中: -```ts title="src/routes/layout.loader.ts" +```ts title="src/routes/layout.data.ts" export type LoaderData = { code: number; data: { @@ -25,7 +25,7 @@ export type LoaderData = { }[]; }; -export default async (): Promise => { +export const loader = async (): Promise => { const data = new Array(20).fill(0).map(() => { const firstName = name.firstName(); return { @@ -58,7 +58,7 @@ import 'tailwindcss/base.css'; import 'tailwindcss/components.css'; import 'tailwindcss/utilities.css'; import '../styles/utils.css'; -import type { LoaderData } from './layout.loader'; +import type { LoaderData } from './layout.data'; export default function Layout() { const { data } = useLoaderData() as LoaderData; diff --git a/packages/solutions/app-tools/src/analyze/constants.ts b/packages/solutions/app-tools/src/analyze/constants.ts index 2396929263ed..2f8a1f1f7047 100644 --- a/packages/solutions/app-tools/src/analyze/constants.ts +++ b/packages/solutions/app-tools/src/analyze/constants.ts @@ -12,6 +12,8 @@ export const FILE_SYSTEM_ROUTES_FILE_NAME = 'routes.js'; export const LOADER_EXPORT_NAME = 'loader'; +export const ACTION_EXPORT_NAME = 'action'; + export const TEMP_LOADERS_DIR = '__loaders__'; export const ENTRY_POINT_FILE_NAME = 'index.jsx'; diff --git a/packages/solutions/app-tools/src/analyze/nestedRoutes.ts b/packages/solutions/app-tools/src/analyze/nestedRoutes.ts index 1734342141ab..ad10aa148eaf 100644 --- a/packages/solutions/app-tools/src/analyze/nestedRoutes.ts +++ b/packages/solutions/app-tools/src/analyze/nestedRoutes.ts @@ -2,7 +2,7 @@ import * as path from 'path'; import { fs, normalizeToPosixPath } from '@modern-js/utils'; import type { NestedRouteForCli } from '@modern-js/types'; import { JS_EXTENSIONS, NESTED_ROUTE } from './constants'; -import { replaceWithAlias } from './utils'; +import { hasAction, replaceWithAlias } from './utils'; const conventionNames = Object.values(NESTED_ROUTE); @@ -152,12 +152,16 @@ export const walk = async ( let pageLoaderFile = ''; let pageRoute = null; + let pageConfigFile = ''; + let pageClientData = ''; + let pageData = ''; + let pageAction = ''; let splatLoaderFile = ''; + let splatRoute = null; + let splatConfigFile = ''; let splatClientData = ''; let splatData = ''; - let splatRoute: NestedRouteForCli | null = null; - let pageConfigFile = ''; - let splatConfigFile = ''; + let splatAction = ''; const items = await fs.readdir(dirname); @@ -202,6 +206,9 @@ export const walk = async ( if (itemWithoutExt === NESTED_ROUTE.LAYOUT_DATA_FILE) { route.data = itemPath; + if (await hasAction(itemPath)) { + route.action = itemPath; + } } if (itemWithoutExt === NESTED_ROUTE.LAYOUT_CONFIG_FILE) { @@ -219,11 +226,14 @@ export const walk = async ( } if (itemWithoutExt === NESTED_ROUTE.PAGE_CLIENT_LOADER) { - route.clientData = itemPath; + pageClientData = itemPath; } if (itemWithoutExt === NESTED_ROUTE.PAGE_DATA_FILE) { - route.data = itemPath; + pageData = itemPath; + if (await hasAction(itemPath)) { + pageAction = itemPath; + } } if (itemWithoutExt === NESTED_ROUTE.PAGE_CONFIG_FILE) { @@ -247,6 +257,15 @@ export const walk = async ( if (pageConfigFile) { pageRoute.config = pageConfigFile; } + if (pageData) { + pageRoute.data = pageData; + } + if (pageClientData) { + pageRoute.clientData = pageClientData; + } + if (pageAction) { + pageRoute.action = pageAction; + } route.children?.push(pageRoute); } @@ -266,6 +285,9 @@ export const walk = async ( if (itemWithoutExt === NESTED_ROUTE.SPLATE_DATA_FILE) { splatData = itemPath; + if (await hasAction(itemPath)) { + splatAction = itemPath; + } } if (itemWithoutExt === NESTED_ROUTE.SPLATE_FILE) { @@ -292,6 +314,9 @@ export const walk = async ( if (splatConfigFile) { splatRoute.config = splatConfigFile; } + if (splatAction) { + splatRoute.action = splatAction; + } route.children?.push(splatRoute); } @@ -350,6 +375,12 @@ export const walk = async ( } } + if (isRoot && !finalRoute._component) { + throw new Error( + 'The root layout component is required, make sure the routes/layout.tsx file exists.', + ); + } + if (isRoot && !oldVersion) { const optimizedRoutes = optimizeRoute(finalRoute); return optimizedRoutes; diff --git a/packages/solutions/app-tools/src/analyze/templates.ts b/packages/solutions/app-tools/src/analyze/templates.ts index 392b4264f6b3..376a38e75add 100644 --- a/packages/solutions/app-tools/src/analyze/templates.ts +++ b/packages/solutions/app-tools/src/analyze/templates.ts @@ -123,23 +123,51 @@ export const routesForServer = ({ routes: (NestedRouteForCli | PageRoute)[]; }) => { const loaders: string[] = []; + const actions: string[] = []; + const loadersMap: Record< + string, + { + routeId: string; + loaderId: number; + filePath: string; + clientData?: boolean; + inline: boolean; + route: NestedRouteForCli; + } + > = {}; const traverseRouteTree = (route: NestedRouteForCli | PageRoute): Route => { let children: Route['children']; if ('children' in route && route.children) { children = route?.children?.map(traverseRouteTree); } let loader: string | undefined; + let action: string | undefined; if (route.type === 'nested') { - if (route.loader) { + if (route.loader || route.data) { loaders.push(route.loader); - loader = `loader_${loaders.length - 1}`; + const loaderId = loaders.length - 1; + loader = `loader_${loaderId}`; + const inline = Boolean(route.data); + loadersMap[loader] = { + loaderId, + routeId: route.id!, + filePath: route.data || route.loader, + clientData: Boolean(route.clientData), + route, + inline, + }; + if (route.action) { + actions.push(route.action); + action = `action_${loaders.length - 1}`; + } } } const finalRoute = { ...route, loader, + action, children, }; return finalRoute; @@ -150,23 +178,36 @@ export const routesForServer = ({ `; for (const route of routes) { if ('type' in route) { + const keywords = ['loader', 'action']; + const regs = keywords.map(createMatchReg); const newRoute = traverseRouteTree(route); - routesCode += `${JSON.stringify(newRoute, null, 2).replace( - /"(loader_[^"]+)"/g, - '$1', - )},`; + const routeStr = JSON.stringify(newRoute, null, 2); + routesCode += regs + .reduce((acc, reg) => acc.replace(reg, '$1$2'), routeStr) + .replace(/\\"/g, '"'); } else { routesCode += `${JSON.stringify(route, null, 2)}`; } } routesCode += `\n];`; let importLoadersCode = ''; - if (loaders.length > 0) { - importLoadersCode = loaders - .map((loader, index) => { - return `import loader_${index} from "${slash(loader)}"`; - }) - .join('\n'); + for (const [key, loaderInfo] of Object.entries(loadersMap)) { + if (loaderInfo.inline) { + const { route } = loaderInfo; + if (route.action) { + importLoadersCode += `import { loader as ${key}, action as action_${ + loaderInfo.loaderId + } } from "${slash(loaderInfo.filePath)}";\n`; + } else { + importLoadersCode += `import { loader as ${key} } from "${slash( + loaderInfo.filePath, + )}";\n`; + } + } else { + importLoadersCode += `import ${key} from "${slash( + loaderInfo.filePath, + )}";\n`; + } } return ` @@ -201,9 +242,11 @@ export const fileSystemRoutes = async ({ string, { routeId: string; + loaderId: number; filePath: string; clientData?: boolean; inline: boolean; + route: NestedRouteForCli; } > = {}; const configs: string[] = []; @@ -222,17 +265,26 @@ export const fileSystemRoutes = async ({ `; let rootLayoutCode = ``; - const getDataLoaderPath = (loaderId: string, clientData?: boolean) => { + const getDataLoaderPath = ({ + loaderId, + clientData, + action, + inline, + routeId, + }: { + loaderId: string; + clientData?: boolean; + action: boolean; + inline: boolean; + routeId: string; + }) => { if (!ssrMode) { return ''; } const clientDataStr = clientData ? `&clientData=${clientData}` : ''; - if (nestedRoutesEntry) { - return `?mapFile=${slash( - loadersMapFile, - )}&loaderId=${loaderId}${clientDataStr}`; + return `?loaderId=${loaderId}${clientDataStr}&action=${action}&inline=${inline}&routeId=${routeId}`; } return ''; }; @@ -245,6 +297,7 @@ export const fileSystemRoutes = async ({ let loading: string | undefined; let error: string | undefined; let loader: string | undefined; + let action: string | undefined; let config: string | undefined; let component = ''; let lazyImport = null; @@ -262,13 +315,19 @@ export const fileSystemRoutes = async ({ loaders.push(route.loader); const loaderId = loaders.length - 1; loader = `loader_${loaderId}`; + const inline = Boolean(route.data); loadersMap[loader] = { + loaderId, routeId: route.id!, filePath: route.data || route.loader, clientData: Boolean(route.clientData), - inline: Boolean(route.data), + route, + inline, }; loader = `loader_${loaderId}`; + if (route.action) { + action = `action_${loaderId}`; + } } if (typeof route.config === 'string') { configs.push(route.config); @@ -310,6 +369,7 @@ export const fileSystemRoutes = async ({ lazyImport, loading, loader, + action, config, error, children, @@ -331,6 +391,7 @@ export const fileSystemRoutes = async ({ 'component', 'lazyImport', 'loader', + 'action', 'loading', 'error', 'config', @@ -380,13 +441,38 @@ export const fileSystemRoutes = async ({ for (const [key, loaderInfo] of Object.entries(loadersMap)) { if (loaderInfo.inline) { - importLoadersCode += `import { loader as ${key} } from "${slash( - loaderInfo.filePath, - )}${getDataLoaderPath(key, loaderInfo.clientData)}";\n`; + const { route } = loaderInfo; + if (route.action) { + importLoadersCode += `import { loader as ${key}, action as action_${ + loaderInfo.loaderId + } } from "${slash(loaderInfo.filePath)}${getDataLoaderPath({ + loaderId: key, + clientData: loaderInfo.clientData, + action: route.action, + inline: loaderInfo.inline, + routeId: loaderInfo.routeId, + })}";\n`; + } else { + importLoadersCode += `import { loader as ${key} } from "${slash( + loaderInfo.filePath, + )}${getDataLoaderPath({ + loaderId: key, + clientData: loaderInfo.clientData, + action: false, + inline: loaderInfo.inline, + routeId: route.id!, + })}";\n`; + } } else { importLoadersCode += `import ${key} from "${slash( loaderInfo.filePath, - )}${getDataLoaderPath(key, loaderInfo.clientData)}";\n`; + )}${getDataLoaderPath({ + loaderId: key, + clientData: loaderInfo.clientData, + action: false, + inline: loaderInfo.inline, + routeId: loaderInfo.routeId, + })}";\n`; } } diff --git a/packages/solutions/app-tools/src/analyze/utils.ts b/packages/solutions/app-tools/src/analyze/utils.ts index 09c2b62450eb..b267ba96fe11 100644 --- a/packages/solutions/app-tools/src/analyze/utils.ts +++ b/packages/solutions/app-tools/src/analyze/utils.ts @@ -12,6 +12,7 @@ import { transform } from 'esbuild'; import { parse } from 'es-module-lexer'; import type { ImportStatement } from '../types'; import { + ACTION_EXPORT_NAME, FILE_SYSTEM_ROUTES_FILE_NAME, JS_EXTENSIONS, LOADER_EXPORT_NAME, @@ -145,13 +146,34 @@ export const parseModule = async ({ return await parse(content); }; -export const hasLoader = async (filename: string) => { - const source = await fse.readFile(filename); - const [, moduleExports] = await parseModule({ - source: source.toString(), - filename, - }); - return moduleExports.some(e => e.n === LOADER_EXPORT_NAME); +export const hasLoader = async (filename: string, source?: string) => { + let content = source; + if (!source) { + content = (await fse.readFile(filename, 'utf-8')).toString(); + } + if (content) { + const [, moduleExports] = await parseModule({ + source: content.toString(), + filename, + }); + return moduleExports.some(e => e.n === LOADER_EXPORT_NAME); + } + return false; +}; + +export const hasAction = async (filename: string, source?: string) => { + let content = source; + if (!source) { + content = (await fse.readFile(filename, 'utf-8')).toString(); + } + if (content) { + const [, moduleExports] = await parseModule({ + source: content.toString(), + filename, + }); + return moduleExports.some(e => e.n === ACTION_EXPORT_NAME); + } + return false; }; export const getServerLoadersFile = ( diff --git a/packages/solutions/app-tools/tests/analyze/__snapshots__/nestedRoutes.test.ts.snap b/packages/solutions/app-tools/tests/analyze/__snapshots__/nestedRoutes.test.ts.snap index fb04b338adb5..c8ccce82bd27 100644 --- a/packages/solutions/app-tools/tests/analyze/__snapshots__/nestedRoutes.test.ts.snap +++ b/packages/solutions/app-tools/tests/analyze/__snapshots__/nestedRoutes.test.ts.snap @@ -58,6 +58,7 @@ exports[`nested routes walk 1`] = ` "children": [ { "_component": "@_modern_js_src/user/$.tsx", + "action": "/tests/analyze/fixtures/nested-routes/user/$.data.ts", "clientData": "/tests/analyze/fixtures/nested-routes/user/$.data.client.ts", "config": "/tests/analyze/fixtures/nested-routes/user/$.config.ts", "data": "/tests/analyze/fixtures/nested-routes/user/$.data.ts", @@ -70,6 +71,7 @@ exports[`nested routes walk 1`] = ` { "_component": "@_modern_js_src/user/[id]/page.tsx", "children": undefined, + "data": "/tests/analyze/fixtures/nested-routes/user/[id]/page.data.ts", "id": "user/(id)/page", "index": true, "type": "nested", diff --git a/packages/solutions/app-tools/tests/analyze/__snapshots__/templates.test.ts.snap b/packages/solutions/app-tools/tests/analyze/__snapshots__/templates.test.ts.snap index f5702fddc3a4..a917a70920d4 100644 --- a/packages/solutions/app-tools/tests/analyze/__snapshots__/templates.test.ts.snap +++ b/packages/solutions/app-tools/tests/analyze/__snapshots__/templates.test.ts.snap @@ -115,8 +115,9 @@ exports[`renderFunction basic usage 1`] = ` exports[`routesForServer generate code for server 1`] = ` - import loader_0 from "@_modern_js_src/routes/user/[id]/page.loader.ts" -import loader_1 from "@_modern_js_src/routes/layout.loader.ts" + import loader_0 from "@_modern_js_src/routes/user/[id]/page.loader.ts"; +import loader_1 from "@_modern_js_src/routes/layout.loader.ts"; + export const routes = [ { @@ -151,7 +152,7 @@ import loader_1 from "@_modern_js_src/routes/layout.loader.ts" ] } ] -}, +} ]; `; diff --git a/packages/solutions/app-tools/tests/analyze/data.test.ts b/packages/solutions/app-tools/tests/analyze/data.test.ts new file mode 100644 index 000000000000..b913b2881508 --- /dev/null +++ b/packages/solutions/app-tools/tests/analyze/data.test.ts @@ -0,0 +1,22 @@ +/** + * @jest-environment node + */ +import path from 'path'; +import { hasAction, hasLoader } from '../../src/analyze/utils'; + +describe('should verify loader and action normally', () => { + const dataFile = path.resolve( + __dirname, + './fixtures/nested-routes/user/$.data.ts', + ); + + test('hasLoader', async () => { + const isExist = await hasLoader(dataFile); + expect(isExist).toBe(true); + }); + + test('hasAction', async () => { + const isExist = await hasAction(dataFile); + expect(isExist).toBe(true); + }); +}); diff --git a/packages/solutions/app-tools/tests/analyze/fixtures/nested-routes/user/$.data.ts b/packages/solutions/app-tools/tests/analyze/fixtures/nested-routes/user/$.data.ts index e69de29bb2d1..e9f1fe78792e 100644 --- a/packages/solutions/app-tools/tests/analyze/fixtures/nested-routes/user/$.data.ts +++ b/packages/solutions/app-tools/tests/analyze/fixtures/nested-routes/user/$.data.ts @@ -0,0 +1,7 @@ +// eslint-disable-next-line @typescript-eslint/no-empty-function +const loader = () => {}; + +// eslint-disable-next-line @typescript-eslint/no-empty-function, import/no-mutable-exports, no-var +export var action = () => {}; + +export { loader }; diff --git a/packages/solutions/app-tools/tests/analyze/fixtures/nested-routes/user/[id]/page.data.ts b/packages/solutions/app-tools/tests/analyze/fixtures/nested-routes/user/[id]/page.data.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/integration/routes/src/common/utils.ts b/tests/integration/routes/src/common/utils.ts new file mode 100644 index 000000000000..1361432ff86b --- /dev/null +++ b/tests/integration/routes/src/common/utils.ts @@ -0,0 +1 @@ +export const modernTestActionName = 'modern_test_action_name'; diff --git a/tests/integration/routes/src/four/routes/user/[id]/page.data.ts b/tests/integration/routes/src/four/routes/user/[id]/page.data.ts new file mode 100644 index 000000000000..2d7c9f550c9a --- /dev/null +++ b/tests/integration/routes/src/four/routes/user/[id]/page.data.ts @@ -0,0 +1,14 @@ +import { ActionFunction, LoaderFunction } from '@modern-js/runtime/router'; +import { modernTestActionName } from '@/common/utils'; + +export const loader: LoaderFunction = () => { + const value = sessionStorage.getItem(modernTestActionName); + return value; +}; + +export const action: ActionFunction = async ({ request }) => { + const formData = await request.formData(); + const name = formData.get('name') as string; + sessionStorage.setItem(modernTestActionName, name); + return null; +}; diff --git a/tests/integration/routes/src/four/routes/user/[id]/page.tsx b/tests/integration/routes/src/four/routes/user/[id]/page.tsx index 92d8c7fb744f..521487153ee9 100644 --- a/tests/integration/routes/src/four/routes/user/[id]/page.tsx +++ b/tests/integration/routes/src/four/routes/user/[id]/page.tsx @@ -1,10 +1,28 @@ -import { useParams } from '@modern-js/runtime/router'; +import { + useFetcher, + useLoaderData, + useParams, +} from '@modern-js/runtime/router'; const Page = () => { const params = useParams<{ id: string; }>(); - return
item page, param is {params.id}
; + const data = useLoaderData() as string; + const { submit } = useFetcher(); + const handleClick = () => { + return submit({ name: 'modern_four_action' }, { method: 'post' }); + }; + + return ( +
+ item page, param is {params.id} + {data} +
+ action-button +
+
+ ); }; export default Page; diff --git a/tests/integration/routes/src/three/routes/user/[id]/profile/page.data.ts b/tests/integration/routes/src/three/routes/user/[id]/profile/page.data.ts new file mode 100644 index 000000000000..632bb8df43ac --- /dev/null +++ b/tests/integration/routes/src/three/routes/user/[id]/profile/page.data.ts @@ -0,0 +1,21 @@ +import type { ActionFunction, LoaderFunction } from '@modern-js/runtime/router'; +import { modernTestActionName } from '@/common/utils'; + +const storage = new Map(); + +export const loader: LoaderFunction = () => { + const value = storage.get(modernTestActionName); + return value || null; +}; + +export const action: ActionFunction = async ({ request }) => { + try { + const user = await request.json(); + const { name } = user; + storage.set(modernTestActionName, name); + return true; + } catch (error) { + console.error(error); + return false; + } +}; diff --git a/tests/integration/routes/src/three/routes/user/[id]/profile/page.tsx b/tests/integration/routes/src/three/routes/user/[id]/profile/page.tsx index 56eb5954f05f..9c0ab961bc11 100644 --- a/tests/integration/routes/src/three/routes/user/[id]/profile/page.tsx +++ b/tests/integration/routes/src/three/routes/user/[id]/profile/page.tsx @@ -1,10 +1,34 @@ -import { useParams } from '@modern-js/runtime/router'; +import { + useFetcher, + useLoaderData, + useParams, +} from '@modern-js/runtime/router'; const Page = () => { const params = useParams<{ id: string; }>(); - return
profile page, param is {params.id}
; + const data = useLoaderData() as string; + const { submit } = useFetcher(); + const handleClick = () => { + const user = { + name: 'modern_three_action', + }; + return submit(user, { + method: 'post', + encType: 'application/json', + }); + }; + + return ( +
+ item page, param is {params.id} + {data} +
+ update +
+
+ ); }; export default Page; diff --git a/tests/integration/routes/src/three/routes/user/profile/page.data.ts b/tests/integration/routes/src/three/routes/user/profile/page.data.ts new file mode 100644 index 000000000000..69678dee0e95 --- /dev/null +++ b/tests/integration/routes/src/three/routes/user/profile/page.data.ts @@ -0,0 +1,3 @@ +export const loader = () => { + return 'request profile page'; +}; diff --git a/tests/integration/routes/src/three/routes/user/profile/page.loader.ts b/tests/integration/routes/src/three/routes/user/profile/page.loader.ts deleted file mode 100644 index 6e51cde2e3a9..000000000000 --- a/tests/integration/routes/src/three/routes/user/profile/page.loader.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default async function loader() { - return 'request profile page'; -} diff --git a/tests/integration/routes/tests/index.test.ts b/tests/integration/routes/tests/index.test.ts index f2a2a412d92c..2ee98b966a11 100644 --- a/tests/integration/routes/tests/index.test.ts +++ b/tests/integration/routes/tests/index.test.ts @@ -214,8 +214,7 @@ const supportNoLayoutDir = async ( const text = await page.evaluate(el => el?.textContent, rootElm); expect(text?.includes('root layout')).toBeTruthy(); expect(text?.includes('user layout')).toBeTruthy(); - expect(text?.includes('item page')).toBeFalsy(); - expect(text?.includes('profile page, param is 1234')).toBeTruthy(); + expect(text?.includes('item page, param is 1234')).toBeTruthy(); expect(errors.length).toEqual(0); }; @@ -371,7 +370,6 @@ const supportLoaderForSSRAndCSR = async ( errors: string[], appPort: number, ) => { - // const page = await browser.newPage(); await page.goto(`http://localhost:${appPort}/three`, { waitUntil: ['domcontentloaded'], }); @@ -520,6 +518,38 @@ const hasHashCorrectly = async (appDir: string) => { expect(threeHtml.includes(hash)).toBe(true); }; +const supportActionInCSR = async ( + page: Page, + errors: string[], + appPort: number, +) => { + await page.goto(`http://localhost:${appPort}/four/user/profile`, { + waitUntil: ['networkidle0'], + }); + const rootElm = await page.$('#root'); + await page.click('.action-btn'); + await new Promise(resolve => setTimeout(resolve, 200)); + const text = await page.evaluate(el => el?.textContent, rootElm); + expect(text?.includes('param is profile')).toBeTruthy(); + expect(text?.includes('modern_four_action')).toBeTruthy(); +}; + +const supportActionInSSR = async ( + page: Page, + errors: string[], + appPort: number, +) => { + expect(errors.length).toBe(0); + await page.goto(`http://localhost:${appPort}/three/user/1234/profile`, { + waitUntil: ['networkidle0'], + }); + const rootElm = await page.$('#root'); + await page.click('.action-btn'); + await new Promise(resolve => setTimeout(resolve, 200)); + const text = await page.evaluate(el => el?.textContent, rootElm); + expect(text?.includes('modern_three_action')).toBeTruthy(); +}; + describe('dev', () => { let app: unknown; let appPort: number; @@ -578,8 +608,7 @@ describe('dev', () => { test('support handle config', async () => supportHandleConfig(page, appPort)); - // FIXME: skip the test - test.skip('support handle loader error', async () => + test('support handle loader error', async () => supportHandleLoaderError(page, errors, appPort)); }); @@ -593,7 +622,7 @@ describe('dev', () => { describe('loader', () => { test('support loader', async () => supportLoader(page, errors, appPort)); - test.skip('support loader for ssr and csr', async () => + test('support loader for ssr and csr', async () => supportLoaderForSSRAndCSR(page, errors, appPort)); test('support loader for csr', () => @@ -621,6 +650,16 @@ describe('dev', () => { }); }); + describe('support action', () => { + test('support action in CSR', async () => { + await supportActionInCSR(page, errors, appPort); + }); + + test('support action in SSR', async () => { + await supportActionInSSR(page, errors, appPort); + }); + }); + afterAll(async () => { await killApp(app); await page.close(); @@ -687,8 +726,7 @@ describe('build', () => { test('path without layout', async () => supportPathWithoutLayout(page, errors, appPort)); - // FIXME: skip the test - test.skip('support handle loader error', async () => + test('support handle loader error', async () => supportHandleLoaderError(page, errors, appPort)); }); @@ -732,6 +770,15 @@ describe('build', () => { }); }); + describe('support action', () => { + test('support action in CSR', async () => { + await supportActionInCSR(page, errors, appPort); + }); + test('support action in SSR', async () => { + await supportActionInSSR(page, errors, appPort); + }); + }); + afterAll(async () => { await killApp(app); await page.close(); @@ -804,7 +851,7 @@ describe('dev with rspack', () => { supportHandleConfig(page, appPort)); // FIXME: skip the test - test.skip('support handle loader error', async () => + test('support handle loader error', async () => supportHandleLoaderError(page, errors, appPort)); }); @@ -818,7 +865,7 @@ describe('dev with rspack', () => { describe('loader', () => { test('support loader', async () => supportLoader(page, errors, appPort)); - test.skip('support loader for ssr and csr', async () => + test('support loader for ssr and csr', async () => supportLoaderForSSRAndCSR(page, errors, appPort)); test('support loader for csr', () => @@ -846,6 +893,15 @@ describe('dev with rspack', () => { }); }); + describe('support action', () => { + test('support action in CSR', async () => { + await supportActionInCSR(page, errors, appPort); + }); + test('support action in SSR', async () => { + await supportActionInSSR(page, errors, appPort); + }); + }); + afterAll(async () => { await killApp(app); await page.close();