From d6c2109bce56e317fa5b7f235f81359eb3cd3dd5 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Tue, 17 Mar 2026 13:26:38 +0700 Subject: [PATCH 1/6] feat(runtime): extract tanstack router into plugin package --- .../create-runtime-tanstack-tailwind.md | 17 + .../en/apis/app/runtime/router/router.mdx | 3 + .../document/docs/en/components/init-app.mdx | 7 + .../guides/basic-features/routes/routes.mdx | 17 +- .../docs/en/guides/get-started/tech-stack.mdx | 11 +- .../zh/apis/app/runtime/router/router.mdx | 3 + .../document/docs/zh/components/init-app.mdx | 7 + .../guides/basic-features/routes/routes.mdx | 17 +- .../docs/zh/guides/get-started/tech-stack.mdx | 11 +- .../runtime/plugin-runtime/src/cli/index.ts | 24 +- .../src/core/server/stream/afterTemplate.ts | 2 +- .../src/core/server/stream/beforeTemplate.ts | 37 +- .../src/core/server/string/ssrData.ts | 1 + .../src/router/cli/code/index.ts | 12 +- .../plugin-runtime/src/router/cli/entry.ts | 68 ++- .../plugin-runtime/src/router/cli/handler.ts | 78 ++- .../plugin-runtime/src/router/cli/index.ts | 123 ++++- .../src/router/runtime/internal.ts | 41 +- .../tests/router/entryOwnership.test.ts | 30 + packages/runtime/plugin-tanstack/package.json | 105 ++++ .../runtime/plugin-tanstack/rslib.config.mts | 4 + .../runtime/plugin-tanstack/rstest.config.ts | 35 ++ packages/runtime/plugin-tanstack/src/cli.ts | 2 + .../runtime/plugin-tanstack/src/cli/index.ts | 255 +++++++++ .../plugin-tanstack/src/cli/tanstackTypes.ts | 467 ++++++++++++++++ .../runtime/plugin-tanstack/src/runtime.ts | 1 + .../src/runtime/DefaultNotFound.tsx | 15 + .../src/runtime/basepathRewrite.ts | 59 ++ .../src/runtime/dataMutation.tsx | 517 ++++++++++++++++++ .../plugin-tanstack/src/runtime/hooks.ts | 14 + .../plugin-tanstack/src/runtime/index.tsx | 25 + .../plugin-tanstack/src/runtime/plugin.tsx | 226 ++++++++ .../src/runtime/prefetchLink.tsx | 69 +++ .../plugin-tanstack/src/runtime/routeTree.ts | 517 ++++++++++++++++++ .../tests/router/dataMutation.test.tsx | 400 ++++++++++++++ .../tests/router/routeTree.test.ts | 85 +++ .../runtime/plugin-tanstack/tsconfig.json | 13 + 37 files changed, 3234 insertions(+), 84 deletions(-) create mode 100644 .changeset/create-runtime-tanstack-tailwind.md create mode 100644 packages/runtime/plugin-runtime/tests/router/entryOwnership.test.ts create mode 100644 packages/runtime/plugin-tanstack/package.json create mode 100644 packages/runtime/plugin-tanstack/rslib.config.mts create mode 100644 packages/runtime/plugin-tanstack/rstest.config.ts create mode 100644 packages/runtime/plugin-tanstack/src/cli.ts create mode 100644 packages/runtime/plugin-tanstack/src/cli/index.ts create mode 100644 packages/runtime/plugin-tanstack/src/cli/tanstackTypes.ts create mode 100644 packages/runtime/plugin-tanstack/src/runtime.ts create mode 100644 packages/runtime/plugin-tanstack/src/runtime/DefaultNotFound.tsx create mode 100644 packages/runtime/plugin-tanstack/src/runtime/basepathRewrite.ts create mode 100644 packages/runtime/plugin-tanstack/src/runtime/dataMutation.tsx create mode 100644 packages/runtime/plugin-tanstack/src/runtime/hooks.ts create mode 100644 packages/runtime/plugin-tanstack/src/runtime/index.tsx create mode 100644 packages/runtime/plugin-tanstack/src/runtime/plugin.tsx create mode 100644 packages/runtime/plugin-tanstack/src/runtime/prefetchLink.tsx create mode 100644 packages/runtime/plugin-tanstack/src/runtime/routeTree.ts create mode 100644 packages/runtime/plugin-tanstack/tests/router/dataMutation.test.tsx create mode 100644 packages/runtime/plugin-tanstack/tests/router/routeTree.test.ts create mode 100644 packages/runtime/plugin-tanstack/tsconfig.json diff --git a/.changeset/create-runtime-tanstack-tailwind.md b/.changeset/create-runtime-tanstack-tailwind.md new file mode 100644 index 000000000000..1c5f0e68a588 --- /dev/null +++ b/.changeset/create-runtime-tanstack-tailwind.md @@ -0,0 +1,17 @@ +--- +'@modern-js/create': minor +'@modern-js/runtime': minor +'@modern-js/plugin-tanstack': minor +--- + +feat(create): support `--router tanstack` and `--tailwind` scaffolding + +- add router selection for React Router / TanStack Router in `@modern-js/create` +- add Tailwind CSS v4 scaffold option (`--tailwind`) with `postcss.config.mjs` and `tailwind.config.ts` +- for TanStack scaffolds, register `tanstackRouterPlugin()` and use `src/views` as the convention directory + +feat(runtime): move TanStack Router integration to `@modern-js/plugin-tanstack` + +- add `@modern-js/plugin-tanstack` runtime/cli package surface +- remove `@modern-js/runtime/tanstack-router` export from `@modern-js/runtime` +- migrate TanStack route generation/runtime ownership from core runtime to the standalone plugin diff --git a/packages/document/docs/en/apis/app/runtime/router/router.mdx b/packages/document/docs/en/apis/app/runtime/router/router.mdx index dd58ea5d609e..c1c2b692b6e2 100644 --- a/packages/document/docs/en/apis/app/runtime/router/router.mdx +++ b/packages/document/docs/en/apis/app/runtime/router/router.mdx @@ -8,6 +8,9 @@ sidebar_position: 1 :::info The router solution based on [react-router v7](https://reactrouter.com/). +This page documents the React Router runtime export (`@modern-js/runtime/router`). +If your app uses TanStack Router (`--router tanstack`), use `@modern-js/plugin-tanstack/runtime` and refer to TanStack Router API docs. + ::: ## hooks diff --git a/packages/document/docs/en/components/init-app.mdx b/packages/document/docs/en/components/init-app.mdx index 2431697de37e..e12d4fcdb4e3 100644 --- a/packages/document/docs/en/components/init-app.mdx +++ b/packages/document/docs/en/components/init-app.mdx @@ -58,3 +58,10 @@ Now, the project structure is as follows: │ └── page.tsx └── tsconfig.json ``` + +When `--tailwind` is enabled, `postcss.config.mjs` and `tailwind.config.ts` are generated in the project root. +When `--router tanstack` is enabled, the scaffold adds `@modern-js/plugin-tanstack`, registers `tanstackRouterPlugin()` in `modern.config.ts`, and uses `src/views` as the route convention directory. + +When `--bff` (default Effect runtime) or `--bff-runtime effect` is enabled, `modern.config.ts` enables `@modern-js/plugin-bff`, generates `api/effect/index.ts` + `shared/effect/api.ts`, and sets `bff.runtimeFramework` to `effect`. +When `--bff-runtime hono` is enabled, `modern.config.ts` enables `@modern-js/plugin-bff`, generates `api/lambda/hello.ts`, and sets `bff.runtimeFramework` to `hono`. +When `--workspace` is enabled, `@modern-js/*` dependencies use `workspace:*` versions for local monorepo linkage. diff --git a/packages/document/docs/en/guides/basic-features/routes/routes.mdx b/packages/document/docs/en/guides/basic-features/routes/routes.mdx index 99b0afbf44d8..685f8498060e 100644 --- a/packages/document/docs/en/guides/basic-features/routes/routes.mdx +++ b/packages/document/docs/en/guides/basic-features/routes/routes.mdx @@ -12,6 +12,11 @@ The routing mentioned in this section all refers to conventional routing. ::: +:::tip +This page uses React Router import paths in examples (`@modern-js/runtime/router`). +If your project is created with `--router tanstack`, use `@modern-js/plugin-tanstack/runtime` instead. +::: + ## What is Nested Routing Nested routing is a pattern that couples URL segments with the component hierarchy and data. Typically, URL segments determine: @@ -493,13 +498,19 @@ import Motivation from '@site-docs-en/components/convention-routing-motivation'; ## FAQ -1. Why is there `@modern-js/runtime/router` to re-export React Router API? +1. Why should I import from `@modern-js/runtime/router` or `@modern-js/plugin-tanstack/runtime`? Notice that all the code examples in the documentation uses APIs exported from the `@modern-js/runtime/router` package instead of directly using the API exported from the React Router package. So, what is the difference? -The API exported from `@modern-js/runtime/router` is the same as the API from the React Router package. If you encounter issues while using an API, check the React Router documentation and issues first. +- `@modern-js/runtime/router` for React Router mode. +- `@modern-js/plugin-tanstack/runtime` for TanStack Router mode. + +Use the Modern.js runtime export that matches your selected router framework, instead of importing directly from the upstream router package. This avoids dependency duplication and ensures compatibility with Modern.js route generation/runtime behavior. + +If you encounter API usage issues, check the corresponding upstream docs: -Additionally, when using conventional routing, make sure to use the API from `@modern-js/runtime/router` instead of directly using the React Router API. Modern.js internally installs React Router, and using the React Router API directly in your application may result in two versions of React Router being present, causing unexpected behavior. +- React Router docs for `@modern-js/runtime/router`. +- TanStack Router docs for `@modern-js/plugin-tanstack/runtime`. :::note If you must directly use the React Router package's API (e.g., route behavior wrapped in a unified npm package), you can set [`source.alias`](/configure/app/source/alias) to point `react-router` and `react-router-dom` to the project's dependencies, avoiding the issue of two versions of React Router. diff --git a/packages/document/docs/en/guides/get-started/tech-stack.mdx b/packages/document/docs/en/guides/get-started/tech-stack.mdx index 21fdbf0f4a32..695d7b7e8b99 100644 --- a/packages/document/docs/en/guides/get-started/tech-stack.mdx +++ b/packages/document/docs/en/guides/get-started/tech-stack.mdx @@ -16,7 +16,16 @@ Rsbuild supports building Vue applications. If you need to use Vue, you can refe ## Routing -Modern.js uses [React Router v7](https://reactrouter.com/en/main) for routing. +Modern.js provides two first-party routing frameworks: + +- [React Router v7](https://reactrouter.com/en/main) (default), via `@modern-js/runtime/router`. +- [TanStack Router](https://tanstack.com/router), via `@modern-js/plugin-tanstack/runtime`. + +When creating a project, you can choose TanStack Router by using: + +```bash +npx @modern-js/create@latest myapp --router tanstack +``` Modern.js supports conventional routing, self-controlled routing, or other routing schemes. Please refer to ["Routing"](/guides/basic-features/routes/routes) to make your choice. diff --git a/packages/document/docs/zh/apis/app/runtime/router/router.mdx b/packages/document/docs/zh/apis/app/runtime/router/router.mdx index 9356bcb3fe83..022c66c083b7 100644 --- a/packages/document/docs/zh/apis/app/runtime/router/router.mdx +++ b/packages/document/docs/zh/apis/app/runtime/router/router.mdx @@ -7,6 +7,9 @@ sidebar_position: 1 :::info 补充信息 基于 [react-router](https://reactrouter.com/) 的路由解决方案。 +本页文档对应 React Router 运行时导出(`@modern-js/runtime/router`)。 +如果应用使用 TanStack Router(`--router tanstack`),请使用 `@modern-js/plugin-tanstack/runtime`,并参考 TanStack Router 官方 API 文档。 + ::: ## hooks diff --git a/packages/document/docs/zh/components/init-app.mdx b/packages/document/docs/zh/components/init-app.mdx index 6f0df4b46dca..4bb7c542577c 100644 --- a/packages/document/docs/zh/components/init-app.mdx +++ b/packages/document/docs/zh/components/init-app.mdx @@ -58,3 +58,10 @@ npx @modern-js/create@latest myapp │ └── page.tsx └── tsconfig.json ``` + +当启用 `--tailwind` 时,项目根目录会额外生成 `postcss.config.mjs` 和 `tailwind.config.ts`。 +当启用 `--router tanstack` 时,脚手架会添加 `@modern-js/plugin-tanstack`、在 `modern.config.ts` 中注册 `tanstackRouterPlugin()`,并使用 `src/views` 作为路由约定目录。 + +当启用 `--bff`(默认 Effect 运行时)或 `--bff-runtime effect` 时,会在 `modern.config.ts` 中启用 `@modern-js/plugin-bff`,生成 `api/effect/index.ts` 与 `shared/effect/api.ts`,并将 `bff.runtimeFramework` 设置为 `effect`。 +当启用 `--bff-runtime hono` 时,会在 `modern.config.ts` 中启用 `@modern-js/plugin-bff`,生成 `api/lambda/hello.ts`,并将 `bff.runtimeFramework` 设置为 `hono`。 +当启用 `--workspace` 时,`@modern-js/*` 依赖会使用 `workspace:*` 版本,便于本地 monorepo 联调。 diff --git a/packages/document/docs/zh/guides/basic-features/routes/routes.mdx b/packages/document/docs/zh/guides/basic-features/routes/routes.mdx index d12c0cd05803..d8cb3744f729 100644 --- a/packages/document/docs/zh/guides/basic-features/routes/routes.mdx +++ b/packages/document/docs/zh/guides/basic-features/routes/routes.mdx @@ -12,6 +12,11 @@ Modern.js 的路由基于 [React Router 7](https://reactrouter.com/en/main), ::: +:::tip +本页示例默认使用 React Router 的导出路径(`@modern-js/runtime/router`)。 +如果你的项目通过 `--router tanstack` 创建,请改用 `@modern-js/plugin-tanstack/runtime`。 +::: + ## 什么是嵌套路由 嵌套路由是一种将 URL 分段与组件层次结构和数据耦合起来的路由模式。通常,URL 段会决定: @@ -495,13 +500,19 @@ import Motivation from '@site-docs/components/convention-routing-motivation'; ## 常见问题 -1. 为什么要提供 `@modern-js/runtime/router` 来导出 React Router API ? +1. 为什么要通过 `@modern-js/runtime/router` 或 `@modern-js/plugin-tanstack/runtime` 引入 API? 可以发现,在文档中所有的代码用例都是使用 `@modern-js/runtime/router` 包导出的 API,而不是直接使用 React Router 包导出的 API。那两者有什么区别呢? -首先,在 `@modern-js/runtime/router` 中导出的 API 和 React Router 包的 API 是完全一致的,如果某个 API 使用出现问题,请先检查 React Router 的文档和 Issues。 +- React Router 模式使用 `@modern-js/runtime/router`。 +- TanStack Router 模式使用 `@modern-js/plugin-tanstack/runtime`。 + +建议优先使用与当前路由方案对应的 Modern.js 运行时导出,而不是直接从上游路由包中引入。这样可以避免依赖重复,并确保与 Modern.js 的路由生成和运行时行为保持一致。 + +如果遇到 API 使用问题,可以分别参考上游文档: -在使用约定式路由的情况下,务必使用 `@modern-js/runtime/router` 中的 API,不直接使用 React Router 的 API。因为 Modern.js 内部会安装 React Router,如果应用中使用了 React Router 的 API,可能会导致两个版本的 React Router 同时存在,出现不符合预期的行为。 +- `@modern-js/runtime/router` 对应 React Router 文档。 +- `@modern-js/plugin-tanstack/runtime` 对应 TanStack Router 文档。 :::note diff --git a/packages/document/docs/zh/guides/get-started/tech-stack.mdx b/packages/document/docs/zh/guides/get-started/tech-stack.mdx index 0a860657ce3d..283110c5cc50 100644 --- a/packages/document/docs/zh/guides/get-started/tech-stack.mdx +++ b/packages/document/docs/zh/guides/get-started/tech-stack.mdx @@ -16,7 +16,16 @@ Modern.js 底层的 Rsbuild 支持构建 Vue 应用,如果你需要使用 Vue ## 路由 -Modern.js 的路由基于 [React Router 7](https://reactrouter.com/en/main)。 +Modern.js 提供两套一方路由方案: + +- 默认使用 [React Router 7](https://reactrouter.com/en/main),通过 `@modern-js/runtime/router` 导出 API。 +- 支持 [TanStack Router](https://tanstack.com/router),通过 `@modern-js/plugin-tanstack/runtime` 导出 API。 + +创建项目时,可通过以下命令选择 TanStack Router: + +```bash +npx @modern-js/create@latest myapp --router tanstack +``` Modern.js 支持约定式路由、自控式路由或其他路由方案,请参考 [页面入口](/guides/concept/entries) 进行选择。 diff --git a/packages/runtime/plugin-runtime/src/cli/index.ts b/packages/runtime/plugin-runtime/src/cli/index.ts index 13feb0185489..fcb0b7d5dee9 100644 --- a/packages/runtime/plugin-runtime/src/cli/index.ts +++ b/packages/runtime/plugin-runtime/src/cli/index.ts @@ -4,8 +4,17 @@ import { isReact18 as checkIsReact18, cleanRequireCache, } from '@modern-js/utils'; -import { documentPlugin } from '../document/cli'; -import { routerPlugin } from '../router/cli'; +import { + documentPlugin, +} from '../document/cli'; +import { + getEntrypointRoutesDir, + handleFileChange, + handleGeneratorEntryCode, + handleModifyEntrypoints, + isRouteEntry, + routerPlugin, +} from '../router/cli'; import { builderPluginAlias } from './alias'; import { generateCode } from './code'; import { ENTRY_BOOTSTRAP_FILE_NAME, ENTRY_POINT_FILE_NAME } from './constants'; @@ -13,7 +22,16 @@ import { isRuntimeEntry } from './entry'; import { ssrPlugin } from './ssr'; export { isRuntimeEntry } from './entry'; -export { ssrPlugin, routerPlugin, documentPlugin }; +export { + documentPlugin, + getEntrypointRoutesDir, + handleFileChange, + handleGeneratorEntryCode, + handleModifyEntrypoints, + isRouteEntry, + routerPlugin, + ssrPlugin, +}; export const runtimePlugin = (params?: { plugins?: CliPlugin[]; }): CliPlugin => ({ diff --git a/packages/runtime/plugin-runtime/src/core/server/stream/afterTemplate.ts b/packages/runtime/plugin-runtime/src/core/server/stream/afterTemplate.ts index 13fe7da2b8e8..c080c8c5c8d4 100644 --- a/packages/runtime/plugin-runtime/src/core/server/stream/afterTemplate.ts +++ b/packages/runtime/plugin-runtime/src/core/server/stream/afterTemplate.ts @@ -110,7 +110,7 @@ function createReplaceSSRData(options: { const attrsStr = attributesToString({ nonce }); const serializeSSRData = serializeJson(ssrData); - const ssrDataScript = useJsonScript + const ssrScripts = useJsonScript ? `` : `window._SSR_DATA = ${serializeSSRData}`; diff --git a/packages/runtime/plugin-runtime/src/core/server/stream/beforeTemplate.ts b/packages/runtime/plugin-runtime/src/core/server/stream/beforeTemplate.ts index 6f14847ff0de..210f82f30170 100644 --- a/packages/runtime/plugin-runtime/src/core/server/stream/beforeTemplate.ts +++ b/packages/runtime/plugin-runtime/src/core/server/stream/beforeTemplate.ts @@ -73,7 +73,7 @@ export async function buildShellBeforeTemplate( async function getCssChunks() { const { routeManifest, routerContext, routes } = runtimeContext; - if (!routeManifest || !routerContext || !routes) { + if (!routeManifest) { return ''; } @@ -90,14 +90,33 @@ export async function buildShellBeforeTemplate( return; } - const routeId = match.route.id; - if (routeId) { - const routeManifest = routeAssets[routeId]; - return routeManifest; - } - }) - .filter(Boolean); - const asyncEntry = routeAssets[`async-${entryName}`]; + let matchedRouteManifests: RouteManifest[] | undefined = undefined; + + if (routerContext && routes) { + const matches = matchRoutes( + routes, + routerContext.location, + routerContext.basename, + ); + matchedRouteManifests = matches + ?.map((match, index) => { + if (!index) { + return; + } + + const routeId = match.route.id; + if (routeId) { + return routeAssets[routeId] as RouteManifest | undefined; + } + }) + .filter(Boolean) as RouteManifest[]; + } else { + return ''; + } + + const asyncEntry = routeAssets[`async-${entryName}`] as + | RouteManifest + | undefined; if (asyncEntry) { matchedRouteManifests?.push(asyncEntry); } diff --git a/packages/runtime/plugin-runtime/src/core/server/string/ssrData.ts b/packages/runtime/plugin-runtime/src/core/server/string/ssrData.ts index 6599e95087b2..895618ee5efe 100644 --- a/packages/runtime/plugin-runtime/src/core/server/string/ssrData.ts +++ b/packages/runtime/plugin-runtime/src/core/server/string/ssrData.ts @@ -102,6 +102,7 @@ export class SSRDataCollector implements Collector { ? `\n` : `\nwindow._ROUTER_DATA = ${serializedRouterData}`; } + return ssrDataScripts; } } diff --git a/packages/runtime/plugin-runtime/src/router/cli/code/index.ts b/packages/runtime/plugin-runtime/src/router/cli/code/index.ts index b48d275c0c5f..517d76ba6990 100644 --- a/packages/runtime/plugin-runtime/src/router/cli/code/index.ts +++ b/packages/runtime/plugin-runtime/src/router/cli/code/index.ts @@ -108,9 +108,14 @@ export const generateCode = async ( appContext; const hooks = api.getHooks(); + const generatedRoutesByEntry: Record< + string, + (NestedRouteForCli | PageRoute)[] + > = {}; await Promise.all(entrypoints.map(generateEntryCode)); + async function generateEntryCode(entrypoint: Entrypoint) { const { entryName, @@ -186,7 +191,10 @@ export const generateCode = async ( entrypoint, routes: markedRoutes, }); - + generatedRoutesByEntry[entryName] = routes as ( + | NestedRouteForCli + | PageRoute + )[]; if (ssrMode === 'stream') { const hasPageRoute = routes.some( route => 'type' in route && route.type === 'page', @@ -285,6 +293,8 @@ export const generateCode = async ( } } } + + return generatedRoutesByEntry; }; export function generatorRegisterCode( diff --git a/packages/runtime/plugin-runtime/src/router/cli/entry.ts b/packages/runtime/plugin-runtime/src/router/cli/entry.ts index 84150b22f3e8..40314715e1dc 100644 --- a/packages/runtime/plugin-runtime/src/router/cli/entry.ts +++ b/packages/runtime/plugin-runtime/src/router/cli/entry.ts @@ -4,39 +4,77 @@ import { fs } from '@modern-js/utils'; import { hasApp } from '../../cli/entry'; import { NESTED_ROUTES_DIR } from './constants'; -export const hasNestedRoutes = (dir: string) => - fs.existsSync(path.join(dir, NESTED_ROUTES_DIR)); +export const ROUTES_DIR_META_KEY = '__modernRoutesDir'; -export const isRouteEntry = (dir: string) => { - if (hasNestedRoutes(dir)) { - return path.join(dir, NESTED_ROUTES_DIR); +type EntrypointWithRoutesMeta = Entrypoint & { + [ROUTES_DIR_META_KEY]?: string; +}; + +export const getEntrypointRoutesDir = (entrypoint: { + [ROUTES_DIR_META_KEY]?: string; + nestedRoutesEntry?: string; +}) => { + if (entrypoint[ROUTES_DIR_META_KEY]) { + return entrypoint[ROUTES_DIR_META_KEY]; + } + + if (entrypoint.nestedRoutesEntry) { + return path.basename(entrypoint.nestedRoutesEntry); + } + + return null; +}; + +export const hasNestedRoutes = ( + dir: string, + routesDir = NESTED_ROUTES_DIR, +) => fs.existsSync(path.join(dir, routesDir)); + +export const isRouteEntry = ( + dir: string, + routesDir = NESTED_ROUTES_DIR, +) => { + if (hasNestedRoutes(dir, routesDir)) { + return path.join(dir, routesDir); } return false; }; -export const modifyEntrypoints = (entrypoints: Entrypoint[]) => { +export const modifyEntrypoints = ( + entrypoints: Entrypoint[], + routesDir = NESTED_ROUTES_DIR, + ) => { return entrypoints.map(entrypoint => { + const entrypointWithMeta = entrypoint as EntrypointWithRoutesMeta; + if (!entrypoint.isAutoMount) { - return entrypoint; + return entrypointWithMeta; } + if (entrypoint?.isCustomSourceEntry) { if (entrypoint.fileSystemRoutes) { - entrypoint.nestedRoutesEntry = + entrypointWithMeta.nestedRoutesEntry = entrypoint.absoluteEntryDir || entrypoint.entry; + entrypointWithMeta[ROUTES_DIR_META_KEY] = routesDir; } - return entrypoint; + return entrypointWithMeta; } + const isHasApp = hasApp(entrypoint.absoluteEntryDir!); if (isHasApp) { - return entrypoint; + return entrypointWithMeta; } - const isHasNestedRoutes = hasNestedRoutes(entrypoint.absoluteEntryDir!); + const isHasNestedRoutes = hasNestedRoutes( + entrypoint.absoluteEntryDir!, + routesDir, + ); if (isHasNestedRoutes) { - entrypoint.nestedRoutesEntry = path.join( + entrypointWithMeta.nestedRoutesEntry = path.join( entrypoint.absoluteEntryDir!, - NESTED_ROUTES_DIR, + routesDir, ); + entrypointWithMeta[ROUTES_DIR_META_KEY] = routesDir; } - return entrypoint; + return entrypointWithMeta; }); -}; +}; \ No newline at end of file diff --git a/packages/runtime/plugin-runtime/src/router/cli/handler.ts b/packages/runtime/plugin-runtime/src/router/cli/handler.ts index 690781699747..baa271a609f9 100644 --- a/packages/runtime/plugin-runtime/src/router/cli/handler.ts +++ b/packages/runtime/plugin-runtime/src/router/cli/handler.ts @@ -8,24 +8,42 @@ import * as templates from './code/templates'; import { isPageComponentFile } from './code/utils'; import { modifyEntrypoints } from './entry'; -let originEntrypoints: any[] = []; +type RegenerateRoutesFn = (params: { + api: CLIPluginAPI; + appContext: ReturnType['getAppContext']>; + resolvedConfig: AppNormalizedConfig; + entrypoints: Entrypoint[]; +}) => Promise; -export async function handleModifyEntrypoints(entrypoints: Entrypoint[]) { - return modifyEntrypoints(entrypoints); +type HandleFileChangeOptions = { + includeEntry?: (entrypoint: Entrypoint) => boolean; + regenerate?: RegenerateRoutesFn; + entrypointsKey?: string; +}; + +const DEFAULT_ENTRYPOINTS_KEY = '__default_router_entries__'; +const originEntrypointsByKey = new Map(); + +export async function handleModifyEntrypoints( + entrypoints: Entrypoint[], + routesDir?: string, +) { + return modifyEntrypoints(entrypoints, routesDir); } export async function handleGeneratorEntryCode( api: CLIPluginAPI, entrypoints: Entrypoint[], + entrypointsKey = DEFAULT_ENTRYPOINTS_KEY, ) { const appContext = api.getAppContext(); const { internalDirectory } = appContext; const resolvedConfig = api.getNormalizedConfig(); const { generatorRegisterCode, generateCode, generatorServerRegisterCode } = await import('./code'); - originEntrypoints = cloneDeep(entrypoints); + originEntrypointsByKey.set(entrypointsKey, cloneDeep(entrypoints)); const enableRsc = resolvedConfig?.server?.rsc; - await generateCode( + const routesByEntry = await generateCode( appContext, resolvedConfig as AppNormalizedConfig, entrypoints, @@ -71,21 +89,35 @@ export async function handleGeneratorEntryCode( } }), ); - return entrypoints; + return routesByEntry; } -export async function handleFileChange(api: CLIPluginAPI, e: any) { +export async function handleFileChange( + api: CLIPluginAPI, + e: any, + options: HandleFileChangeOptions = {}, +) { + const { includeEntry, regenerate, entrypointsKey = DEFAULT_ENTRYPOINTS_KEY } = + options; const appContext = api.getAppContext(); const { appDirectory, entrypoints } = appContext; + const activeEntrypoints = includeEntry + ? entrypoints.filter(includeEntry) + : entrypoints; const { filename, eventType } = e; - const nestedRouteEntries = entrypoints + const nestedRouteEntries = activeEntrypoints .map(point => point.nestedRoutesEntry) .filter(Boolean) as string[]; - const pagesDir = entrypoints + const pagesDir = activeEntrypoints .map(point => point.entry) // should only watch file-based routes .filter(entry => entry && !path.extname(entry)) .concat(nestedRouteEntries); + + if (pagesDir.length === 0) { + return; + } + const isPageFile = (name: string) => pagesDir.some(pageDir => name.includes(pageDir)); @@ -105,14 +137,24 @@ export async function handleFileChange(api: CLIPluginAPI, e: any) { (isConfigRoutesFile && (eventType === 'change' || eventType === 'add' || eventType === 'unlink')) ) { - const resolvedConfig = api.getNormalizedConfig(); - const { generateCode } = await import('./code'); - const entrypoints = cloneDeep(originEntrypoints); - await generateCode( - appContext, - resolvedConfig as AppNormalizedConfig, - entrypoints, - api, + const resolvedConfig = api.getNormalizedConfig() as AppNormalizedConfig; + const cachedEntrypoints = + originEntrypointsByKey.get(entrypointsKey) || activeEntrypoints; + const entrypoints = cloneDeep(cachedEntrypoints).filter(entrypoint => + includeEntry ? includeEntry(entrypoint) : true, ); + + if (regenerate) { + await regenerate({ + api, + appContext, + resolvedConfig, + entrypoints, + }); + return; + } + + const { generateCode } = await import('./code'); + await generateCode(appContext, resolvedConfig, entrypoints, api); } -} +} \ No newline at end of file diff --git a/packages/runtime/plugin-runtime/src/router/cli/index.ts b/packages/runtime/plugin-runtime/src/router/cli/index.ts index 4d076c2021d1..956860112de7 100644 --- a/packages/runtime/plugin-runtime/src/router/cli/index.ts +++ b/packages/runtime/plugin-runtime/src/router/cli/index.ts @@ -1,28 +1,74 @@ import path from 'node:path'; import type { AppTools, CliPlugin } from '@modern-js/app-tools'; -import type { - Entrypoint, - NestedRouteForCli, - PageRoute, - ServerRoute, -} from '@modern-js/types'; -import { fs, NESTED_ROUTE_SPEC_FILE } from '@modern-js/utils'; +import type { NestedRouteForCli, PageRoute, ServerRoute } from '@modern-js/types'; +import { fs, NESTED_ROUTE_SPEC_FILE, findExists } from '@modern-js/utils'; import { filterRoutesForServer } from '@modern-js/utils'; -import { isRouteEntry } from './entry'; +import { NESTED_ROUTES_DIR } from './constants'; +import { getEntrypointRoutesDir, isRouteEntry } from './entry'; import { handleFileChange, handleGeneratorEntryCode, handleModifyEntrypoints, } from './handler'; -export { isRouteEntry } from './entry'; -export { handleFileChange, handleModifyEntrypoints } from './handler'; +export { getEntrypointRoutesDir, isRouteEntry } from './entry'; +export { + handleFileChange, + handleGeneratorEntryCode, + handleModifyEntrypoints, +} from './handler'; + +const JS_OR_TS_EXTS = [ + '.js', + '.jsx', + '.ts', + '.tsx', + '.mjs', + '.mts', + '.cjs', + '.cts', +] as const; + +function hasRouterConfigInRuntimeFile(runtimeConfigBase: string) { + const runtimeConfigFile = findExists( + JS_OR_TS_EXTS.map(ext => `${runtimeConfigBase}${ext}`), + ); + + if (!runtimeConfigFile) { + return false; + } + + try { + const content = fs.readFileSync(runtimeConfigFile, 'utf-8'); + return /router\s*:/.test(content); + } catch { + return false; + } +} + +type RouteEntrypointLike = { + entry?: string; + pageRoutesEntry?: string; + nestedRoutesEntry?: string; +}; + +function isBuiltInRouteEntrypoint(entrypoint: RouteEntrypointLike) { + if (entrypoint.pageRoutesEntry) { + return true; + } + + const entrypointRoutesDir = getEntrypointRoutesDir(entrypoint); + if (entrypointRoutesDir) { + return entrypointRoutesDir === NESTED_ROUTES_DIR; + } + + return Boolean(entrypoint.entry && isRouteEntry(entrypoint.entry)); +} export const routerPlugin = (): CliPlugin => ({ name: '@modern-js/plugin-router', required: ['@modern-js/runtime'], setup: api => { - const nestedRoutes: Record = {}; const nestedRoutesForServer: Record = {}; const { metaName } = api.getAppContext(); @@ -40,8 +86,15 @@ export const routerPlugin = (): CliPlugin => ({ }); api._internalRuntimePlugins(({ entrypoint, plugins }) => { - const { nestedRoutesEntry } = entrypoint as Entrypoint; - const { serverRoutes, metaName } = api.getAppContext(); + const { serverRoutes, metaName, srcDirectory, runtimeConfigFile } = + api.getAppContext(); + const normalizedConfig = api.getNormalizedConfig() as any; + const hasUserRouterConfig = + normalizedConfig.router && + Object.keys(normalizedConfig.router).length > 0; + const hasRuntimeRouterConfig = hasRouterConfigInRuntimeFile( + path.join(srcDirectory, runtimeConfigFile), + ); const serverBase = serverRoutes .filter( (route: ServerRoute) => route.entryName === entrypoint.entryName, @@ -49,7 +102,11 @@ export const routerPlugin = (): CliPlugin => ({ .map(route => route.urlPath) .sort((a, b) => (a.length - b.length > 0 ? -1 : 1)); - if (nestedRoutesEntry) { + if ( + isBuiltInRouteEntrypoint(entrypoint) || + hasUserRouterConfig || + hasRuntimeRouterConfig + ) { plugins.push({ name: 'router', path: `@${metaName}/runtime/router/internal`, @@ -81,17 +138,23 @@ export const routerPlugin = (): CliPlugin => ({ return { entrypoints: newEntryPoints }; }); api.generateEntryCode(async ({ entrypoints }) => { - await handleGeneratorEntryCode(api, entrypoints); + const builtInEntrypoints = entrypoints.filter(isBuiltInRouteEntrypoint); + if (builtInEntrypoints.length > 0) { + await handleGeneratorEntryCode(api, builtInEntrypoints); + } }); api.onFileChanged(async e => { - await handleFileChange(api, e); + await handleFileChange(api, e, { + includeEntry: isBuiltInRouteEntrypoint, + }); }); api.modifyFileSystemRoutes(({ entrypoint, routes }) => { - nestedRoutes[entrypoint.entryName] = routes; - nestedRoutesForServer[entrypoint.entryName] = filterRoutesForServer( - routes as (NestedRouteForCli | PageRoute)[], - ); + if (isBuiltInRouteEntrypoint(entrypoint)) { + nestedRoutesForServer[entrypoint.entryName] = filterRoutesForServer( + routes as (NestedRouteForCli | PageRoute)[], + ); + } return { entrypoint, @@ -100,12 +163,22 @@ export const routerPlugin = (): CliPlugin => ({ }); api.onBeforeGenerateRoutes(async ({ entrypoint, code }) => { - const { distDirectory } = api.getAppContext(); + if (isBuiltInRouteEntrypoint(entrypoint)) { + const { distDirectory } = api.getAppContext(); - await fs.outputJSON( - path.resolve(distDirectory, NESTED_ROUTE_SPEC_FILE), - nestedRoutesForServer, - ); + const nestedRoutesSpecPath = path.resolve( + distDirectory, + NESTED_ROUTE_SPEC_FILE, + ); + const existingNestedRoutes = (await fs.pathExists(nestedRoutesSpecPath)) + ? ((await fs.readJSON(nestedRoutesSpecPath)) as Record) + : {}; + + await fs.outputJSON(nestedRoutesSpecPath, { + ...existingNestedRoutes, + ...nestedRoutesForServer, + }); + } return { entrypoint, diff --git a/packages/runtime/plugin-runtime/src/router/runtime/internal.ts b/packages/runtime/plugin-runtime/src/router/runtime/internal.ts index 6db15cd3c00f..22e8658e2827 100644 --- a/packages/runtime/plugin-runtime/src/router/runtime/internal.ts +++ b/packages/runtime/plugin-runtime/src/router/runtime/internal.ts @@ -1,8 +1,41 @@ -import { routerPlugin } from './plugin'; +import { merge } from '@modern-js/runtime-utils/merge'; +import type { RuntimePlugin } from '../../core'; +import { + modifyRoutes as modifyRoutesHook, + onBeforeCreateRoutes as onBeforeCreateRoutesHook, +} from './hooks'; +import type { RouterExtendsHooks } from './hooks'; +import { routerPlugin as reactRouterPlugin } from './plugin'; import type { RouterConfig, SingleRouteConfig } from './types'; -export { routerPlugin }; + +export const routerPlugin = ( + userConfig: Partial = {}, +): RuntimePlugin<{ + extendHooks: RouterExtendsHooks; +}> => { + return { + name: '@modern-js/plugin-router', + registryHooks: { + modifyRoutes: modifyRoutesHook, + onBeforeCreateRoutes: onBeforeCreateRoutesHook, + }, + setup: api => { + const mergedConfig = merge( + api.getRuntimeConfig().router || {}, + userConfig, + ) as RouterConfig; + + reactRouterPlugin(mergedConfig).setup?.(api as any); + }, + }; +}; + export default routerPlugin; export type { SingleRouteConfig, RouterConfig }; export type { RouterExtendsHooks } from './hooks'; -export { renderRoutes } from './utils'; -export { modifyRoutes } from './plugin'; +export { + createRouteObjectsFromConfig, + renderRoutes, + urlJoin, +} from './utils'; +export { modifyRoutes } from './plugin'; \ No newline at end of file diff --git a/packages/runtime/plugin-runtime/tests/router/entryOwnership.test.ts b/packages/runtime/plugin-runtime/tests/router/entryOwnership.test.ts new file mode 100644 index 000000000000..104f2204531e --- /dev/null +++ b/packages/runtime/plugin-runtime/tests/router/entryOwnership.test.ts @@ -0,0 +1,30 @@ +import type { Entrypoint } from '@modern-js/types'; +import { + getEntrypointRoutesDir, + modifyEntrypoints, +} from '../../src/router/cli/entry'; + +describe('router entry ownership metadata', () => { + test('tracks configured routesDir for custom source entries', () => { + const customEntry = { + isAutoMount: true, + isCustomSourceEntry: true, + fileSystemRoutes: { globalApp: '' }, + absoluteEntryDir: '/workspace/src/admin', + entry: '/workspace/src/admin', + } as unknown as Entrypoint; + + const [modified] = modifyEntrypoints([customEntry], 'views'); + + expect(modified.nestedRoutesEntry).toBe('/workspace/src/admin'); + expect(getEntrypointRoutesDir(modified as any)).toBe('views'); + }); + + test('falls back to nestedRoutesEntry basename without metadata', () => { + expect( + getEntrypointRoutesDir({ + nestedRoutesEntry: '/workspace/src/routes', + }), + ).toBe('routes'); + }); +}); diff --git a/packages/runtime/plugin-tanstack/package.json b/packages/runtime/plugin-tanstack/package.json new file mode 100644 index 000000000000..056a5887f5f7 --- /dev/null +++ b/packages/runtime/plugin-tanstack/package.json @@ -0,0 +1,105 @@ +{ + "name": "@modern-js/plugin-tanstack", + "description": "TanStack Router integration for Modern.js.", + "homepage": "https://modernjs.dev", + "bugs": "https://github.com/web-infra-dev/modern.js/issues", + "repository": { + "type": "git", + "url": "https://github.com/web-infra-dev/modern.js", + "directory": "packages/runtime/plugin-tanstack" + }, + "license": "MIT", + "keywords": [ + "react", + "framework", + "modern", + "modern.js", + "tanstack-router" + ], + "version": "3.0.5", + "engines": { + "node": ">=20" + }, + "types": "./dist/types/cli/index.d.ts", + "main": "./dist/cjs/cli/index.js", + "exports": { + ".": { + "types": "./dist/types/cli/index.d.ts", + "node": { + "import": "./dist/esm-node/cli/index.mjs", + "require": "./dist/cjs/cli/index.js" + }, + "default": "./dist/cjs/cli/index.js" + }, + "./package.json": "./package.json", + "./cli": { + "types": "./dist/types/cli/index.d.ts", + "node": { + "import": "./dist/esm-node/cli/index.mjs", + "require": "./dist/cjs/cli/index.js" + }, + "default": "./dist/cjs/cli/index.js" + }, + "./runtime": { + "types": "./dist/types/runtime/index.d.ts", + "node": { + "module": "./dist/esm/runtime/index.mjs" + }, + "default": "./dist/esm/runtime/index.mjs" + } + }, + "typesVersions": { + "*": { + ".": [ + "./dist/types/cli/index.d.ts" + ], + "cli": [ + "./dist/types/cli/index.d.ts" + ], + "runtime": [ + "./dist/types/runtime/index.d.ts" + ] + } + }, + "scripts": { + "dev": "rslib build --watch", + "prepublishOnly": "only-allow-pnpm", + "build": "rslib build", + "test": "rstest --passWithNoTests" + }, + "dependencies": { + "@modern-js/plugin": "workspace:*", + "@modern-js/runtime-utils": "workspace:*", + "@modern-js/types": "workspace:*", + "@modern-js/utils": "workspace:*", + "@swc/helpers": "^0.5.17", + "@tanstack/react-router": "1.161.4" + }, + "peerDependencies": { + "@modern-js/runtime": "workspace:^3.0.5", + "react": ">=17.0.2", + "react-dom": ">=17.0.2" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*", + "@modern-js/rslib": "workspace:*", + "@modern-js/runtime": "workspace:*", + "@scripts/rstest-config": "workspace:*", + "@rslib/core": "0.19.6", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", + "@tanstack/history": "1.161.4", + "@types/node": "^20", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "typescript": "^5" + }, + "sideEffects": false, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "types": "./dist/types/cli/index.d.ts" + } +} diff --git a/packages/runtime/plugin-tanstack/rslib.config.mts b/packages/runtime/plugin-tanstack/rslib.config.mts new file mode 100644 index 000000000000..ffbf32efb683 --- /dev/null +++ b/packages/runtime/plugin-tanstack/rslib.config.mts @@ -0,0 +1,4 @@ +import { rslibConfig } from '@modern-js/rslib'; +import { defineConfig } from '@rslib/core'; + +export default defineConfig(rslibConfig); diff --git a/packages/runtime/plugin-tanstack/rstest.config.ts b/packages/runtime/plugin-tanstack/rstest.config.ts new file mode 100644 index 000000000000..b31dda9ca697 --- /dev/null +++ b/packages/runtime/plugin-tanstack/rstest.config.ts @@ -0,0 +1,35 @@ +import type { ProjectConfig } from '@rstest/core'; +import { withTestPreset } from '@scripts/rstest-config'; + +const commonConfig: ProjectConfig = { + setupFiles: ['@scripts/rstest-config/setup.ts'], + globals: true, + tools: { + swc: { + jsc: { + transform: { + react: { + runtime: 'automatic', + }, + }, + }, + }, + }, +}; + +export default { + projects: [ + withTestPreset({ + name: 'plugin-tanstack-node', + testEnvironment: 'node', + include: ['tests/router/routeTree.test.ts'], + extends: commonConfig, + }), + withTestPreset({ + name: 'plugin-tanstack-client', + testEnvironment: 'happy-dom', + include: ['tests/router/dataMutation.test.tsx'], + extends: commonConfig, + }), + ], +}; diff --git a/packages/runtime/plugin-tanstack/src/cli.ts b/packages/runtime/plugin-tanstack/src/cli.ts new file mode 100644 index 000000000000..e5cfa103d59d --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/cli.ts @@ -0,0 +1,2 @@ +export * from './cli/index'; +export { default } from './cli/index'; diff --git a/packages/runtime/plugin-tanstack/src/cli/index.ts b/packages/runtime/plugin-tanstack/src/cli/index.ts new file mode 100644 index 000000000000..aa682855d577 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/cli/index.ts @@ -0,0 +1,255 @@ +import path from 'node:path'; +import type { AppTools, CliPlugin } from '@modern-js/app-tools'; +import type { + Entrypoint, + NestedRouteForCli, + PageRoute, + ServerRoute, +} from '@modern-js/types'; +import type { RouterConfig } from '@modern-js/runtime'; +import { + fs, + filterRoutesForServer, + findExists, + NESTED_ROUTE_SPEC_FILE, +} from '@modern-js/utils'; +import { + getEntrypointRoutesDir, + handleFileChange, + handleGeneratorEntryCode, + handleModifyEntrypoints, + isRouteEntry, +} from '@modern-js/runtime/cli'; +import { + writeTanstackRegisterFile, + writeTanstackRouterTypesForEntry, +} from './tanstackTypes'; + +const DEFAULT_TANSTACK_ROUTES_DIR = 'views'; +const DEFAULT_GENERATED_DIR_NAME = 'modern-tanstack'; +const TANSTACK_ENTRYPOINTS_KEY = '__tanstack_router_entries__'; +const TANSTACK_RUNTIME_MODULE = '@modern-js/plugin-tanstack/runtime'; +const JS_OR_TS_EXTS = [ + '.js', + '.jsx', + '.ts', + '.tsx', + '.mjs', + '.mts', + '.cjs', + '.cts', +] as const; + +function hasTanstackRouterConfigInRuntimeFile(runtimeConfigBase: string) { + const runtimeConfigFile = findExists( + JS_OR_TS_EXTS.map(ext => `${runtimeConfigBase}${ext}`), + ); + + if (!runtimeConfigFile) { + return false; + } + + try { + const content = fs.readFileSync(runtimeConfigFile, 'utf-8'); + return /tanstackRouter\s*:/.test(content); + } catch { + return false; + } +} + +type TanstackRouteEntrypointLike = { + entry?: string; + nestedRoutesEntry?: string; +}; + +function isTanstackRouteEntrypoint( + entrypoint: TanstackRouteEntrypointLike, + routesDir: string, +) { + const entrypointRoutesDir = getEntrypointRoutesDir(entrypoint); + if (entrypointRoutesDir) { + return entrypointRoutesDir === routesDir; + } + + if (entrypoint.nestedRoutesEntry) { + return path.basename(entrypoint.nestedRoutesEntry) === routesDir; + } + + return Boolean(entrypoint.entry && isRouteEntry(entrypoint.entry, routesDir)); +} + +export interface TanstackRouterPluginOptions extends Partial { + routesDir?: string; + generatedDirName?: string; +} + +export const tanstackRouterPlugin = ( + options: TanstackRouterPluginOptions = {}, +): CliPlugin => ({ + name: '@modern-js/plugin-tanstack', + required: ['@modern-js/runtime'], + setup: api => { + const nestedRoutesForServer: Record = {}; + const { metaName } = api.getAppContext(); + const routesDir = options.routesDir || DEFAULT_TANSTACK_ROUTES_DIR; + const generatedDirName = + options.generatedDirName || DEFAULT_GENERATED_DIR_NAME; + + api._internalRuntimePlugins(({ entrypoint, plugins }) => { + const { serverRoutes, srcDirectory, runtimeConfigFile } = api.getAppContext(); + const hasRuntimeTanstackConfig = hasTanstackRouterConfigInRuntimeFile( + path.join(srcDirectory, runtimeConfigFile), + ); + const { routesDir: _routesDir, generatedDirName: _generatedDirName, ...runtimeConfig } = + options; + const hasInlineRuntimeConfig = Object.keys(runtimeConfig).length > 0; + const serverBase = serverRoutes + .filter((route: ServerRoute) => route.entryName === entrypoint.entryName) + .map(route => route.urlPath) + .sort((left, right) => (left.length - right.length > 0 ? -1 : 1)); + + if ( + isTanstackRouteEntrypoint(entrypoint, routesDir) || + hasRuntimeTanstackConfig || + hasInlineRuntimeConfig + ) { + plugins.push({ + name: 'tanstackRouter', + path: `@${metaName}/plugin-tanstack/runtime`, + config: { + serverBase, + ...runtimeConfig, + }, + }); + } + + return { entrypoint, plugins }; + }); + + api.checkEntryPoint(({ path: entryPath, entry }) => { + return { + path: entryPath, + entry: entry || isRouteEntry(entryPath, routesDir), + }; + }); + + api.config(() => { + return { + source: { + include: [ + /[\\/]node_modules[\\/]@tanstack[\\/]react-router[\\/]/, + /[\\/]node_modules[\\/]@tanstack[\\/]history[\\/]/, + path.resolve(__dirname, '../runtime').replace('cjs', 'esm'), + ], + }, + }; + }); + + api.modifyEntrypoints(async ({ entrypoints }) => { + return { + entrypoints: await handleModifyEntrypoints(entrypoints, routesDir), + }; + }); + + api.generateEntryCode(async ({ entrypoints }) => { + await generateTanstackEntryCode(api, entrypoints, generatedDirName); + }); + + api.onFileChanged(async event => { + await handleFileChange(api, event, { + includeEntry: entrypoint => isTanstackRouteEntrypoint(entrypoint, routesDir), + regenerate: async ({ api, entrypoints }) => { + await generateTanstackEntryCode(api, entrypoints, generatedDirName); + }, + entrypointsKey: TANSTACK_ENTRYPOINTS_KEY, + }); + }); + + api.modifyFileSystemRoutes(({ entrypoint, routes }) => { + if (isTanstackRouteEntrypoint(entrypoint, routesDir)) { + nestedRoutesForServer[entrypoint.entryName] = filterRoutesForServer( + routes as (NestedRouteForCli | PageRoute)[], + ); + } + + return { + entrypoint, + routes, + }; + }); + + api.onBeforeGenerateRoutes(async ({ entrypoint, code }) => { + if (isTanstackRouteEntrypoint(entrypoint, routesDir)) { + const { distDirectory } = api.getAppContext(); + + const nestedRoutesSpecPath = path.resolve( + distDirectory, + NESTED_ROUTE_SPEC_FILE, + ); + const existingNestedRoutes = (await fs.pathExists(nestedRoutesSpecPath)) + ? ((await fs.readJSON(nestedRoutesSpecPath)) as Record) + : {}; + + await fs.outputJSON(nestedRoutesSpecPath, { + ...existingNestedRoutes, + ...nestedRoutesForServer, + }); + } + + return { + entrypoint, + code, + }; + }); + }, +}); + +async function generateTanstackEntryCode( + api: Parameters['setup']>>[0], + entrypoints: Entrypoint[], + generatedDirName: string, +) { + const appContext = api.getAppContext(); + const routesByEntry = await handleGeneratorEntryCode( + api, + entrypoints, + TANSTACK_ENTRYPOINTS_KEY, + ); + + await writeTanstackRegisterFile({ + appContext, + entrypoints, + generatedDirName, + runtimeModule: TANSTACK_RUNTIME_MODULE, + }); + + await Promise.all( + entrypoints.map(async entrypoint => { + const entryName = entrypoint.entryName; + const routes = routesByEntry[entryName]; + const outPath = path.join( + appContext.srcDirectory, + generatedDirName, + entryName, + 'router.gen.ts', + ); + + if (routes?.length) { + await writeTanstackRouterTypesForEntry({ + appContext, + entryName, + routes, + generatedDirName, + runtimeModule: TANSTACK_RUNTIME_MODULE, + }); + return; + } + + if (await fs.pathExists(outPath)) { + await fs.remove(outPath); + } + }), + ); +} + +export default tanstackRouterPlugin; diff --git a/packages/runtime/plugin-tanstack/src/cli/tanstackTypes.ts b/packages/runtime/plugin-tanstack/src/cli/tanstackTypes.ts new file mode 100644 index 000000000000..462cc8a42c64 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/cli/tanstackTypes.ts @@ -0,0 +1,467 @@ +import path from 'path'; +import type { AppToolsContext } from '@modern-js/app-tools'; +import type { Entrypoint, NestedRouteForCli, PageRoute } from '@modern-js/types'; +import { findExists, formatImportPath, fs, slash } from '@modern-js/utils'; + +const JS_OR_TS_EXTS = [ + '.js', + '.jsx', + '.ts', + '.tsx', + '.mjs', + '.mts', + '.cjs', + '.cts', +] as const; + +async function resolveFileNoExt(inputNoExtPath: string) { + const file = findExists(JS_OR_TS_EXTS.map(ext => `${inputNoExtPath}${ext}`)); + return file ? getPathWithoutExt(file) : null; +} + +function quote(str: string) { + return JSON.stringify(str); +} + +function normalizeRelativeImport(p: string) { + const normalized = formatImportPath(slash(p)); + if (normalized.startsWith('.')) { + return normalized; + } + return `./${normalized}`; +} + +function getPathWithoutExt(filename: string) { + const extname = path.extname(filename); + return extname ? filename.slice(0, -extname.length) : filename; +} + +const reservedWords = + 'break case class catch const continue debugger default delete do else export extends finally for function if import in instanceof let new return super switch this throw try typeof var void while with yield enum await implements package protected static interface private public'; +const builtins = + 'arguments Infinity NaN undefined null true false eval uneval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Symbol Error EvalError InternalError RangeError ReferenceError SyntaxError TypeError URIError Number Math Date String RegExp Array Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array Uint32Array Float32Array Float64Array Map Set WeakMap WeakSet SIMD ArrayBuffer DataView JSON Promise Generator GeneratorFunction Reflect Proxy Intl'; +const forbidList = new Set(`${reservedWords} ${builtins}`.split(' ')); + +function makeLegalIdentifier(str: string) { + const identifier = str + .replace(/-(\w)/g, (_, letter) => letter.toUpperCase()) + .replace(/[^$_a-zA-Z0-9]/g, '_'); + + if (/\d/.test(identifier[0]) || forbidList.has(identifier)) { + return `_${identifier}`; + } + return identifier || '_'; +} + +function pickModernLoaderModule(route: NestedRouteForCli | PageRoute) { + const loaderPath = (route as any).data || (route as any).loader; + if (!loaderPath || typeof loaderPath !== 'string') { + return null; + } + + const inline = Boolean((route as any).data); + return { loaderPath, inline }; +} + +function isPathlessLayout(route: NestedRouteForCli | PageRoute) { + return ( + (route as any).type === 'nested' && + typeof (route as any).index !== 'boolean' && + typeof (route as any).path === 'undefined' + ); +} + +function isIndexRoute(route: NestedRouteForCli | PageRoute) { + return (route as any).type === 'nested' && Boolean((route as any).index); +} + +function toTanstackPath(pathname: string): string { + return pathname + .split('/') + .map(segment => { + if (!segment) { + return segment; + } + if (segment === '*') { + return '$'; + } + if (segment.startsWith(':')) { + const name = segment.slice(1); + if (name.endsWith('?')) { + return `{-$${name.slice(0, -1)}}`; + } + return `$${name}`; + } + return segment; + }) + .join('/'); +} + +async function writeFileIfChanged(filePath: string, content: string) { + try { + const prev = (await fs.pathExists(filePath)) + ? await fs.readFile(filePath, 'utf-8') + : null; + if (prev !== content) { + await fs.outputFile(filePath, content, 'utf-8'); + } + } catch { + await fs.outputFile(filePath, content, 'utf-8'); + } +} + +export async function writeTanstackRegisterFile(opts: { + appContext: AppToolsContext; + entrypoints: Entrypoint[]; + generatedDirName: string; + runtimeModule: string; +}) { + const { appContext, entrypoints, generatedDirName, runtimeModule } = opts; + const allEntries = Array.from( + new Set(entrypoints.map(entrypoint => entrypoint.entryName).filter(Boolean)), + ); + const mainEntry = entrypoints.find(entrypoint => entrypoint.isMainEntry)?.entryName; + + const registerEntries = allEntries.sort((left, right) => { + if (mainEntry && left === mainEntry) { + return -1; + } + + if (mainEntry && right === mainEntry) { + return 1; + } + + return left.localeCompare(right); + }); + + const registerDtsPath = path.join( + appContext.srcDirectory, + generatedDirName, + 'register.gen.d.ts', + ); + + if (registerEntries.length === 0) { + if (await fs.pathExists(registerDtsPath)) { + await fs.remove(registerDtsPath); + } + return; + } + + const importStatements = registerEntries + .map( + (entryName, index) => + `import type { router as router${index} } from './${entryName}/router.gen';`, + ) + .join('\n'); + const routerUnionType = registerEntries + .map((_, index) => `typeof router${index}`) + .join(' | '); + const registerContent = `// This file is auto-generated by Modern.js. Do not edit manually. + +${importStatements} + +declare module '${runtimeModule}' { + interface Register { + router: ${routerUnionType}; + } +} +`; + + await writeFileIfChanged(registerDtsPath, registerContent); +} + +export async function writeTanstackRouterTypesForEntry(opts: { + appContext: AppToolsContext; + entryName: string; + routes: (NestedRouteForCli | PageRoute)[]; + generatedDirName: string; + runtimeModule: string; +}) { + const { appContext, entryName, routes, generatedDirName, runtimeModule } = opts; + const { routerGenTs } = await generateTanstackRouterTypesSourceForEntry({ + appContext, + entryName, + routes, + runtimeModule, + generatedDirName, + }); + + const outPath = path.join( + appContext.srcDirectory, + generatedDirName, + entryName, + 'router.gen.ts', + ); + await writeFileIfChanged(outPath, routerGenTs); +} + +async function generateTanstackRouterTypesSourceForEntry(opts: { + appContext: AppToolsContext; + entryName: string; + routes: (NestedRouteForCli | PageRoute)[]; + runtimeModule: string; + generatedDirName: string; +}): Promise<{ routerGenTs: string }> { + const { appContext, entryName, routes, runtimeModule, generatedDirName } = opts; + const outDir = path.join(appContext.srcDirectory, generatedDirName, entryName); + + const rootModern = routes.find( + route => route && (route as any).type === 'nested' && (route as any).isRoot, + ) as NestedRouteForCli | undefined; + + const topLevel = rootModern + ? ((rootModern as any).children as Array) || [] + : routes; + + const imports: string[] = []; + const statements: string[] = []; + + const loaderImportMap = new Map(); + let loaderIndex = 0; + let routeIndex = 0; + + const getImportNameForLoader = async ( + aliasedNoExtPath: string, + inline: boolean, + ) => { + const key = `${inline ? 'inline' : 'default'}:${aliasedNoExtPath}`; + const existing = loaderImportMap.get(key); + if (existing) { + return existing; + } + + const prefix = `${appContext.internalSrcAlias}/`; + let absNoExt: string | null = null; + if (aliasedNoExtPath.startsWith(prefix)) { + const rel = aliasedNoExtPath.slice(prefix.length); + absNoExt = path.join(appContext.srcDirectory, rel); + } else if (path.isAbsolute(aliasedNoExtPath)) { + absNoExt = aliasedNoExtPath; + } else { + absNoExt = path.join(appContext.srcDirectory, aliasedNoExtPath); + } + + const resolvedNoExt = await resolveFileNoExt(absNoExt); + if (!resolvedNoExt) { + return null; + } + + const relImport = normalizeRelativeImport(path.relative(outDir, resolvedNoExt)); + const importName = `loader_${loaderIndex++}`; + + if (inline) { + imports.push(`import { loader as ${importName} } from ${quote(relImport)};`); + } else { + imports.push(`import ${importName} from ${quote(relImport)};`); + } + + loaderImportMap.set(key, importName); + return importName; + }; + + const createRouteVarName = (route: NestedRouteForCli | PageRoute) => { + const id = (route as any).id as string | undefined; + const base = id ? makeLegalIdentifier(id) : `r_${routeIndex++}`; + return `route_${base}`; + }; + + const buildRoute = async (opts: { + parentVar: string; + route: NestedRouteForCli | PageRoute; + }): Promise => { + const { parentVar, route } = opts; + const varName = createRouteVarName(route); + const loaderInfo = pickModernLoaderModule(route); + const loaderName = loaderInfo + ? await getImportNameForLoader(loaderInfo.loaderPath, loaderInfo.inline) + : null; + const rawPath = (route as any).path as string | undefined; + const hasSplat = typeof rawPath === 'string' && rawPath.includes('*'); + + const routeOptions: string[] = [`getParentRoute: () => ${parentVar},`]; + + if (isPathlessLayout(route)) { + const id = (route as any).id as string | undefined; + routeOptions.push(`id: ${quote(id || 'pathless')},`); + } else { + const pathname = isIndexRoute(route) ? '/' : toTanstackPath(rawPath || ''); + routeOptions.push(`path: ${quote(pathname)},`); + } + + if (loaderName) { + routeOptions.push( + `loader: modernLoaderToTanstack({ hasSplat: ${hasSplat} }, ${loaderName}),`, + ); + } + + statements.push( + `const ${varName} = createRoute({\n ${routeOptions.join('\n ')}\n});`, + ); + + const children = (route as any).children as + | Array + | undefined; + if (children && children.length > 0) { + const childVars = await Promise.all( + children.map(child => buildRoute({ parentVar: varName, route: child })), + ); + statements.push(`${varName}.addChildren([${childVars.join(', ')}]);`); + } + + return varName; + }; + + const rootLoaderInfo = rootModern ? pickModernLoaderModule(rootModern) : null; + const rootLoaderName = rootLoaderInfo?.loaderPath + ? await getImportNameForLoader( + rootLoaderInfo.loaderPath, + rootLoaderInfo.inline, + ) + : null; + + const topLevelVars = await Promise.all( + topLevel.map(route => buildRoute({ parentVar: 'rootRoute', route })), + ); + + const rootOptions: string[] = []; + if (rootLoaderName) { + rootOptions.push( + `loader: modernLoaderToTanstack({ hasSplat: false }, ${rootLoaderName}),`, + ); + } + + const routerGenTs = `/* eslint-disable */ +// This file is auto-generated by Modern.js. Do not edit manually. + +import { + createMemoryHistory, + createRootRouteWithContext, + createRoute, + createRouter, + notFound, + redirect, +} from '${runtimeModule}'; + +type ModernRouterContext = { + request?: Request; + requestContext?: unknown; +}; + +function isResponse(value: unknown): value is Response { + return ( + value != null && + typeof value === 'object' && + typeof (value as any).status === 'number' && + typeof (value as any).headers === 'object' + ); +} + +const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); +function isRedirectResponse(res: Response) { + return redirectStatusCodes.has(res.status); +} + +function throwTanstackRedirect(location: string) { + const target = location || '/'; + try { + void new URL(target); + throw redirect({ href: target }); + } catch { + throw redirect({ to: target }); + } +} + +function mapParamsForModernLoader(params: Record, hasSplat: boolean) { + if (!hasSplat) { + return params; + } + + const { _splat, ...rest } = params as any; + if (typeof _splat !== 'undefined') { + return { ...rest, '*': _splat }; + } + return rest; +} + +function modernLoaderToTanstack any>( + opts: { hasSplat: boolean }, + modernLoader: TLoader, +) { + type LoaderResult = Awaited>; + + return async (ctx: any): Promise => { + try { + const signal: AbortSignal = + ctx?.abortController?.signal || + ctx?.signal || + new AbortController().signal; + const baseRequest: Request | undefined = + ctx?.context?.request instanceof Request ? ctx.context.request : undefined; + + const href = + typeof ctx?.location === 'string' + ? ctx.location + : ctx?.location?.publicHref || + ctx?.location?.href || + ctx?.location?.url?.href || + ''; + + const request = baseRequest + ? new Request(baseRequest, { signal }) + : new Request(href, { signal }); + + const params = mapParamsForModernLoader(ctx?.params || {}, opts.hasSplat); + + const result = await (modernLoader as any)({ + request, + params, + context: ctx?.context?.requestContext, + }); + + if (isResponse(result)) { + if (isRedirectResponse(result)) { + const location = result.headers.get('Location') || '/'; + throwTanstackRedirect(location); + } + if (result.status === 404) { + throw notFound(); + } + } + + return result as LoaderResult; + } catch (err) { + if (isResponse(err)) { + if (isRedirectResponse(err)) { + const location = err.headers.get('Location') || '/'; + throwTanstackRedirect(location); + } + if (err.status === 404) { + throw notFound(); + } + } + throw err; + } + }; +} + +${imports.join('\n')} + +export const rootRoute = createRootRouteWithContext()({ + ${rootOptions.join('\n ')} +}); + +${statements.join('\n\n')} + +export const routeTree = rootRoute.addChildren([${topLevelVars.join(', ')}]); + +export const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/'], + }), + context: {} as ModernRouterContext, +}); +`; + + return { routerGenTs }; +} diff --git a/packages/runtime/plugin-tanstack/src/runtime.ts b/packages/runtime/plugin-tanstack/src/runtime.ts new file mode 100644 index 000000000000..6d764da62977 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime.ts @@ -0,0 +1 @@ +export * from './runtime/index'; diff --git a/packages/runtime/plugin-tanstack/src/runtime/DefaultNotFound.tsx b/packages/runtime/plugin-tanstack/src/runtime/DefaultNotFound.tsx new file mode 100644 index 000000000000..cfe6152ef21f --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/DefaultNotFound.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +export const DefaultNotFound = () => ( +
+ 404 +
+); diff --git a/packages/runtime/plugin-tanstack/src/runtime/basepathRewrite.ts b/packages/runtime/plugin-tanstack/src/runtime/basepathRewrite.ts new file mode 100644 index 000000000000..69875e6eaafb --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/basepathRewrite.ts @@ -0,0 +1,59 @@ +function normalizeBasepath(basepath: string): string { + if (!basepath) { + return '/'; + } + + let normalized = basepath.startsWith('/') ? basepath : `/${basepath}`; + if (normalized.length > 1 && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + + return normalized || '/'; +} + +export function createModernBasepathRewrite( + basepath: string, + caseSensitive = false, +) { + const normalizedBasepath = normalizeBasepath(basepath); + if (normalizedBasepath === '/') { + return undefined; + } + + const normalizedBasepathWithSlash = `${normalizedBasepath}/`; + const checkBasepath = caseSensitive + ? normalizedBasepath + : normalizedBasepath.toLowerCase(); + const checkBasepathWithSlash = caseSensitive + ? normalizedBasepathWithSlash + : normalizedBasepathWithSlash.toLowerCase(); + + return { + input: ({ url }: { url: URL }) => { + const pathname = caseSensitive + ? url.pathname + : url.pathname.toLowerCase(); + + if (pathname === checkBasepath) { + url.pathname = '/'; + } else if (pathname.startsWith(checkBasepathWithSlash)) { + url.pathname = url.pathname.slice(normalizedBasepath.length) || '/'; + } + + return url; + }, + output: ({ url }: { url: URL }) => { + const pathname = url.pathname || '/'; + + // Unlike TanStack Router's built-in `basepath` rewrite, avoid adding an + // extra trailing slash for the base-path root. + if (pathname === '/') { + url.pathname = normalizedBasepath; + } else { + url.pathname = `${normalizedBasepath}${pathname.startsWith('/') ? '' : '/'}${pathname}`; + } + + return url; + }, + }; +} diff --git a/packages/runtime/plugin-tanstack/src/runtime/dataMutation.tsx b/packages/runtime/plugin-tanstack/src/runtime/dataMutation.tsx new file mode 100644 index 000000000000..905c22e23834 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/dataMutation.tsx @@ -0,0 +1,517 @@ +import { useRouter } from '@tanstack/react-router'; +import type { AnyRouter } from '@tanstack/react-router'; +import type React from 'react'; +import { useCallback, useRef, useState } from 'react'; + +type SubmitTarget = + | HTMLFormElement + | FormData + | URLSearchParams + | Record; +type SubmitterElement = HTMLButtonElement | HTMLInputElement; + +export type SubmitOptions = { + action?: string; + method?: string; + encType?: string; +}; + +export type FetcherState = 'idle' | 'submitting' | 'loading'; + +export class RouteActionResponseError extends Error { + readonly response: Response; + readonly data: TData; + + constructor(response: Response, data: TData) { + super(`Route action failed with status ${response.status}`); + this.name = 'RouteActionResponseError'; + this.response = response; + this.data = data; + } +} + +type RouteAction = (args: { + request: Request; + params: Record; + context?: unknown; +}) => Promise | unknown; + +type RouteLoader = (args: { + request: Request; + params: Record; + context?: unknown; +}) => Promise | unknown; + +function formDataToUrlSearchParams(formData: FormData) { + const searchParams = new URLSearchParams(); + formData.forEach((value, key) => { + if (typeof value === 'string') { + searchParams.append(key, value); + } + }); + return searchParams; +} + +function formDataToTextPlain(formData: FormData) { + return Array.from(formData.entries()) + .map(([key, value]) => `${key}=${String(value)}`) + .join('\n'); +} + +function toFormData(target: SubmitTarget): FormData { + if (target instanceof HTMLFormElement) { + return new FormData(target); + } + + if (target instanceof FormData) { + return target; + } + + if (target instanceof URLSearchParams) { + const formData = new FormData(); + target.forEach((value, key) => { + formData.append(key, value); + }); + return formData; + } + + const formData = new FormData(); + Object.entries(target).forEach(([key, value]) => { + if (typeof value === 'undefined' || value === null) { + return; + } + formData.append(key, String(value)); + }); + return formData; +} + +function getSubmitter(event: React.FormEvent) { + const nativeEvent = event.nativeEvent as SubmitEvent | undefined; + const submitter = nativeEvent?.submitter; + if ( + submitter instanceof HTMLButtonElement || + submitter instanceof HTMLInputElement + ) { + return submitter; + } + return null; +} + +function createFormDataFromSubmit({ + form, + submitter, +}: { + form: HTMLFormElement; + submitter: SubmitterElement | null; +}) { + if (submitter) { + try { + return new FormData(form, submitter); + } catch {} + } + return new FormData(form); +} + +function resolveSubmitOptionsFromForm({ + form, + submitter, + action, + method, + encType, +}: { + form: HTMLFormElement; + submitter: SubmitterElement | null; + action?: string; + method?: string; + encType?: string; +}): Required { + const resolvedAction = + submitter?.getAttribute('formaction') || + action || + form.getAttribute('action') || + '.'; + const resolvedMethod = ( + submitter?.getAttribute('formmethod') || + method || + form.getAttribute('method') || + 'get' + ).toLowerCase(); + const resolvedEncType = + submitter?.getAttribute('formenctype') || + encType || + form.getAttribute('enctype') || + 'application/x-www-form-urlencoded'; + + return { + action: resolvedAction, + method: resolvedMethod, + encType: resolvedEncType, + }; +} + +function resolveRouteHandlers(router: AnyRouter, actionTo: string) { + const builtLocation = router.buildLocation({ + to: actionTo as any, + } as any); + const href = router.getParsedLocationHref(builtLocation as any); + const matchedRoutes = router.getMatchedRoutes( + (builtLocation as any).pathname, + ); + const routeStaticData = matchedRoutes.foundRoute?.options?.staticData as + | Record + | undefined; + const action = routeStaticData?.modernRouteAction as RouteAction | undefined; + const loader = routeStaticData?.modernRouteLoader as RouteLoader | undefined; + + return { + action, + loader, + href, + params: (matchedRoutes.routeParams || {}) as Record, + }; +} + +function isRedirectResponse(value: unknown): value is Response { + if (!(value instanceof Response)) { + return false; + } + return [301, 302, 303, 307, 308].includes(value.status); +} + +async function parseResponseData(response: Response) { + if (response.status === 204) { + return null; + } + + const contentType = response.headers.get('Content-Type') || ''; + if (contentType.includes('application/json')) { + return response.json(); + } + return response.text(); +} + +async function parseResponseResultOrThrow(response: Response) { + const parsed = await parseResponseData(response); + if (!response.ok) { + throw new RouteActionResponseError(response, parsed); + } + return parsed; +} + +async function submitRouteAction({ + router, + target, + options = {}, + isFetcher = false, + onInvalidateStart, +}: { + router: AnyRouter; + target: SubmitTarget; + options?: SubmitOptions; + isFetcher?: boolean; + onInvalidateStart?: () => void; +}) { + const method = (options.method || 'post').toLowerCase(); + const encType = options.encType || 'application/x-www-form-urlencoded'; + const actionTo = options.action || '.'; + const formData = toFormData(target); + const resolved = resolveRouteHandlers(router, actionTo); + + if (method === 'get') { + const search = formDataToUrlSearchParams(formData).toString(); + const requestUrl = new URL(resolved.href, window.location.origin); + requestUrl.search = search; + + if (isFetcher && resolved.loader) { + const result = await resolved.loader({ + request: new Request(requestUrl, { + method: 'GET', + }), + params: resolved.params, + }); + + if (result instanceof Response) { + const redirectTo = + result.headers.get('X-Modernjs-Redirect') || + result.headers.get('Location'); + if (redirectTo || isRedirectResponse(result)) { + await router.navigate({ + to: (redirectTo || '/') as any, + } as any); + return parseResponseData(result); + } + return parseResponseResultOrThrow(result); + } + + return result; + } + + await router.navigate({ + href: search ? `${resolved.href}?${search}` : resolved.href, + } as any); + return; + } + + if (!resolved.action) { + throw new Error(`No route action found for "${actionTo}"`); + } + + const headers = new Headers(); + let body: BodyInit | null = null; + if (encType.includes('application/json')) { + headers.set('Content-Type', 'application/json'); + body = JSON.stringify( + Object.fromEntries(formDataToUrlSearchParams(formData).entries()), + ); + } else if (encType.includes('text/plain')) { + headers.set('Content-Type', 'text/plain;charset=UTF-8'); + body = formDataToTextPlain(formData); + } else if (encType.includes('application/x-www-form-urlencoded')) { + headers.set( + 'Content-Type', + 'application/x-www-form-urlencoded;charset=UTF-8', + ); + body = formDataToUrlSearchParams(formData); + } else { + body = formData; + } + + const request = new Request(new URL(resolved.href, window.location.origin), { + method: method.toUpperCase(), + headers, + body, + }); + + const result = await resolved.action({ + request, + params: resolved.params, + }); + + if (result instanceof Response) { + const redirectTo = + result.headers.get('X-Modernjs-Redirect') || + result.headers.get('Location'); + if (redirectTo || isRedirectResponse(result)) { + await router.navigate({ + to: (redirectTo || '/') as any, + } as any); + return parseResponseData(result); + } + + const parsed = isFetcher + ? await parseResponseResultOrThrow(result) + : await parseResponseData(result); + onInvalidateStart?.(); + await router.invalidate(); + return parsed; + } + + onInvalidateStart?.(); + await router.invalidate(); + return result; +} + +export type FormProps = Omit< + React.FormHTMLAttributes, + 'onSubmit' | 'action' +> & { + action?: string; + onSubmit?: React.FormEventHandler; + reloadDocument?: boolean; +}; + +export function Form({ + action, + method = 'get', + encType, + reloadDocument, + onSubmit, + ...rest +}: FormProps) { + const router = useRouter(); + + const handleSubmit = useCallback( + async (event: React.FormEvent) => { + onSubmit?.(event); + if (event.defaultPrevented || reloadDocument) { + return; + } + + event.preventDefault(); + const submitter = getSubmitter(event); + const formData = createFormDataFromSubmit({ + form: event.currentTarget, + submitter, + }); + const normalizedOptions = resolveSubmitOptionsFromForm({ + form: event.currentTarget, + submitter, + action, + method, + encType, + }); + await submitRouteAction({ + router, + target: formData, + options: normalizedOptions, + }); + }, + [action, encType, method, onSubmit, reloadDocument, router], + ); + + return ( +
+ ); +} + +export type FetcherSubmitOptions = SubmitOptions; + +export type Fetcher = { + state: FetcherState; + data: unknown; + error: unknown; + Form: React.ComponentType; + submit: ( + target: SubmitTarget, + options?: FetcherSubmitOptions, + ) => Promise; +}; + +export function useFetcher(): Fetcher { + const router = useRouter(); + const [state, setState] = useState('idle'); + const [data, setData] = useState(undefined); + const [error, setError] = useState(undefined); + const requestStatesRef = useRef>>( + new Map(), + ); + const requestIdRef = useRef(0); + + const syncStateFromRequests = useCallback(() => { + let hasSubmitting = false; + let hasLoading = false; + + requestStatesRef.current.forEach(requestState => { + if (requestState === 'submitting') { + hasSubmitting = true; + } else if (requestState === 'loading') { + hasLoading = true; + } + }); + + if (hasSubmitting) { + setState('submitting'); + return; + } + if (hasLoading) { + setState('loading'); + return; + } + setState('idle'); + }, []); + + const setRequestState = useCallback( + (requestId: number, requestState: Exclude) => { + requestStatesRef.current.set(requestId, requestState); + syncStateFromRequests(); + }, + [syncStateFromRequests], + ); + + const clearRequestState = useCallback( + (requestId: number) => { + requestStatesRef.current.delete(requestId); + syncStateFromRequests(); + }, + [syncStateFromRequests], + ); + + const submit = useCallback( + async (target: SubmitTarget, options?: FetcherSubmitOptions) => { + setError(undefined); + const requestId = ++requestIdRef.current; + const normalizedMethod = (options?.method || 'post').toLowerCase(); + const isLoaderSubmit = normalizedMethod === 'get'; + setRequestState(requestId, isLoaderSubmit ? 'loading' : 'submitting'); + + try { + const result = await submitRouteAction({ + router, + target, + options, + isFetcher: true, + onInvalidateStart: () => { + if (!isLoaderSubmit) { + setRequestState(requestId, 'loading'); + } + }, + }); + setData(result); + } catch (err) { + setError(err); + throw err; + } finally { + clearRequestState(requestId); + } + }, + [clearRequestState, router, setRequestState], + ); + + const FetcherForm = useCallback( + ({ + action, + method = 'get', + encType, + reloadDocument, + onSubmit, + ...rest + }: FormProps) => { + const handleSubmit = async (event: React.FormEvent) => { + onSubmit?.(event); + if (event.defaultPrevented || reloadDocument) { + return; + } + + event.preventDefault(); + const submitter = getSubmitter(event); + const formData = createFormDataFromSubmit({ + form: event.currentTarget, + submitter, + }); + const normalizedOptions = resolveSubmitOptionsFromForm({ + form: event.currentTarget, + submitter, + action, + method, + encType, + }); + await submit(formData, normalizedOptions); + }; + + return ( + + ); + }, + [submit], + ); + + return { + state, + data, + error, + Form: FetcherForm, + submit, + }; +} diff --git a/packages/runtime/plugin-tanstack/src/runtime/hooks.ts b/packages/runtime/plugin-tanstack/src/runtime/hooks.ts new file mode 100644 index 000000000000..50b3406cc605 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/hooks.ts @@ -0,0 +1,14 @@ +import { createSyncHook } from '@modern-js/plugin'; +import type { RouteObject } from '@modern-js/runtime/router'; +import type { TRuntimeContext } from '@modern-js/runtime'; + +const modifyRoutes = createSyncHook<(routes: RouteObject[]) => RouteObject[]>(); +const onBeforeCreateRoutes = + createSyncHook<(context: TRuntimeContext) => void>(); + +export { modifyRoutes, onBeforeCreateRoutes }; + +export type TanstackRouterExtendsHooks = { + modifyRoutes: typeof modifyRoutes; + onBeforeCreateRoutes: typeof onBeforeCreateRoutes; +}; diff --git a/packages/runtime/plugin-tanstack/src/runtime/index.tsx b/packages/runtime/plugin-tanstack/src/runtime/index.tsx new file mode 100644 index 000000000000..ef51e45294c7 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/index.tsx @@ -0,0 +1,25 @@ +export * from '@tanstack/react-router'; +export { useMatch } from '@tanstack/react-router'; +export { Link, NavLink } from './prefetchLink'; +export { + Form, + RouteActionResponseError, + useFetcher, +} from './dataMutation'; +export { tanstackRouterPlugin } from './plugin'; +export type { + LinkProps, + NavLinkProps, + PrefetchBehavior, +} from './prefetchLink'; +export type { + Fetcher, + FetcherState, + FetcherSubmitOptions, + FormProps, + SubmitOptions, +} from './dataMutation'; +export type { + TanstackRouterExtendsHooks, +} from './hooks'; +export type { TanstackRouterRuntimeConfig } from './plugin'; diff --git a/packages/runtime/plugin-tanstack/src/runtime/plugin.tsx b/packages/runtime/plugin-tanstack/src/runtime/plugin.tsx new file mode 100644 index 000000000000..eb0e0f0d5705 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/plugin.tsx @@ -0,0 +1,226 @@ +import { merge } from '@modern-js/runtime-utils/merge'; +import { + RouterProvider, + createBrowserHistory, + createHashHistory, + createMemoryHistory, + createRouter, + useLocation, + useMatches, + useNavigate, + useRouter, +} from '@tanstack/react-router'; +import type { RouteObject } from '@modern-js/runtime/router'; +import { + type RouterConfig, + type RuntimePlugin, + type TRuntimeContext, +} from '@modern-js/runtime'; +import { + InternalRuntimeContext, + getGlobalLayoutApp, + getGlobalRoutes, +} from '@modern-js/runtime/context'; +import { + createRouteObjectsFromConfig, + urlJoin, +} from '@modern-js/runtime/router/internal'; +import * as React from 'react'; +import { useContext, useMemo } from 'react'; +import { createModernBasepathRewrite } from './basepathRewrite'; +import { + modifyRoutes as modifyRoutesHook, + onBeforeCreateRoutes as onBeforeCreateRoutesHook, +} from './hooks'; +import type { TanstackRouterExtendsHooks } from './hooks'; +import { createRouteTreeFromRouteObjects } from './routeTree'; + +function normalizeBase(base: string) { + if (base.length > 1 && base.endsWith('/')) return base.slice(0, -1); + return base || '/'; +} + +function isSegmentPrefix(pathname: string, base: string) { + const normalizedBase = normalizeBase(base); + const normalizedPathname = pathname || '/'; + return ( + normalizedPathname === normalizedBase || + normalizedPathname.startsWith(`${normalizedBase}/`) + ); +} + +function stripSyntheticNotFoundRoute(routes: RouteObject[]): RouteObject[] { + return routes + .filter(route => !(route.path === '*' && !route.id && !route.loader)) + .map(route => { + if (!route.children?.length) { + return route; + } + return { + ...route, + children: stripSyntheticNotFoundRoute(route.children), + }; + }); +} + +export interface TanstackRouterRuntimeConfig extends Partial { + routesDir?: string; +} + +export const tanstackRouterPlugin = ( + userConfig: TanstackRouterRuntimeConfig = {}, +): RuntimePlugin<{ + extendHooks: TanstackRouterExtendsHooks; +}> => { + return { + name: '@modern-js/plugin-tanstack', + registryHooks: { + modifyRoutes: modifyRoutesHook, + onBeforeCreateRoutes: onBeforeCreateRoutesHook, + }, + setup: api => { + api.onBeforeRender(context => { + context.router = { + useMatches, + useLocation, + useNavigate, + useRouter, + }; + }); + + api.wrapRoot(App => { + const runtimeConfig = api.getRuntimeConfig() as Record; + const mergedConfig = merge( + runtimeConfig.tanstackRouter || {}, + userConfig, + ) as RouterConfig; + + const { + serverBase = [], + supportHtml5History = true, + basename = '', + routesConfig, + createRoutes, + } = mergedConfig; + + const finalRouteConfig = { + routes: getGlobalRoutes(), + globalApp: getGlobalLayoutApp(), + ...routesConfig, + }; + + if (!finalRouteConfig.routes && !createRoutes) { + return App; + } + + const hooks = api.getHooks() as any; + let cachedRouteObjects: RouteObject[] | undefined; + + const getRouteObjects = (context: TRuntimeContext) => { + if (typeof cachedRouteObjects !== 'undefined') { + return cachedRouteObjects; + } + + hooks.onBeforeCreateRoutes.call(context); + const routeObjects = createRoutes + ? createRoutes() + : createRouteObjectsFromConfig({ + routesConfig: finalRouteConfig, + }) || []; + + const normalizedRouteObjects = createRoutes + ? routeObjects + : stripSyntheticNotFoundRoute(routeObjects); + + cachedRouteObjects = hooks.modifyRoutes.call( + normalizedRouteObjects, + ) as RouteObject[]; + return cachedRouteObjects; + }; + + const selectBasePath = (pathname: string) => { + const match = serverBase.find(baseUrl => + isSegmentPrefix(pathname, baseUrl), + ); + return match || '/'; + }; + + let cachedRouteTree: any = null; + let cachedRouter: any = null; + let cachedRouterBasepath: string | null = null; + + const RouterWrapper = () => { + const runtimeContext = useContext( + InternalRuntimeContext, + ) as unknown as TRuntimeContext & { + _internalRouterBaseName?: string; + }; + + const isBrowser = typeof window !== 'undefined'; + const requestPathname = + runtimeContext.request instanceof Request + ? new URL(runtimeContext.request.url).pathname + : '/'; + const pathname = isBrowser ? location.pathname : requestPathname; + const baseUrl = selectBasePath(pathname).replace(/^[\\/]*/, '/'); + const resolvedBasename = + baseUrl === '/' + ? urlJoin( + baseUrl, + runtimeContext._internalRouterBaseName || basename || '', + ) + : baseUrl; + + const routeTree = useMemo(() => { + if (cachedRouteTree) { + return cachedRouteTree; + } + + const routeObjects = getRouteObjects(runtimeContext); + if (!routeObjects.length) { + return null; + } + + cachedRouteTree = createRouteTreeFromRouteObjects(routeObjects); + return cachedRouteTree; + }, [runtimeContext]); + + if (!routeTree) { + return App ? : null; + } + + const router = useMemo(() => { + if (cachedRouter && cachedRouterBasepath === resolvedBasename) { + return cachedRouter; + } + + const history = isBrowser + ? supportHtml5History + ? createBrowserHistory() + : createHashHistory() + : createMemoryHistory({ + initialEntries: [pathname || '/'], + }); + const rewrite = createModernBasepathRewrite(resolvedBasename); + + cachedRouter = createRouter({ + routeTree, + basepath: '/', + rewrite, + history, + context: {}, + }); + cachedRouterBasepath = resolvedBasename; + + return cachedRouter; + }, [resolvedBasename, routeTree, supportHtml5History]); + + const routerContent = ; + return App ? {routerContent} : routerContent; + }; + + return RouterWrapper as any; + }); + }, + }; +}; diff --git a/packages/runtime/plugin-tanstack/src/runtime/prefetchLink.tsx b/packages/runtime/plugin-tanstack/src/runtime/prefetchLink.tsx new file mode 100644 index 000000000000..27daa3531102 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/prefetchLink.tsx @@ -0,0 +1,69 @@ +import { + type AnyRouter, + type LinkComponentProps, + type RegisteredRouter, + Link as TanStackLink, +} from '@tanstack/react-router'; +import type { ReactElement } from 'react'; + +export type PrefetchBehavior = 'intent' | 'render' | 'none'; + +function resolvePreloadFromPrefetch( + prefetch: PrefetchBehavior | undefined, + preload: unknown, +) { + if (typeof preload !== 'undefined') { + return preload; + } + + if (prefetch === 'none') { + return false; + } + + if (prefetch === 'intent' || prefetch === 'render') { + return prefetch; + } + + return preload; +} + +export type LinkProps< + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends string = string, + TTo extends string | undefined = '.', + TMaskFrom extends string = TFrom, + TMaskTo extends string = '.', +> = LinkComponentProps<'a', TRouter, TFrom, TTo, TMaskFrom, TMaskTo> & { + prefetch?: PrefetchBehavior; +}; + +export type NavLinkProps< + TRouter extends AnyRouter = RegisteredRouter, + TFrom extends string = string, + TTo extends string | undefined = '.', + TMaskFrom extends string = TFrom, + TMaskTo extends string = '.', +> = LinkProps; + +type LinkComponent = < + TRouter extends AnyRouter = RegisteredRouter, + const TFrom extends string = string, + const TTo extends string | undefined = undefined, + const TMaskFrom extends string = TFrom, + const TMaskTo extends string = '', +>( + props: LinkProps, +) => ReactElement; + +const LinkComponentImpl = (props: any) => { + const { prefetch, preload, ...rest } = props; + return ( + + ); +}; + +export const Link = LinkComponentImpl as LinkComponent; +export const NavLink = LinkComponentImpl as LinkComponent; diff --git a/packages/runtime/plugin-tanstack/src/runtime/routeTree.ts b/packages/runtime/plugin-tanstack/src/runtime/routeTree.ts new file mode 100644 index 000000000000..bdd9dabaa943 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/routeTree.ts @@ -0,0 +1,517 @@ +import type { RouteObject } from '@modern-js/runtime-utils/router'; +import type { NestedRoute, PageRoute } from '@modern-js/types'; +import type { + AnyRoute, + AnyRouter, + RootRoute as TanstackRootRoute, +} from '@tanstack/react-router'; +import { + createRootRoute, + createRoute, + notFound, + redirect, +} from '@tanstack/react-router'; +import { DefaultNotFound } from './DefaultNotFound'; + +function toTanstackPath(pathname: string): string { + // TanStack Router uses `$param` and `$` (splat) style params. + // Modern's conventional routing currently generates React Router style params (e.g. `:id`, `*`). + // + // We only convert the subset Modern generates today: + // - `:id` -> `$id` + // - `:id?` -> `{-$id}` (optional param) + // - `*` -> `$` + return pathname + .split('/') + .map(segment => { + if (!segment) { + return segment; + } + if (segment === '*') { + return '$'; + } + if (segment.startsWith(':')) { + const name = segment.slice(1); + if (name.endsWith('?')) { + return `{-$${name.slice(0, -1)}}`; + } + return `$${name}`; + } + return segment; + }) + .join('/'); +} + +function isResponse(value: unknown): value is Response { + return ( + value != null && + typeof value === 'object' && + typeof (value as any).status === 'number' && + typeof (value as any).headers === 'object' + ); +} + +function isTanstackRedirect(value: unknown): boolean { + return isResponse(value) && typeof (value as any).options === 'object'; +} + +const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); +function isRedirectResponse(res: Response) { + return redirectStatusCodes.has(res.status); +} + +function throwTanstackRedirect(location: string) { + const target = location || '/'; + // Prefer `to` for internal/relative redirects so basepath can be applied. + // Use `href` for absolute redirects (external). + try { + void new URL(target); + throw redirect({ href: target }); + } catch { + throw redirect({ to: target }); + } +} + +function mapParamsForModernLoader({ + modernRoute, + params, +}: { + modernRoute: NestedRoute | PageRoute; + params: Record; +}) { + // React Router uses `*` for splat params, TanStack Router uses `_splat`. + if (modernRoute.type === 'nested' && modernRoute.path?.includes('*')) { + const { _splat, ...rest } = params as any; + if (typeof _splat !== 'undefined') { + return { ...rest, '*': _splat }; + } + return rest; + } + return params; +} + +function createModernRequest(input: string, signal: AbortSignal) { + return new Request(input, { signal }); +} + +function wrapModernLoader( + modernRoute: NestedRoute | PageRoute, + modernLoader: ((args: any) => any) | undefined, +) { + return async (ctx: any) => { + try { + if (typeof (modernRoute as any).lazyImport === 'function') { + try { + (modernRoute as any).lazyImport(); + } catch {} + } + + const signal: AbortSignal = + ctx?.abortController?.signal || + ctx?.signal || + new AbortController().signal; + const baseRequest: Request | undefined = + ctx?.context?.request instanceof Request + ? ctx.context.request + : undefined; + + const href = + typeof ctx?.location === 'string' + ? ctx.location + : ctx?.location?.publicHref || + ctx?.location?.href || + ctx?.location?.url?.href || + ''; + + const request = baseRequest + ? new Request(baseRequest, { signal }) + : createModernRequest(href, signal); + const params = mapParamsForModernLoader({ + modernRoute, + params: ctx.params || {}, + }); + + const result = modernLoader + ? await modernLoader({ + request, + params, + context: ctx?.context?.requestContext, + }) + : null; + + if (isResponse(result)) { + if (isRedirectResponse(result)) { + const location = result.headers.get('Location') || '/'; + throwTanstackRedirect(location); + } + if (result.status === 404) { + throw notFound(); + } + } + + return result; + } catch (err) { + if (isResponse(err)) { + if (isTanstackRedirect(err)) { + throw err; + } + if (isRedirectResponse(err)) { + const location = err.headers.get('Location') || '/'; + throwTanstackRedirect(location); + } + if (err.status === 404) { + throw notFound(); + } + } + throw err; + } + }; +} + +function isRouteObjectPathlessLayout(route: RouteObject) { + return !route.path && !route.index; +} + +function isRouteObjectSplatRoute(route: RouteObject) { + return typeof route.path === 'string' && route.path.includes('*'); +} + +function mapParamsForRouteObjectLoader({ + route, + params, +}: { + route: RouteObject; + params: Record; +}) { + if (isRouteObjectSplatRoute(route)) { + const { _splat, ...rest } = params as any; + if (typeof _splat !== 'undefined') { + return { ...rest, '*': _splat }; + } + return rest; + } + return params; +} + +function wrapRouteObjectLoader(route: RouteObject) { + const routeLoader = route.loader; + if (typeof routeLoader !== 'function') { + return undefined; + } + + return async (ctx: any) => { + try { + const signal: AbortSignal = + ctx?.abortController?.signal || + ctx?.signal || + new AbortController().signal; + const baseRequest: Request | undefined = + ctx?.context?.request instanceof Request + ? ctx.context.request + : undefined; + + const href = + typeof ctx?.location === 'string' + ? ctx.location + : ctx?.location?.publicHref || + ctx?.location?.href || + ctx?.location?.url?.href || + ''; + + const request = baseRequest + ? new Request(baseRequest, { signal }) + : createModernRequest(href, signal); + + const params = mapParamsForRouteObjectLoader({ + route, + params: ctx.params || {}, + }); + + const result = await routeLoader({ + request, + params, + context: ctx?.context?.requestContext, + } as any); + + if (isResponse(result)) { + if (isRedirectResponse(result)) { + const location = result.headers.get('Location') || '/'; + throwTanstackRedirect(location); + } + if (result.status === 404) { + throw notFound(); + } + } + + return result; + } catch (err) { + if (isResponse(err)) { + if (isTanstackRedirect(err)) { + throw err; + } + if (isRedirectResponse(err)) { + const location = err.headers.get('Location') || '/'; + throwTanstackRedirect(location); + } + if (err.status === 404) { + throw notFound(); + } + } + throw err; + } + }; +} + +function toRouteComponent(route: RouteObject): any { + if (route.Component) { + return route.Component as any; + } + const element = route.element; + if (element) { + return (() => element as any) as any; + } + return undefined; +} + +function toErrorComponent(route: RouteObject): any { + const anyRoute = route as any; + if (anyRoute.ErrorBoundary) { + return anyRoute.ErrorBoundary as any; + } + if (route.errorElement) { + return (() => route.errorElement as any) as any; + } + return undefined; +} + +function toPendingComponent(route: RouteObject): any { + const anyRoute = route as any; + return anyRoute.HydrateFallback || anyRoute.pendingComponent || undefined; +} + +function createRouteStaticData(opts: { + modernRouteId?: string; + modernRouteAction?: unknown; + modernRouteLoader?: unknown; +}) { + const staticData: Record = {}; + + if (opts.modernRouteId) { + staticData.modernRouteId = opts.modernRouteId; + } + + if (opts.modernRouteAction) { + staticData.modernRouteAction = opts.modernRouteAction; + } + + if (opts.modernRouteLoader) { + staticData.modernRouteLoader = opts.modernRouteLoader; + } + + return Object.keys(staticData).length > 0 ? staticData : undefined; +} + +function createRouteFromRouteObject(opts: { + parent: AnyRoute; + routeObject: RouteObject; +}): AnyRoute { + const { parent, routeObject } = opts; + + const stableFallbackId = + routeObject.id || + (routeObject as any).file || + (routeObject as any).path || + 'pathless'; + + const base: any = { + getParentRoute: () => parent, + component: toRouteComponent(routeObject), + pendingComponent: toPendingComponent(routeObject), + errorComponent: toErrorComponent(routeObject), + wrapInSuspense: true, + staticData: createRouteStaticData({ + modernRouteId: routeObject.id, + modernRouteAction: routeObject.action, + modernRouteLoader: routeObject.loader, + }), + loader: wrapRouteObjectLoader(routeObject), + }; + + if (isRouteObjectPathlessLayout(routeObject)) { + base.id = stableFallbackId; + } else { + base.path = routeObject.index + ? '/' + : toTanstackPath((routeObject.path as string) || ''); + } + + const route = createRoute(base) as unknown as AnyRoute; + + const children = routeObject.children; + if (children && children.length > 0) { + const childRoutes = children.map(child => + createRouteFromRouteObject({ parent: route, routeObject: child }), + ); + (route as any).addChildren(childRoutes); + } + + return route; +} + +function createRouteFromModernRoute(opts: { + parent: AnyRoute; + modernRoute: NestedRoute | PageRoute; +}): AnyRoute { + const { parent, modernRoute } = opts; + + const modernId = (modernRoute as any).id as string | undefined; + const stableFallbackId = + modernId || + (modernRoute as any)._component || + (modernRoute as any).filename || + (modernRoute as any).data || + (modernRoute as any).loader; + + const pendingComponent = + (modernRoute as any).loading || (modernRoute as any).pendingComponent; + const errorComponent = + (modernRoute as any).error || (modernRoute as any).errorComponent; + const component = (modernRoute as any).component; + const modernLoader = (modernRoute as any).loader; + const modernAction = (modernRoute as any).action; + + // Pathless layout: no path segment, but must remain in the tree. + const isPathlessLayout = + modernRoute.type === 'nested' && + typeof modernRoute.index !== 'boolean' && + typeof (modernRoute as any).path === 'undefined'; + + const isIndexRoute = + modernRoute.type === 'nested' && Boolean((modernRoute as any).index); + + const base: any = { + getParentRoute: () => parent, + component: component || undefined, + pendingComponent: pendingComponent || undefined, + errorComponent: errorComponent || undefined, + wrapInSuspense: true, + staticData: createRouteStaticData({ + modernRouteId: modernId, + modernRouteAction: modernAction, + modernRouteLoader: modernLoader, + }), + loader: wrapModernLoader(modernRoute, modernLoader), + }; + + if (isPathlessLayout) { + // Use a stable custom id for pathless layouts to avoid hydration mismatch. + base.id = stableFallbackId || 'pathless'; + } else { + const rawPath = (modernRoute as any).path as string | undefined; + base.path = isIndexRoute ? '/' : toTanstackPath(rawPath || ''); + } + + const route = createRoute(base) as unknown as AnyRoute; + + const children = (modernRoute as any).children as + | Array + | undefined; + if (children && children.length > 0) { + const childRoutes = children.map(child => + createRouteFromModernRoute({ parent: route, modernRoute: child }), + ); + (route as any).addChildren(childRoutes); + } + + return route; +} + +export function createRouteTreeFromModernRoutes( + routes: Array, +): TanstackRootRoute { + const rootModern = routes.find( + route => route && (route as any).type === 'nested' && (route as any).isRoot, + ) as NestedRoute | undefined; + + const rootComponent = (rootModern as any)?.component; + const pendingComponent = (rootModern as any)?.loading; + const errorComponent = (rootModern as any)?.error; + const rootLoader = (rootModern as any)?.loader as + | ((args: any) => any) + | undefined; + const rootAction = (rootModern as any)?.action; + const rootModernId = (rootModern as any)?.id as string | undefined; + + const rootRoute = createRootRoute({ + component: rootComponent || undefined, + pendingComponent: pendingComponent || undefined, + errorComponent: errorComponent || undefined, + wrapInSuspense: true, + notFoundComponent: DefaultNotFound, + staticData: createRouteStaticData({ + modernRouteId: rootModernId, + modernRouteAction: rootAction, + modernRouteLoader: rootLoader, + }), + loader: rootModern ? wrapModernLoader(rootModern, rootLoader) : undefined, + }) as any; + + const topLevel = rootModern + ? ((rootModern as any).children as Array) || [] + : routes; + + const childRoutes = topLevel.map(child => + createRouteFromModernRoute({ parent: rootRoute, modernRoute: child }), + ); + + (rootRoute as any).addChildren(childRoutes); + return rootRoute as any; +} + +function getRootLikeRouteObject(routes: RouteObject[]) { + return routes.find(route => route.path === '/' && !route.index); +} + +export function createRouteTreeFromRouteObjects( + routes: RouteObject[], +): TanstackRootRoute { + const rootLikeRoute = getRootLikeRouteObject(routes); + + const rootRoute = createRootRoute({ + component: rootLikeRoute ? toRouteComponent(rootLikeRoute) : undefined, + pendingComponent: rootLikeRoute + ? toPendingComponent(rootLikeRoute) + : undefined, + errorComponent: rootLikeRoute ? toErrorComponent(rootLikeRoute) : undefined, + wrapInSuspense: true, + notFoundComponent: DefaultNotFound, + staticData: createRouteStaticData({ + modernRouteId: rootLikeRoute?.id, + modernRouteAction: rootLikeRoute?.action, + modernRouteLoader: rootLikeRoute?.loader, + }), + loader: rootLikeRoute ? wrapRouteObjectLoader(rootLikeRoute) : undefined, + }) as any; + + const topLevel = rootLikeRoute + ? [ + ...((rootLikeRoute.children as RouteObject[] | undefined) || []), + ...routes.filter(route => route !== rootLikeRoute), + ] + : routes; + + const childRoutes = topLevel.map(routeObject => + createRouteFromRouteObject({ parent: rootRoute, routeObject }), + ); + + (rootRoute as any).addChildren(childRoutes); + return rootRoute as any; +} + +export function getModernRouteIdsFromMatches(router: AnyRouter): string[] { + const matches = router.state.matches || []; + const ids = matches + .map((match: any) => match.route?.options?.staticData?.modernRouteId) + .filter(Boolean); + return Array.from(new Set(ids)); +} diff --git a/packages/runtime/plugin-tanstack/tests/router/dataMutation.test.tsx b/packages/runtime/plugin-tanstack/tests/router/dataMutation.test.tsx new file mode 100644 index 000000000000..7eee48030d65 --- /dev/null +++ b/packages/runtime/plugin-tanstack/tests/router/dataMutation.test.tsx @@ -0,0 +1,400 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { act } from 'react'; +import type { Fetcher } from '../../src/runtime/dataMutation'; +import { + Form, + useFetcher, +} from '../../src/runtime/dataMutation'; + +type RouteHandler = (args: { + request: Request; + params: Record; + context?: unknown; +}) => Promise | unknown; + +let currentRouter: any; + +rstest.mock('@tanstack/react-router', () => ({ + useRouter: () => currentRouter, +})); + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { + promise, + resolve, + reject, + }; +} + +function createRouter(handler: { + action?: RouteHandler; + loader?: RouteHandler; + invalidate?: () => Promise; +}) { + return { + buildLocation: ({ to }: { to?: string }) => ({ + pathname: typeof to === 'string' ? to : '/', + }), + getParsedLocationHref: (location: { pathname: string }) => + location.pathname, + getMatchedRoutes: () => ({ + foundRoute: { + options: { + staticData: { + modernRouteAction: handler.action, + modernRouteLoader: handler.loader, + }, + }, + }, + routeParams: {}, + }), + navigate: rstest.fn(async () => undefined), + invalidate: rstest.fn(handler.invalidate || (async () => undefined)), + }; +} + +function formatFetcherError(error: unknown) { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + if (error == null) { + return ''; + } + return JSON.stringify(error); +} + +describe('tanstack data mutation fetcher', () => { + let latestFetcher: Fetcher | undefined; + const states: string[] = []; + + function FetcherHarness() { + const fetcher = useFetcher(); + latestFetcher = fetcher; + + React.useEffect(() => { + states.push(fetcher.state); + }, [fetcher.state]); + + return ( +
+
{fetcher.state}
+
+ {fetcher.data === undefined + ? 'undefined' + : JSON.stringify(fetcher.data)} +
+
{formatFetcherError(fetcher.error)}
+
+ ); + } + + beforeEach(() => { + latestFetcher = undefined; + states.length = 0; + }); + + test('tracks submitting and loading phases for mutation submit', async () => { + const actionResult = createDeferred(); + const invalidateResult = createDeferred(); + + currentRouter = createRouter({ + action: async () => actionResult.promise, + invalidate: async () => invalidateResult.promise, + }); + + render(); + expect(screen.getByTestId('state').textContent).toBe('idle'); + + let submitPromise: Promise | undefined; + act(() => { + submitPromise = latestFetcher!.submit( + { amount: 2 }, + { method: 'post', action: '/mutation' }, + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('state').textContent).toBe('submitting'); + }); + + actionResult.resolve( + new Response(JSON.stringify({ count: 2 }), { + headers: { + 'Content-Type': 'application/json', + }, + }), + ); + + await waitFor(() => { + expect(screen.getByTestId('state').textContent).toBe('loading'); + }); + + invalidateResult.resolve(); + + await act(async () => { + await submitPromise; + }); + + expect(screen.getByTestId('state').textContent).toBe('idle'); + expect(screen.getByTestId('data').textContent).toBe('{"count":2}'); + expect(states).toEqual(['idle', 'submitting', 'loading', 'idle']); + }); + + test('defaults fetcher submit without method to mutation state', async () => { + const actionResult = createDeferred(); + const invalidateResult = createDeferred(); + const action = rstest.fn(async () => actionResult.promise); + const loader = rstest.fn(async () => ({ count: 0 })); + + currentRouter = createRouter({ + action, + loader, + invalidate: async () => invalidateResult.promise, + }); + + render(); + expect(screen.getByTestId('state').textContent).toBe('idle'); + + let submitPromise: Promise | undefined; + act(() => { + submitPromise = latestFetcher!.submit( + { amount: 1 }, + { action: '/mutation' }, + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('state').textContent).toBe('submitting'); + }); + expect(action).toHaveBeenCalledTimes(1); + expect(loader).not.toHaveBeenCalled(); + + actionResult.resolve( + new Response(JSON.stringify({ count: 1 }), { + headers: { + 'Content-Type': 'application/json', + }, + }), + ); + + await waitFor(() => { + expect(screen.getByTestId('state').textContent).toBe('loading'); + }); + + invalidateResult.resolve(); + + await act(async () => { + await submitPromise; + }); + + expect(screen.getByTestId('state').textContent).toBe('idle'); + expect(screen.getByTestId('data').textContent).toBe('{"count":1}'); + expect(states).toEqual(['idle', 'submitting', 'loading', 'idle']); + }); + + test('surfaces non-2xx action responses as fetcher errors', async () => { + const actionResult = createDeferred(); + currentRouter = createRouter({ + action: async () => actionResult.promise, + }); + + render(); + + let thrownError: unknown; + let submitPromise: Promise | undefined; + act(() => { + submitPromise = latestFetcher!.submit( + { amount: 'not-a-number' }, + { method: 'post', action: '/mutation' }, + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('state').textContent).toBe('submitting'); + }); + + actionResult.resolve( + new Response(JSON.stringify({ message: 'invalid amount' }), { + status: 422, + headers: { + 'Content-Type': 'application/json', + }, + }), + ); + + await act(async () => { + try { + await submitPromise; + } catch (error) { + thrownError = error; + } + }); + + expect(thrownError).toBeInstanceOf(Error); + expect(screen.getByTestId('state').textContent).toBe('idle'); + expect(screen.getByTestId('error').textContent).toContain('422'); + expect(currentRouter.invalidate).not.toHaveBeenCalled(); + expect(states).toEqual(['idle', 'submitting', 'idle']); + }); + + test('uses loading state for loader fetches', async () => { + const loaderResult = createDeferred<{ count: number }>(); + + currentRouter = createRouter({ + loader: async () => loaderResult.promise, + }); + + render(); + + let submitPromise: Promise | undefined; + act(() => { + submitPromise = latestFetcher!.submit( + {}, + { method: 'get', action: '/mutation' }, + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('state').textContent).toBe('loading'); + }); + + loaderResult.resolve({ count: 7 }); + + await act(async () => { + await submitPromise; + }); + + expect(screen.getByTestId('state').textContent).toBe('idle'); + expect(screen.getByTestId('data').textContent).toBe('{"count":7}'); + expect(currentRouter.invalidate).not.toHaveBeenCalled(); + expect(states).toEqual(['idle', 'loading', 'idle']); + }); + + test('keeps non-idle state while overlapping mutation submits are still active', async () => { + const actionResults = [ + createDeferred(), + createDeferred(), + ]; + const invalidateResults = [createDeferred(), createDeferred()]; + let actionCallIndex = 0; + let invalidateCallIndex = 0; + + currentRouter = createRouter({ + action: async () => { + const current = actionResults[actionCallIndex]; + actionCallIndex += 1; + return current.promise; + }, + invalidate: async () => { + const current = invalidateResults[invalidateCallIndex]; + invalidateCallIndex += 1; + return current.promise; + }, + }); + + render(); + + let firstSubmit: Promise | undefined; + let secondSubmit: Promise | undefined; + + act(() => { + firstSubmit = latestFetcher!.submit( + { amount: 1 }, + { method: 'post', action: '/mutation' }, + ); + }); + await waitFor(() => { + expect(screen.getByTestId('state').textContent).toBe('submitting'); + }); + + act(() => { + secondSubmit = latestFetcher!.submit( + { amount: 2 }, + { method: 'post', action: '/mutation' }, + ); + }); + await waitFor(() => { + expect(screen.getByTestId('state').textContent).toBe('submitting'); + }); + + actionResults[0].resolve( + new Response(JSON.stringify({ count: 1 }), { + headers: { + 'Content-Type': 'application/json', + }, + }), + ); + await waitFor(() => { + expect(currentRouter.invalidate).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('state').textContent).toBe('submitting'); + }); + + invalidateResults[0].resolve(); + await waitFor(() => { + expect(screen.getByTestId('state').textContent).toBe('submitting'); + }); + + actionResults[1].resolve( + new Response(JSON.stringify({ count: 3 }), { + headers: { + 'Content-Type': 'application/json', + }, + }), + ); + await waitFor(() => { + expect(currentRouter.invalidate).toHaveBeenCalledTimes(2); + expect(screen.getByTestId('state').textContent).toBe('loading'); + }); + + invalidateResults[1].resolve(); + await act(async () => { + await Promise.all([firstSubmit, secondSubmit]); + }); + + expect(screen.getByTestId('state').textContent).toBe('idle'); + expect(screen.getByTestId('data').textContent).toBe('{"count":3}'); + }); + + test('does not throw for Form submit with non-2xx response and still invalidates', async () => { + const action = rstest.fn( + async () => + new Response(JSON.stringify({ message: 'invalid amount' }), { + status: 422, + headers: { + 'Content-Type': 'application/json', + }, + }), + ); + currentRouter = createRouter({ + action, + }); + + render( + + + , + ); + + const form = document.querySelector('form'); + expect(form).toBeTruthy(); + + await act(async () => { + fireEvent.submit(form!); + }); + + await waitFor(() => { + expect(action).toHaveBeenCalledTimes(1); + expect(currentRouter.invalidate).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/runtime/plugin-tanstack/tests/router/routeTree.test.ts b/packages/runtime/plugin-tanstack/tests/router/routeTree.test.ts new file mode 100644 index 000000000000..d5dd042e4c1d --- /dev/null +++ b/packages/runtime/plugin-tanstack/tests/router/routeTree.test.ts @@ -0,0 +1,85 @@ +import type { RouteObject } from '@modern-js/runtime-utils/router'; +import { createMemoryHistory } from '@tanstack/history'; +import { createRouter } from '@tanstack/react-router'; +import { createRouteTreeFromRouteObjects } from '../../src/runtime/routeTree'; + +async function loadRouteTree(routeTree: any, pathname: string) { + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: [pathname], + }), + context: { + request: new Request(`http://localhost${pathname}`), + requestContext: {}, + }, + }); + + await router.load(); + return router; +} + +describe('tanstack route tree from RouteObject[]', () => { + test('maps root loader and dynamic params', async () => { + const routes: RouteObject[] = [ + { + id: 'root', + path: '/', + loader: () => ({ root: 'ok' }), + Component: () => null, + children: [ + { + id: 'user', + path: 'user/:id', + loader: ({ params }: any) => ({ id: params.id }), + Component: () => null, + }, + ], + }, + ]; + + const routeTree = createRouteTreeFromRouteObjects(routes); + const router = await loadRouteTree(routeTree, '/user/123'); + + const rootMatch = router.state.matches.find( + match => match.routeId === '__root__', + ); + const userMatch = router.state.matches.find( + match => match.routeId === '/user/$id', + ); + + expect(rootMatch?.loaderData).toEqual({ root: 'ok' }); + expect(userMatch?.loaderData).toEqual({ id: '123' }); + }); + + test('maps splat params', async () => { + let splatParamValue = ''; + const routes: RouteObject[] = [ + { + id: 'root', + path: '/', + Component: () => null, + children: [ + { + id: 'files', + path: 'files/*', + loader: ({ params }: any) => { + splatParamValue = String(params['*'] || ''); + return { value: params['*'] }; + }, + Component: () => null, + }, + ], + }, + ]; + + const routeTree = createRouteTreeFromRouteObjects(routes); + + const splatRouter = await loadRouteTree(routeTree, '/files/a/b/c'); + const filesMatch = splatRouter.state.matches.find( + match => match.routeId === '/files/$', + ); + expect(filesMatch?.loaderData).toEqual({ value: 'a/b/c' }); + expect(splatParamValue).toBe('a/b/c'); + }); +}); diff --git a/packages/runtime/plugin-tanstack/tsconfig.json b/packages/runtime/plugin-tanstack/tsconfig.json new file mode 100644 index 000000000000..911496be5cb0 --- /dev/null +++ b/packages/runtime/plugin-tanstack/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": false, + "lib": ["DOM", "ESNext", "dom.iterable"], + "jsx": "preserve", + "baseUrl": "./", + "isolatedModules": true, + "paths": {}, + "rootDir": "./src" + }, + "include": ["src"] +} From a7259b616690995fefa35ad2c4b15dfabe686e84 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Fri, 15 May 2026 01:46:47 +0200 Subject: [PATCH 2/6] feat(runtime): add plugin-owned TanStack SSR hooks --- .../create-runtime-tanstack-tailwind.md | 11 +- .../src/core/context/runtime.ts | 31 +- .../plugin-runtime/src/core/react/wrapper.tsx | 7 + .../src/core/server/requestHandler.tsx | 178 ++-- .../src/core/server/stream/afterTemplate.ts | 14 +- .../src/core/server/stream/beforeTemplate.ts | 30 +- .../src/core/server/string/ssrData.ts | 42 +- .../src/router/cli/code/index.ts | 12 +- .../plugin-runtime/src/router/cli/entry.ts | 21 +- .../plugin-runtime/src/router/cli/handler.ts | 31 +- .../plugin-runtime/src/router/cli/index.ts | 36 +- .../src/router/runtime/hooks.ts | 24 +- .../src/router/runtime/lifecycle.ts | 189 +++++ .../src/router/runtime/plugin.node.tsx | 79 +- .../src/router/runtime/types.ts | 50 ++ .../tests/core/react/wrapper.test.tsx | 49 ++ .../tests/router/cliExtension.test.ts | 338 ++++++++ .../tests/router/lifecycle.test.tsx | 107 +++ .../buildTemplate.after.test.ts | 122 +++ .../buildTemplate.before.test.ts | 57 ++ .../__snapshots__/entry.test.ts.snap | 7 + .../serverRender/renderToString/entry.test.ts | 109 +++ .../ssr/serverRender/requestHandler.test.tsx | 133 +++ packages/runtime/plugin-tanstack/package.json | 14 +- .../{rstest.config.ts => rstest.config.mts} | 6 +- .../runtime/plugin-tanstack/src/cli/index.ts | 527 +++++++----- .../plugin-tanstack/src/cli/tanstackTypes.ts | 342 ++++---- .../src/runtime/dataMutation.tsx | 2 +- .../plugin-tanstack/src/runtime/hooks.ts | 28 +- .../plugin-tanstack/src/runtime/index.tsx | 25 +- .../plugin-tanstack/src/runtime/lifecycle.ts | 150 ++++ .../src/runtime/plugin.node.tsx | 331 ++++++++ .../plugin-tanstack/src/runtime/plugin.tsx | 186 ++-- .../src/runtime/prefetchLink.tsx | 1 + .../plugin-tanstack/src/runtime/routeTree.ts | 6 +- .../plugin-tanstack/src/runtime/ssr-shim.d.ts | 12 + .../plugin-tanstack/src/runtime/types.ts | 83 ++ .../plugin-tanstack/src/runtime/utils.tsx | 158 ++++ .../plugin-tanstack/tests/router/cli.test.ts | 386 +++++++++ .../tests/router/dataMutation.test.tsx | 8 +- .../tests/router/tanstackTypes.test.ts | 62 ++ pnpm-lock.yaml | 798 ++++++++++++++---- .../modern.config.ts | 19 + .../package.json | 24 + .../routes-tanstack-create-routes/src/App.tsx | 5 + .../src/modern-app-env.d.ts | 1 + .../src/modern-tanstack/register.gen.d.ts | 9 + .../src/modern.runtime.tsx | 77 ++ .../tests/create-routes-contract.test.ts | 92 ++ .../tsconfig.json | 12 + .../routes-tanstack/modern.config.ts | 24 + .../integration/routes-tanstack/package.json | 24 + .../routes-tanstack/src/modern-app-env.d.ts | 1 + .../src/modern-tanstack/register.gen.d.ts | 10 + .../src/modern-tanstack/stream/router.gen.ts | 194 +++++ .../src/modern-tanstack/string/router.gen.ts | 213 +++++ .../routes-tanstack/src/modern.runtime.tsx | 3 + .../src/stream/routes/layout.loader.ts | 5 + .../src/stream/routes/layout.tsx | 39 + .../stream/routes/optional/[id$]/page.data.ts | 9 + .../src/stream/routes/optional/[id$]/page.tsx | 8 + .../src/stream/routes/page.data.ts | 5 + .../src/stream/routes/page.tsx | 8 + .../src/stream/routes/redirect/page.data.ts | 8 + .../src/stream/routes/redirect/page.tsx | 3 + .../src/stream/routes/user/[id]/page.data.ts | 5 + .../src/stream/routes/user/[id]/page.tsx | 8 + .../src/string/routes/blocker/page.tsx | 52 ++ .../src/string/routes/layout.loader.ts | 5 + .../src/string/routes/layout.tsx | 45 + .../src/string/routes/mutation/page.data.ts | 30 + .../src/string/routes/mutation/page.tsx | 70 ++ .../string/routes/optional/[id$]/page.data.ts | 9 + .../src/string/routes/optional/[id$]/page.tsx | 8 + .../src/string/routes/page.data.ts | 5 + .../src/string/routes/page.tsx | 8 + .../src/string/routes/redirect/page.data.ts | 8 + .../src/string/routes/redirect/page.tsx | 3 + .../src/string/routes/user/[id]/page.data.ts | 5 + .../src/string/routes/user/[id]/page.tsx | 8 + .../src/type-tests/tanstack-router.tsx | 94 +++ .../tests/tanstack-data-flow-contract.test.ts | 51 ++ .../integration/routes-tanstack/tsconfig.json | 13 + tests/rstest.superapp-contracts.config.mts | 16 + tests/utils/fixtureLock.ts | 78 ++ 85 files changed, 5321 insertions(+), 795 deletions(-) create mode 100644 packages/runtime/plugin-runtime/src/router/runtime/lifecycle.ts create mode 100644 packages/runtime/plugin-runtime/tests/core/react/wrapper.test.tsx create mode 100644 packages/runtime/plugin-runtime/tests/router/cliExtension.test.ts create mode 100644 packages/runtime/plugin-runtime/tests/router/lifecycle.test.tsx create mode 100644 packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToStream/buildTemplate.after.test.ts create mode 100644 packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToStream/buildTemplate.before.test.ts create mode 100644 packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToString/__snapshots__/entry.test.ts.snap create mode 100644 packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToString/entry.test.ts create mode 100644 packages/runtime/plugin-runtime/tests/ssr/serverRender/requestHandler.test.tsx rename packages/runtime/plugin-tanstack/{rstest.config.ts => rstest.config.mts} (82%) create mode 100644 packages/runtime/plugin-tanstack/src/runtime/lifecycle.ts create mode 100644 packages/runtime/plugin-tanstack/src/runtime/plugin.node.tsx create mode 100644 packages/runtime/plugin-tanstack/src/runtime/ssr-shim.d.ts create mode 100644 packages/runtime/plugin-tanstack/src/runtime/types.ts create mode 100644 packages/runtime/plugin-tanstack/src/runtime/utils.tsx create mode 100644 packages/runtime/plugin-tanstack/tests/router/cli.test.ts create mode 100644 packages/runtime/plugin-tanstack/tests/router/tanstackTypes.test.ts create mode 100644 tests/integration/routes-tanstack-create-routes/modern.config.ts create mode 100644 tests/integration/routes-tanstack-create-routes/package.json create mode 100644 tests/integration/routes-tanstack-create-routes/src/App.tsx create mode 100644 tests/integration/routes-tanstack-create-routes/src/modern-app-env.d.ts create mode 100644 tests/integration/routes-tanstack-create-routes/src/modern-tanstack/register.gen.d.ts create mode 100644 tests/integration/routes-tanstack-create-routes/src/modern.runtime.tsx create mode 100644 tests/integration/routes-tanstack-create-routes/tests/create-routes-contract.test.ts create mode 100644 tests/integration/routes-tanstack-create-routes/tsconfig.json create mode 100644 tests/integration/routes-tanstack/modern.config.ts create mode 100644 tests/integration/routes-tanstack/package.json create mode 100644 tests/integration/routes-tanstack/src/modern-app-env.d.ts create mode 100644 tests/integration/routes-tanstack/src/modern-tanstack/register.gen.d.ts create mode 100644 tests/integration/routes-tanstack/src/modern-tanstack/stream/router.gen.ts create mode 100644 tests/integration/routes-tanstack/src/modern-tanstack/string/router.gen.ts create mode 100644 tests/integration/routes-tanstack/src/modern.runtime.tsx create mode 100644 tests/integration/routes-tanstack/src/stream/routes/layout.loader.ts create mode 100644 tests/integration/routes-tanstack/src/stream/routes/layout.tsx create mode 100644 tests/integration/routes-tanstack/src/stream/routes/optional/[id$]/page.data.ts create mode 100644 tests/integration/routes-tanstack/src/stream/routes/optional/[id$]/page.tsx create mode 100644 tests/integration/routes-tanstack/src/stream/routes/page.data.ts create mode 100644 tests/integration/routes-tanstack/src/stream/routes/page.tsx create mode 100644 tests/integration/routes-tanstack/src/stream/routes/redirect/page.data.ts create mode 100644 tests/integration/routes-tanstack/src/stream/routes/redirect/page.tsx create mode 100644 tests/integration/routes-tanstack/src/stream/routes/user/[id]/page.data.ts create mode 100644 tests/integration/routes-tanstack/src/stream/routes/user/[id]/page.tsx create mode 100644 tests/integration/routes-tanstack/src/string/routes/blocker/page.tsx create mode 100644 tests/integration/routes-tanstack/src/string/routes/layout.loader.ts create mode 100644 tests/integration/routes-tanstack/src/string/routes/layout.tsx create mode 100644 tests/integration/routes-tanstack/src/string/routes/mutation/page.data.ts create mode 100644 tests/integration/routes-tanstack/src/string/routes/mutation/page.tsx create mode 100644 tests/integration/routes-tanstack/src/string/routes/optional/[id$]/page.data.ts create mode 100644 tests/integration/routes-tanstack/src/string/routes/optional/[id$]/page.tsx create mode 100644 tests/integration/routes-tanstack/src/string/routes/page.data.ts create mode 100644 tests/integration/routes-tanstack/src/string/routes/page.tsx create mode 100644 tests/integration/routes-tanstack/src/string/routes/redirect/page.data.ts create mode 100644 tests/integration/routes-tanstack/src/string/routes/redirect/page.tsx create mode 100644 tests/integration/routes-tanstack/src/string/routes/user/[id]/page.data.ts create mode 100644 tests/integration/routes-tanstack/src/string/routes/user/[id]/page.tsx create mode 100644 tests/integration/routes-tanstack/src/type-tests/tanstack-router.tsx create mode 100644 tests/integration/routes-tanstack/tests/tanstack-data-flow-contract.test.ts create mode 100644 tests/integration/routes-tanstack/tsconfig.json create mode 100644 tests/rstest.superapp-contracts.config.mts create mode 100644 tests/utils/fixtureLock.ts diff --git a/.changeset/create-runtime-tanstack-tailwind.md b/.changeset/create-runtime-tanstack-tailwind.md index 1c5f0e68a588..660d5186e766 100644 --- a/.changeset/create-runtime-tanstack-tailwind.md +++ b/.changeset/create-runtime-tanstack-tailwind.md @@ -1,17 +1,10 @@ --- -'@modern-js/create': minor '@modern-js/runtime': minor '@modern-js/plugin-tanstack': minor --- -feat(create): support `--router tanstack` and `--tailwind` scaffolding - -- add router selection for React Router / TanStack Router in `@modern-js/create` -- add Tailwind CSS v4 scaffold option (`--tailwind`) with `postcss.config.mjs` and `tailwind.config.ts` -- for TanStack scaffolds, register `tanstackRouterPlugin()` and use `src/views` as the convention directory - feat(runtime): move TanStack Router integration to `@modern-js/plugin-tanstack` - add `@modern-js/plugin-tanstack` runtime/cli package surface -- remove `@modern-js/runtime/tanstack-router` export from `@modern-js/runtime` -- migrate TanStack route generation/runtime ownership from core runtime to the standalone plugin +- add generic runtime router CLI and SSR hooks so router integrations can be implemented by plugins +- keep TanStack route generation/runtime ownership in the standalone plugin instead of `@modern-js/runtime` diff --git a/packages/runtime/plugin-runtime/src/core/context/runtime.ts b/packages/runtime/plugin-runtime/src/core/context/runtime.ts index 81c4738a6c5d..9851d30421f2 100644 --- a/packages/runtime/plugin-runtime/src/core/context/runtime.ts +++ b/packages/runtime/plugin-runtime/src/core/context/runtime.ts @@ -1,15 +1,23 @@ -import type { RouteObject } from '@modern-js/runtime-utils/router'; -import type { StaticHandlerContext } from '@modern-js/runtime-utils/router'; +import type { + RouteObject, + StaticHandlerContext, +} from '@modern-js/runtime-utils/router'; import type { BaseSSRServerContext } from '@modern-js/types'; import { ROUTE_MANIFEST } from '@modern-js/utils/universal/constants'; import { createContext, useContext } from 'react'; -import type { RouteManifest } from '../../router/runtime/types'; +import type { + InternalRouterRuntimeState, + InternalRouterServerSnapshot, + RouteManifest, + RouterFramework, +} from '../../router/runtime/types'; import type { RequestContext, SSRServerContext } from '../types'; export interface TRuntimeContext { initialData?: Record; isBrowser: boolean; routes?: RouteObject[]; + routerFramework?: RouterFramework; requestContext: RequestContext; /** * @deprecated Use `requestContext` instead @@ -23,7 +31,24 @@ export interface TRuntimeContext { */ export interface TInternalRuntimeContext extends TRuntimeContext { routeManifest?: RouteManifest; + routerRuntime?: InternalRouterRuntimeState; + routerInstance?: unknown; + routerHydrationScript?: string; + routerMatchedRouteIds?: string[]; + routerServerSnapshot?: InternalRouterServerSnapshot; routerContext?: StaticHandlerContext; + /** + * @deprecated Use `routerInstance` or `routerRuntime.instance` instead. + */ + tanstackRouter?: unknown; + /** + * @deprecated Use `routerServerSnapshot.hydrationScript(s)` instead. + */ + tanstackSsrScript?: string; + /** + * @deprecated Use `routerServerSnapshot.matchedRouteIds` instead. + */ + tanstackMatchedModernRouteIds?: string[]; unstable_getBlockNavState?: () => boolean; ssrContext?: SSRServerContext; _internalContext?: any; diff --git a/packages/runtime/plugin-runtime/src/core/react/wrapper.tsx b/packages/runtime/plugin-runtime/src/core/react/wrapper.tsx index a204ef6b12df..8b2101b9a880 100644 --- a/packages/runtime/plugin-runtime/src/core/react/wrapper.tsx +++ b/packages/runtime/plugin-runtime/src/core/react/wrapper.tsx @@ -14,8 +14,14 @@ export function wrapRuntimeContextProvider( isBrowser, initialData, routes, + routerFramework, context, routeManifest, + routerRuntime, + routerInstance, + routerHydrationScript, + routerMatchedRouteIds, + routerServerSnapshot, routerContext, unstable_getBlockNavState, ssrContext, @@ -28,6 +34,7 @@ export function wrapRuntimeContextProvider( isBrowser, initialData, routes, + routerFramework, context, ...rest, }; diff --git a/packages/runtime/plugin-runtime/src/core/server/requestHandler.tsx b/packages/runtime/plugin-runtime/src/core/server/requestHandler.tsx index c4eda33f006a..045b61921d50 100644 --- a/packages/runtime/plugin-runtime/src/core/server/requestHandler.tsx +++ b/packages/runtime/plugin-runtime/src/core/server/requestHandler.tsx @@ -10,13 +10,13 @@ import { parseHeaders, parseQuery, } from '@modern-js/runtime-utils/universal/request'; -import React from 'react'; -import { Fragment } from 'react'; +import React, { Fragment } from 'react'; +import { cleanupRouterRuntimeState } from '../../router/runtime/lifecycle'; import { handleRSCRedirect } from '../../router/runtime/rsc-router'; import { - type TInternalRuntimeContext, getGlobalInternalRuntimeContext, getGlobalRSCRoot, + type TInternalRuntimeContext, } from '../context'; import { getInitialContext } from '../context/runtime'; import { getServerPayload } from '../context/serverPayload'; @@ -179,7 +179,15 @@ function createSSRContext( } const loaderFailureMode = - typeof ssrConfig === 'object' ? ssrConfig.loaderFailureMode : undefined; + typeof ssrConfig === 'object' && + ssrConfig && + 'loaderFailureMode' in ssrConfig + ? ( + ssrConfig as { + loaderFailureMode?: 'clientRender' | 'errorBoundary'; + } + ).loaderFailureMode + : undefined; return { nonce, @@ -277,96 +285,102 @@ export const createRequestHandler: CreateRequestHandler = async ( basename: ssrContext.baseUrl || '/', }; - const beforeRenderResult = await runBeforeRender(context); + try { + const beforeRenderResult = await runBeforeRender(context); - // Support data loader to return `new Response` and set status code - if ( - context.routerContext?.statusCode && - context.routerContext?.statusCode !== 200 - ) { - context.ssrContext?.response.status( - context.routerContext?.statusCode, + // Support data loader to return `new Response` and set status code + const routerServerSnapshot = context.routerServerSnapshot; + const routerStatusCode = + routerServerSnapshot?.statusCode ?? + context.routerContext?.statusCode; + if (routerStatusCode && routerStatusCode !== 200) { + context.ssrContext?.response.status(routerStatusCode); + } + + // log error by monitors when data loader throw error + const errors = Object.values( + (routerServerSnapshot?.errors || + context.routerContext?.errors || + {}) as Record, ); - } + if (errors.length > 0) { + options.onError(errors[0], SSRErrors.LOADER_ERROR); + } - // log error by monitors when data loader throw error - const errors = Object.values( - (context.routerContext?.errors || {}) as Record, - ); - if (errors.length > 0) { - options.onError(errors[0], SSRErrors.LOADER_ERROR); - } + // Handle redirect from loader (beforeRenderResult) + if ( + typeof Response !== 'undefined' && + beforeRenderResult instanceof Response && + isRedirectStatus(beforeRenderResult.status) + ) { + // Already in RSC format (from plugin.node.tsx), return directly + if (beforeRenderResult.headers.has('X-Modernjs-Redirect')) { + return beforeRenderResult; + } + // Convert to appropriate format + const redirectUrl = + beforeRenderResult.headers.get('Location') || '/'; + return processRedirect( + new Headers({ Location: redirectUrl }), + beforeRenderResult.status, + redirectCtx, + ); + } - // Handle redirect from loader (beforeRenderResult) - if ( - typeof Response !== 'undefined' && - beforeRenderResult instanceof Response && - isRedirectStatus(beforeRenderResult.status) - ) { - // Already in RSC format (from plugin.node.tsx), return directly - if (beforeRenderResult.headers.has('X-Modernjs-Redirect')) { - return beforeRenderResult; + if (!createRequestOptions?.enableRsc) { + const { htmlTemplate } = options.resource; + options.resource.htmlTemplate = htmlTemplate.replace( + '', + `${CHUNK_CSS_PLACEHOLDER}`, + ); } - // Convert to appropriate format - const redirectUrl = beforeRenderResult.headers.get('Location') || '/'; - return processRedirect( - new Headers({ Location: redirectUrl }), - beforeRenderResult.status, - redirectCtx, - ); - } - if (!createRequestOptions?.enableRsc) { - const { htmlTemplate } = options.resource; - options.resource.htmlTemplate = htmlTemplate.replace( - '', - `${CHUNK_CSS_PLACEHOLDER}`, - ); - } + let response: Response; + + if (createRequestOptions?.enableRsc) { + response = await handleRSCRequest( + request, + Root, + context, + options, + handleRequest, + ); + } else { + response = await handleRequest(request, Root, { + ...options, + runtimeContext: context, + }); + } - let response: Response; + // Handle redirect from component render (via responseProxy) + if ( + responseProxy.status !== -1 && + isRedirectStatus(responseProxy.status) && + responseProxy.headers.Location + ) { + return processRedirect( + new Headers(responseProxy.headers), + responseProxy.status, + redirectCtx, + ); + } - if (createRequestOptions?.enableRsc) { - response = await handleRSCRequest( - request, - Root, - context, - options, - handleRequest, - ); - } else { - response = await handleRequest(request, Root, { - ...options, - runtimeContext: context, + // Apply non-redirect responseProxy headers/status to response + Object.entries(responseProxy.headers).forEach(([key, value]) => { + response.headers.set(key, value); }); - } - - // Handle redirect from component render (via responseProxy) - if ( - responseProxy.status !== -1 && - isRedirectStatus(responseProxy.status) && - responseProxy.headers.Location - ) { - return processRedirect( - new Headers(responseProxy.headers), - responseProxy.status, - redirectCtx, - ); - } - // Apply non-redirect responseProxy headers/status to response - Object.entries(responseProxy.headers).forEach(([key, value]) => { - response.headers.set(key, value); - }); + if (responseProxy.status !== -1) { + return new Response(response.body, { + status: responseProxy.status, + headers: response.headers, + }); + } - if (responseProxy.status !== -1) { - return new Response(response.body, { - status: responseProxy.status, - headers: response.headers, - }); + return response; + } finally { + await cleanupRouterRuntimeState(context); } - - return response; }, ); }; diff --git a/packages/runtime/plugin-runtime/src/core/server/stream/afterTemplate.ts b/packages/runtime/plugin-runtime/src/core/server/stream/afterTemplate.ts index c080c8c5c8d4..63131bb275ed 100644 --- a/packages/runtime/plugin-runtime/src/core/server/stream/afterTemplate.ts +++ b/packages/runtime/plugin-runtime/src/core/server/stream/afterTemplate.ts @@ -1,12 +1,13 @@ -import type { IncomingHttpHeaders } from 'http'; import { serializeJson } from '@modern-js/runtime-utils/node'; import type { HeadersData } from '@modern-js/runtime-utils/universal/request'; +import type { IncomingHttpHeaders } from 'http'; +import { getRouterHydrationScripts } from '../../../router/runtime/lifecycle'; import { type RenderLevel, SSR_DATA_JSON_ID } from '../../constants'; import type { TInternalRuntimeContext } from '../../context'; import type { SSRContainer } from '../../types'; import { SSR_DATA_PLACEHOLDER } from '../constants'; import type { HandleRequestConfig } from '../requestHandler'; -import { type BuildHtmlCb, type SSRConfig, buildHtml } from '../shared'; +import { type BuildHtmlCb, buildHtml, type SSRConfig } from '../shared'; import { attributesToString, safeReplace } from '../utils'; export type BuildShellAfterTemplateOptions = { @@ -110,10 +111,15 @@ function createReplaceSSRData(options: { const attrsStr = attributesToString({ nonce }); const serializeSSRData = serializeJson(ssrData); - const ssrScripts = useJsonScript + const ssrDataScript = useJsonScript ? `` : `window._SSR_DATA = ${serializeSSRData}`; + const hydrationScripts = getRouterHydrationScripts(runtimeContext); + const ssrScripts = hydrationScripts.length + ? `${ssrDataScript}\n${hydrationScripts.join('\n')}` + : ssrDataScript; + return (template: string) => - safeReplace(template, SSR_DATA_PLACEHOLDER, ssrDataScript); + safeReplace(template, SSR_DATA_PLACEHOLDER, ssrScripts); } diff --git a/packages/runtime/plugin-runtime/src/core/server/stream/beforeTemplate.ts b/packages/runtime/plugin-runtime/src/core/server/stream/beforeTemplate.ts index 210f82f30170..de579df86179 100644 --- a/packages/runtime/plugin-runtime/src/core/server/stream/beforeTemplate.ts +++ b/packages/runtime/plugin-runtime/src/core/server/stream/beforeTemplate.ts @@ -1,6 +1,7 @@ // Todo: This import will introduce router code, like remix, even if router config is false import { matchRoutes } from '@modern-js/runtime-utils/router'; import ReactHelmet, { type HelmetData } from 'react-helmet'; +import { getRouterMatchedRouteIds } from '../../../router/runtime/lifecycle'; import type { TInternalRuntimeContext } from '../../context'; import { CHUNK_CSS_PLACEHOLDER } from '../constants'; import { createReplaceHelemt } from '../helmet'; @@ -79,20 +80,19 @@ export async function buildShellBeforeTemplate( const { routeAssets } = routeManifest; - const matches = matchRoutes( - routes, - routerContext.location, - routerContext.basename, - ); - const matchedRouteManifests = matches - ?.map((match, index) => { - if (!index) { - return; - } + type RouteManifest = { + referenceCssAssets?: string[]; + }; - let matchedRouteManifests: RouteManifest[] | undefined = undefined; + let matchedRouteManifests: RouteManifest[] | undefined; - if (routerContext && routes) { + const matchedRouteIds = getRouterMatchedRouteIds(runtimeContext); + + if (matchedRouteIds?.length) { + matchedRouteManifests = matchedRouteIds + .map(routeId => routeAssets[routeId] as RouteManifest | undefined) + .filter(Boolean) as RouteManifest[]; + } else if (routerContext && routes) { const matches = matchRoutes( routes, routerContext.location, @@ -122,10 +122,8 @@ export async function buildShellBeforeTemplate( } const cssChunks: string[] = matchedRouteManifests - ? matchedRouteManifests?.reduce((chunks, routeManifest) => { - const { referenceCssAssets = [] } = routeManifest as { - referenceCssAssets?: string[]; - }; + ? matchedRouteManifests.reduce((chunks, routeManifest) => { + const { referenceCssAssets = [] } = routeManifest; const _cssChunks = referenceCssAssets.filter( (asset?: string) => asset?.endsWith('.css') && !template.includes(asset), diff --git a/packages/runtime/plugin-runtime/src/core/server/string/ssrData.ts b/packages/runtime/plugin-runtime/src/core/server/string/ssrData.ts index 895618ee5efe..ed7fb787ebc7 100644 --- a/packages/runtime/plugin-runtime/src/core/server/string/ssrData.ts +++ b/packages/runtime/plugin-runtime/src/core/server/string/ssrData.ts @@ -1,16 +1,17 @@ -import type { IncomingHttpHeaders } from 'http'; import { serializeJson } from '@modern-js/runtime-utils/node'; import type { StaticHandlerContext } from '@modern-js/runtime-utils/router'; import type { HeadersData } from '@modern-js/runtime-utils/universal/request'; +import type { IncomingHttpHeaders } from 'http'; +import { getRouterHydrationScripts } from '../../../router/runtime/lifecycle'; import { ROUTER_DATA_JSON_ID, SSR_DATA_JSON_ID } from '../../constants'; -import type { TRuntimeContext } from '../../context'; +import type { TInternalRuntimeContext } from '../../context'; import type { SSRContainer, SSRServerContext } from '../../types'; import type { SSRConfig } from '../shared'; import { attributesToString, serializeErrors } from '../utils'; import type { ChunkSet, Collector } from './types'; export interface SSRDataCreatorOptions { - runtimeContext: TRuntimeContext; + runtimeContext: TInternalRuntimeContext; request: Request; chunkSet: ChunkSet; ssrContext: SSRServerContext; @@ -28,15 +29,10 @@ export class SSRDataCollector implements Collector { } effect() { - const { routerContext, chunkSet } = this.#options; + const { chunkSet } = this.#options; const ssrData = this.#getSSRData(); - const routerData = routerContext - ? { - loaderData: routerContext.loaderData, - errors: serializeErrors(routerContext.errors), - } - : undefined; + const routerData = this.#getRouterData(); const ssrDataScripts = this.#getSSRDataScripts(ssrData, routerData); @@ -84,11 +80,33 @@ export class SSRDataCollector implements Collector { }; } + #getRouterData() { + const { routerContext, runtimeContext } = this.#options; + const snapshotRouterData = runtimeContext.routerServerSnapshot?.routerData; + + if (snapshotRouterData) { + return { + loaderData: snapshotRouterData.loaderData, + errors: serializeErrors(snapshotRouterData.errors || null), + }; + } + + return routerContext + ? { + loaderData: routerContext.loaderData, + errors: serializeErrors(routerContext.errors), + } + : undefined; + } + #getSSRDataScripts( ssrData: Record, routerData?: Record, ) { const { nonce, useJsonScript = false } = this.#options; + const hydrationScripts = getRouterHydrationScripts( + this.#options.runtimeContext, + ); const serializeSSRData = serializeJson(ssrData); const attrsStr = attributesToString({ nonce }); @@ -103,6 +121,10 @@ export class SSRDataCollector implements Collector { : `\nwindow._ROUTER_DATA = ${serializedRouterData}`; } + if (hydrationScripts.length) { + ssrDataScripts += `\n${hydrationScripts.join('\n')}`; + } + return ssrDataScripts; } } diff --git a/packages/runtime/plugin-runtime/src/router/cli/code/index.ts b/packages/runtime/plugin-runtime/src/router/cli/code/index.ts index 517d76ba6990..0ba07d1d4dcf 100644 --- a/packages/runtime/plugin-runtime/src/router/cli/code/index.ts +++ b/packages/runtime/plugin-runtime/src/router/cli/code/index.ts @@ -1,4 +1,3 @@ -import path from 'path'; import type { AppNormalizedConfig, AppTools, @@ -14,19 +13,18 @@ import type { SSRMode, } from '@modern-js/types'; import { + filterRoutesForServer, + filterRoutesLoader, fs, getEntryOptions, isSSGEntry, isUseRsc, isUseSSRBundle, logger, -} from '@modern-js/utils'; -import { - filterRoutesForServer, - filterRoutesLoader, markRoutes, } from '@modern-js/utils'; import { cloneDeep } from '@modern-js/utils/lodash'; +import path from 'path'; import { ENTRY_POINT_RUNTIME_GLOBAL_CONTEXT_FILE_NAME } from '../../../cli/constants'; import { resolveSSRMode } from '../../../cli/ssr/mode'; import { FILE_SYSTEM_ROUTES_FILE_NAME } from '../constants'; @@ -108,6 +106,7 @@ export const generateCode = async ( appContext; const hooks = api.getHooks(); + const generatedRoutesByEntry: Record< string, (NestedRouteForCli | PageRoute)[] @@ -115,7 +114,6 @@ export const generateCode = async ( await Promise.all(entrypoints.map(generateEntryCode)); - async function generateEntryCode(entrypoint: Entrypoint) { const { entryName, @@ -195,6 +193,7 @@ export const generateCode = async ( | NestedRouteForCli | PageRoute )[]; + if (ssrMode === 'stream') { const hasPageRoute = routes.some( route => 'type' in route && route.type === 'page', @@ -290,6 +289,7 @@ export const generateCode = async ( code, 'utf8', ); + } } } diff --git a/packages/runtime/plugin-runtime/src/router/cli/entry.ts b/packages/runtime/plugin-runtime/src/router/cli/entry.ts index 40314715e1dc..8a396e64c75a 100644 --- a/packages/runtime/plugin-runtime/src/router/cli/entry.ts +++ b/packages/runtime/plugin-runtime/src/router/cli/entry.ts @@ -1,12 +1,12 @@ -import path from 'path'; import type { Entrypoint } from '@modern-js/types'; import { fs } from '@modern-js/utils'; +import path from 'path'; import { hasApp } from '../../cli/entry'; import { NESTED_ROUTES_DIR } from './constants'; export const ROUTES_DIR_META_KEY = '__modernRoutesDir'; -type EntrypointWithRoutesMeta = Entrypoint & { +export type EntrypointWithRoutesMeta = Entrypoint & { [ROUTES_DIR_META_KEY]?: string; }; @@ -25,15 +25,10 @@ export const getEntrypointRoutesDir = (entrypoint: { return null; }; -export const hasNestedRoutes = ( - dir: string, - routesDir = NESTED_ROUTES_DIR, -) => fs.existsSync(path.join(dir, routesDir)); +export const hasNestedRoutes = (dir: string, routesDir = NESTED_ROUTES_DIR) => + fs.existsSync(path.join(dir, routesDir)); -export const isRouteEntry = ( - dir: string, - routesDir = NESTED_ROUTES_DIR, -) => { +export const isRouteEntry = (dir: string, routesDir = NESTED_ROUTES_DIR) => { if (hasNestedRoutes(dir, routesDir)) { return path.join(dir, routesDir); } @@ -43,14 +38,13 @@ export const isRouteEntry = ( export const modifyEntrypoints = ( entrypoints: Entrypoint[], routesDir = NESTED_ROUTES_DIR, - ) => { +) => { return entrypoints.map(entrypoint => { const entrypointWithMeta = entrypoint as EntrypointWithRoutesMeta; if (!entrypoint.isAutoMount) { return entrypointWithMeta; } - if (entrypoint?.isCustomSourceEntry) { if (entrypoint.fileSystemRoutes) { entrypointWithMeta.nestedRoutesEntry = @@ -59,7 +53,6 @@ export const modifyEntrypoints = ( } return entrypointWithMeta; } - const isHasApp = hasApp(entrypoint.absoluteEntryDir!); if (isHasApp) { return entrypointWithMeta; @@ -77,4 +70,4 @@ export const modifyEntrypoints = ( } return entrypointWithMeta; }); -}; \ No newline at end of file +}; diff --git a/packages/runtime/plugin-runtime/src/router/cli/handler.ts b/packages/runtime/plugin-runtime/src/router/cli/handler.ts index baa271a609f9..9d66ebc65358 100644 --- a/packages/runtime/plugin-runtime/src/router/cli/handler.ts +++ b/packages/runtime/plugin-runtime/src/router/cli/handler.ts @@ -1,13 +1,19 @@ -import path from 'path'; import type { AppNormalizedConfig, AppTools } from '@modern-js/app-tools'; import type { CLIPluginAPI } from '@modern-js/plugin'; -import type { Entrypoint } from '@modern-js/types'; +import type { + Entrypoint, + NestedRouteForCli, + PageRoute, +} from '@modern-js/types'; import { getMeta } from '@modern-js/utils'; import { cloneDeep } from '@modern-js/utils/lodash'; +import path from 'path'; import * as templates from './code/templates'; import { isPageComponentFile } from './code/utils'; import { modifyEntrypoints } from './entry'; +type GeneratedRoutesByEntry = Record; + type RegenerateRoutesFn = (params: { api: CLIPluginAPI; appContext: ReturnType['getAppContext']>; @@ -15,6 +21,10 @@ type RegenerateRoutesFn = (params: { entrypoints: Entrypoint[]; }) => Promise; +type HandleGeneratorEntryCodeOptions = { + entrypointsKey?: string; +}; + type HandleFileChangeOptions = { includeEntry?: (entrypoint: Entrypoint) => boolean; regenerate?: RegenerateRoutesFn; @@ -34,8 +44,12 @@ export async function handleModifyEntrypoints( export async function handleGeneratorEntryCode( api: CLIPluginAPI, entrypoints: Entrypoint[], - entrypointsKey = DEFAULT_ENTRYPOINTS_KEY, -) { + options: HandleGeneratorEntryCodeOptions | string = {}, +): Promise { + const normalizedOptions = + typeof options === 'string' ? { entrypointsKey: options } : options; + const entrypointsKey = + normalizedOptions.entrypointsKey || DEFAULT_ENTRYPOINTS_KEY; const appContext = api.getAppContext(); const { internalDirectory } = appContext; const resolvedConfig = api.getNormalizedConfig(); @@ -97,8 +111,11 @@ export async function handleFileChange( e: any, options: HandleFileChangeOptions = {}, ) { - const { includeEntry, regenerate, entrypointsKey = DEFAULT_ENTRYPOINTS_KEY } = - options; + const { + includeEntry, + regenerate, + entrypointsKey = DEFAULT_ENTRYPOINTS_KEY, + } = options; const appContext = api.getAppContext(); const { appDirectory, entrypoints } = appContext; const activeEntrypoints = includeEntry @@ -157,4 +174,4 @@ export async function handleFileChange( const { generateCode } = await import('./code'); await generateCode(appContext, resolvedConfig, entrypoints, api); } -} \ No newline at end of file +} diff --git a/packages/runtime/plugin-runtime/src/router/cli/index.ts b/packages/runtime/plugin-runtime/src/router/cli/index.ts index 956860112de7..5ab9e3191333 100644 --- a/packages/runtime/plugin-runtime/src/router/cli/index.ts +++ b/packages/runtime/plugin-runtime/src/router/cli/index.ts @@ -1,8 +1,16 @@ import path from 'node:path'; import type { AppTools, CliPlugin } from '@modern-js/app-tools'; -import type { NestedRouteForCli, PageRoute, ServerRoute } from '@modern-js/types'; -import { fs, NESTED_ROUTE_SPEC_FILE, findExists } from '@modern-js/utils'; -import { filterRoutesForServer } from '@modern-js/utils'; +import type { + NestedRouteForCli, + PageRoute, + ServerRoute, +} from '@modern-js/types'; +import { + filterRoutesForServer, + findExists, + fs, + NESTED_ROUTE_SPEC_FILE, +} from '@modern-js/utils'; import { NESTED_ROUTES_DIR } from './constants'; import { getEntrypointRoutesDir, isRouteEntry } from './entry'; import { @@ -65,6 +73,13 @@ function isBuiltInRouteEntrypoint(entrypoint: RouteEntrypointLike) { return Boolean(entrypoint.entry && isRouteEntry(entrypoint.entry)); } +function isPluginOwnedRouteEntrypoint(entrypoint: RouteEntrypointLike) { + const entrypointRoutesDir = getEntrypointRoutesDir(entrypoint); + return Boolean( + entrypointRoutesDir && entrypointRoutesDir !== NESTED_ROUTES_DIR, + ); +} + export const routerPlugin = (): CliPlugin => ({ name: '@modern-js/plugin-router', required: ['@modern-js/runtime'], @@ -102,11 +117,12 @@ export const routerPlugin = (): CliPlugin => ({ .map(route => route.urlPath) .sort((a, b) => (a.length - b.length > 0 ? -1 : 1)); - if ( + const shouldInstallBuiltInRouter = isBuiltInRouteEntrypoint(entrypoint) || - hasUserRouterConfig || - hasRuntimeRouterConfig - ) { + (!isPluginOwnedRouteEntrypoint(entrypoint) && + (hasUserRouterConfig || hasRuntimeRouterConfig)); + + if (shouldInstallBuiltInRouter) { plugins.push({ name: 'router', path: `@${metaName}/runtime/router/internal`, @@ -165,13 +181,15 @@ export const routerPlugin = (): CliPlugin => ({ api.onBeforeGenerateRoutes(async ({ entrypoint, code }) => { if (isBuiltInRouteEntrypoint(entrypoint)) { const { distDirectory } = api.getAppContext(); - const nestedRoutesSpecPath = path.resolve( distDirectory, NESTED_ROUTE_SPEC_FILE, ); const existingNestedRoutes = (await fs.pathExists(nestedRoutesSpecPath)) - ? ((await fs.readJSON(nestedRoutesSpecPath)) as Record) + ? ((await fs.readJSON(nestedRoutesSpecPath)) as Record< + string, + unknown + >) : {}; await fs.outputJSON(nestedRoutesSpecPath, { diff --git a/packages/runtime/plugin-runtime/src/router/runtime/hooks.ts b/packages/runtime/plugin-runtime/src/router/runtime/hooks.ts index cd3e3354e3e8..6e43ec9d0571 100644 --- a/packages/runtime/plugin-runtime/src/router/runtime/hooks.ts +++ b/packages/runtime/plugin-runtime/src/router/runtime/hooks.ts @@ -1,15 +1,35 @@ -import { createAsyncInterruptHook, createSyncHook } from '@modern-js/plugin'; +import { createSyncHook } from '@modern-js/plugin'; import type { RouteObject } from '@modern-js/runtime-utils/router'; import type { TRuntimeContext } from '../../core/context/runtime'; +import type { RouterLifecycleContext } from './lifecycle'; // only for inhouse use const modifyRoutes = createSyncHook<(routes: RouteObject[]) => RouteObject[]>(); const onBeforeCreateRoutes = createSyncHook<(context: TRuntimeContext) => void>(); +const onBeforeCreateRouter = + createSyncHook<(context: RouterLifecycleContext) => void>(); +const onAfterCreateRouter = + createSyncHook<(context: RouterLifecycleContext) => void>(); +const onBeforeHydrateRouter = + createSyncHook<(context: RouterLifecycleContext) => void>(); +const onAfterHydrateRouter = + createSyncHook<(context: RouterLifecycleContext) => void>(); -export { modifyRoutes, onBeforeCreateRoutes }; +export { + modifyRoutes, + onAfterCreateRouter, + onAfterHydrateRouter, + onBeforeCreateRouter, + onBeforeCreateRoutes, + onBeforeHydrateRouter, +}; export type RouterExtendsHooks = { modifyRoutes: typeof modifyRoutes; onBeforeCreateRoutes: typeof onBeforeCreateRoutes; + onBeforeCreateRouter: typeof onBeforeCreateRouter; + onAfterCreateRouter: typeof onAfterCreateRouter; + onBeforeHydrateRouter: typeof onBeforeHydrateRouter; + onAfterHydrateRouter: typeof onAfterHydrateRouter; }; diff --git a/packages/runtime/plugin-runtime/src/router/runtime/lifecycle.ts b/packages/runtime/plugin-runtime/src/router/runtime/lifecycle.ts new file mode 100644 index 000000000000..9d8e18ad3ed1 --- /dev/null +++ b/packages/runtime/plugin-runtime/src/router/runtime/lifecycle.ts @@ -0,0 +1,189 @@ +import type { RouteObject } from '@modern-js/runtime-utils/router'; +import type { TInternalRuntimeContext } from '../../core/context/runtime'; +import type { + InternalRouterRuntimeState, + InternalRouterServerSnapshot, + RouterFramework, + RouterRouteMatchSnapshot, + RouterServerPrepareResult, +} from './types'; + +export type RouterLifecyclePhase = 'ssr-prepare' | 'client-create' | 'hydrate'; + +export type RouterLifecycleContext = { + framework: RouterFramework; + phase: RouterLifecyclePhase; + routes: RouteObject[]; + runtimeContext: TInternalRuntimeContext; + basename?: string; + hydrationData?: unknown; + router?: unknown; + matches?: RouterRouteMatchSnapshot[]; + cleanup?: () => void | Promise; + serverSnapshot?: InternalRouterServerSnapshot; +}; + +type RouterSnapshotLike = Partial; + +function toHydrationScripts(state: { + hydrationScript?: string; + hydrationScripts?: string[]; +}) { + if (state.hydrationScripts?.length) { + return state.hydrationScripts; + } + + return state.hydrationScript ? [state.hydrationScript] : undefined; +} + +function getMatchedRouteIdsFromMatches(matches?: RouterRouteMatchSnapshot[]) { + const routeIds = matches + ?.map(match => match.assetRouteId ?? match.routeId) + .filter((routeId): routeId is string => typeof routeId === 'string'); + + return routeIds?.length ? routeIds : undefined; +} + +export function createRouterServerSnapshot( + state: RouterSnapshotLike, +): InternalRouterServerSnapshot { + const hydrationScripts = toHydrationScripts(state); + const matchedRouteIds = + state.matchedRouteIds ?? getMatchedRouteIdsFromMatches(state.matches); + + return { + ...state, + ...(hydrationScripts?.length + ? { + hydrationScript: state.hydrationScript ?? hydrationScripts[0], + hydrationScripts, + } + : {}), + ...(matchedRouteIds ? { matchedRouteIds } : {}), + }; +} + +export function createRouterRuntimeState( + state: InternalRouterRuntimeState, +): InternalRouterRuntimeState { + const hasSnapshotState = + Boolean(state.serverSnapshot) || + Boolean(state.hydrationScript) || + Boolean(state.hydrationScripts?.length) || + Boolean(state.matchedRouteIds?.length) || + Boolean(state.matches?.length); + const serverSnapshot = state.serverSnapshot + ? createRouterServerSnapshot({ + ...state.serverSnapshot, + framework: state.serverSnapshot.framework ?? state.framework, + basename: state.serverSnapshot.basename ?? state.basename, + hydrationScript: + state.serverSnapshot.hydrationScript ?? state.hydrationScript, + hydrationScripts: + state.serverSnapshot.hydrationScripts ?? state.hydrationScripts, + matchedRouteIds: + state.serverSnapshot.matchedRouteIds ?? state.matchedRouteIds, + matches: state.serverSnapshot.matches ?? state.matches, + }) + : hasSnapshotState + ? createRouterServerSnapshot({ + framework: state.framework, + basename: state.basename, + hydrationScript: state.hydrationScript, + hydrationScripts: state.hydrationScripts, + matchedRouteIds: state.matchedRouteIds, + matches: state.matches, + }) + : undefined; + const hydrationScripts = toHydrationScripts({ + hydrationScript: state.hydrationScript ?? serverSnapshot?.hydrationScript, + hydrationScripts: + state.hydrationScripts ?? serverSnapshot?.hydrationScripts, + }); + const matchedRouteIds = + state.matchedRouteIds ?? + serverSnapshot?.matchedRouteIds ?? + getMatchedRouteIdsFromMatches(state.matches); + + return { + ...state, + ...(hydrationScripts?.length + ? { + hydrationScript: state.hydrationScript ?? hydrationScripts[0], + hydrationScripts, + } + : {}), + ...(matchedRouteIds ? { matchedRouteIds } : {}), + ...(serverSnapshot ? { serverSnapshot } : {}), + }; +} + +export function applyRouterRuntimeState( + runtimeContext: TInternalRuntimeContext, + state: InternalRouterRuntimeState, +) { + const normalized = createRouterRuntimeState(state); + runtimeContext.routerFramework = normalized.framework; + runtimeContext.routerInstance = normalized.instance; + runtimeContext.routerHydrationScript = normalized.hydrationScript; + runtimeContext.routerMatchedRouteIds = normalized.matchedRouteIds; + runtimeContext.routerRuntime = normalized; + if (normalized.serverSnapshot) { + runtimeContext.routerServerSnapshot = normalized.serverSnapshot; + } + + return runtimeContext; +} + +export function applyRouterServerPrepareResult( + runtimeContext: TInternalRuntimeContext, + result: RouterServerPrepareResult, +) { + const state = createRouterRuntimeState({ + ...result.state, + cleanup: result.cleanup ?? result.state.cleanup, + serverSnapshot: result.snapshot ?? result.state.serverSnapshot, + }); + applyRouterRuntimeState(runtimeContext, state); + return runtimeContext; +} + +export function getRouterHydrationScripts( + runtimeContext: TInternalRuntimeContext, +) { + return ( + runtimeContext.routerServerSnapshot?.hydrationScripts ?? + toHydrationScripts({ + hydrationScript: runtimeContext.routerServerSnapshot?.hydrationScript, + }) ?? + runtimeContext.routerRuntime?.hydrationScripts ?? + toHydrationScripts({ + hydrationScript: + runtimeContext.routerRuntime?.hydrationScript ?? + runtimeContext.routerHydrationScript, + }) ?? + [] + ); +} + +export function getRouterMatchedRouteIds( + runtimeContext: TInternalRuntimeContext, +) { + return ( + runtimeContext.routerServerSnapshot?.matchedRouteIds ?? + getMatchedRouteIdsFromMatches( + runtimeContext.routerServerSnapshot?.matches, + ) ?? + runtimeContext.routerRuntime?.matchedRouteIds ?? + getMatchedRouteIdsFromMatches(runtimeContext.routerRuntime?.matches) ?? + runtimeContext.routerMatchedRouteIds + ); +} + +export async function cleanupRouterRuntimeState( + runtimeContext: TInternalRuntimeContext, +) { + try { + await runtimeContext.routerRuntime?.cleanup?.(); + } catch {} +} diff --git a/packages/runtime/plugin-runtime/src/router/runtime/plugin.node.tsx b/packages/runtime/plugin-runtime/src/router/runtime/plugin.node.tsx index 819f2432ac63..a01f664a8f0d 100644 --- a/packages/runtime/plugin-runtime/src/router/runtime/plugin.node.tsx +++ b/packages/runtime/plugin-runtime/src/router/runtime/plugin.node.tsx @@ -3,14 +3,13 @@ import { createRequestContext, reporterCtx, } from '@modern-js/runtime-utils/node'; -import { createStaticHandler } from '@modern-js/runtime-utils/router'; import { - StaticRouterProvider, + createRoutesFromElements, + createStaticHandler, createStaticRouter, -} from '@modern-js/runtime-utils/router'; -import { type RouteObject, - createRoutesFromElements, + type StaticHandlerContext, + StaticRouterProvider, } from '@modern-js/runtime-utils/router'; import { time } from '@modern-js/runtime-utils/time'; import { LOADER_REPORTER_NAME } from '@modern-js/utils/universal/constants'; @@ -18,26 +17,33 @@ import type React from 'react'; import { useContext } from 'react'; import type { RuntimePlugin } from '../../core'; import { - InternalRuntimeContext, - type ServerPayload, getGlobalEnableRsc, getGlobalLayoutApp, getGlobalRoutes, + InternalRuntimeContext, + type ServerPayload, } from '../../core/context'; import { setServerPayload } from '../../core/context/serverPayload/index.server'; import DeferredDataScripts from './DeferredDataScripts.node'; import { - type RouterExtendsHooks, modifyRoutes as modifyRoutesHook, + onAfterCreateRouter as onAfterCreateRouterHook, + onBeforeCreateRouter as onBeforeCreateRouterHook, onBeforeCreateRoutes as onBeforeCreateRoutesHook, + type RouterExtendsHooks, } from './hooks'; import { - RSCStaticRouter, + applyRouterRuntimeState, + createRouterServerSnapshot, + type RouterLifecycleContext, +} from './lifecycle'; +import { createServerPayload, handleRSCRedirect, prepareRSCRoutes, + RSCStaticRouter, } from './rsc-router'; -import type { RouterConfig } from './types'; +import type { InternalRouterServerSnapshot, RouterConfig } from './types'; import { createRouteObjectsFromConfig, renderRoutes, urlJoin } from './utils'; function createRemixRequest(request: Request) { @@ -52,6 +58,30 @@ function createRemixRequest(request: Request) { }); } +function createReactRouterServerSnapshot( + routerContext: StaticHandlerContext, + basename?: string, +): InternalRouterServerSnapshot { + return createRouterServerSnapshot({ + framework: 'react-router', + basename, + statusCode: routerContext.statusCode, + errors: routerContext.errors as Record | undefined, + routerData: { + loaderData: routerContext.loaderData, + errors: routerContext.errors as Record | undefined, + }, + matches: routerContext.matches + .map(match => { + const routeId = match.route.id; + return typeof routeId === 'string' ? { routeId } : undefined; + }) + .filter( + (match): match is { routeId: string } => typeof match !== 'undefined', + ), + }); +} + export const routerPlugin = ( userConfig: Partial = {}, ): RuntimePlugin<{ @@ -60,6 +90,8 @@ export const routerPlugin = ( return { name: '@modern-js/plugin-router', registryHooks: { + onAfterCreateRouter: onAfterCreateRouterHook, + onBeforeCreateRouter: onBeforeCreateRouterHook, modifyRoutes: modifyRoutesHook, onBeforeCreateRoutes: onBeforeCreateRoutesHook, }, @@ -130,6 +162,15 @@ export const routerPlugin = ( routes = hooks.modifyRoutes.call(routes); + const routerLifecycleContext: RouterLifecycleContext = { + framework: 'react-router', + phase: 'ssr-prepare', + routes, + runtimeContext: context, + basename: _basename, + }; + hooks.onBeforeCreateRouter.call(routerLifecycleContext); + const { query } = createStaticHandler(routes, { basename: _basename, }); @@ -180,6 +221,24 @@ export const routerPlugin = ( throw errors[0]; } context.routerContext = routerContext; + const routerServerSnapshot = createReactRouterServerSnapshot( + routerContext, + _basename, + ); + + applyRouterRuntimeState(context, { + framework: 'react-router', + basename: _basename, + instance: routerContext, + matchedRouteIds: routerServerSnapshot.matchedRouteIds, + serverSnapshot: routerServerSnapshot, + }); + hooks.onAfterCreateRouter.call({ + ...routerLifecycleContext, + router: routerContext, + serverSnapshot: routerServerSnapshot, + runtimeContext: context, + }); let payload: ServerPayload; if (enableRsc) { diff --git a/packages/runtime/plugin-runtime/src/router/runtime/types.ts b/packages/runtime/plugin-runtime/src/router/runtime/types.ts index d5faf75b82fe..b13700553e1b 100644 --- a/packages/runtime/plugin-runtime/src/router/runtime/types.ts +++ b/packages/runtime/plugin-runtime/src/router/runtime/types.ts @@ -22,7 +22,16 @@ export type SingleRouteConfig = RouteProps & { component?: React.ComponentType; }; +export type BuiltInRouterFramework = 'react-router' | 'tanstack'; +export type RouterFramework = BuiltInRouterFramework | (string & {}); + export type RouterConfig = { + /** + * Select the router implementation used by Modern.js conventional routing. + * - `react-router` (default): React Router v7 based integration + * - `tanstack`: TanStack Router integration + */ + framework?: RouterFramework; routesConfig: { globalApp?: React.ComponentType; routes?: (NestedRoute | PageRoute)[]; @@ -46,10 +55,51 @@ export type RouterConfig = { export type Routes = RouterConfig['routesConfig']['routes']; +export interface RouterRouteMatchSnapshot { + routeId: string; + assetRouteId?: string; + pathname?: string; + params?: Record; +} + export interface RouteManifest { routeAssets: RouteAssets; } +export interface InternalRouterServerSnapshot { + framework?: RouterFramework; + basename?: string; + statusCode?: number; + errors?: Record; + routerData?: { + loaderData?: Record; + errors?: Record; + }; + hydrationScript?: string; + hydrationScripts?: string[]; + matchedRouteIds?: string[]; + matches?: RouterRouteMatchSnapshot[]; +} + +export interface InternalRouterRuntimeState { + framework: RouterFramework; + basename?: string; + instance?: unknown; + hydrationScript?: string; + hydrationScripts?: string[]; + matchedRouteIds?: string[]; + matches?: RouterRouteMatchSnapshot[]; + serverSnapshot?: InternalRouterServerSnapshot; + cleanup?: () => void | Promise; +} + +export interface RouterServerPrepareResult { + state: InternalRouterRuntimeState; + snapshot?: InternalRouterServerSnapshot; + redirect?: Response; + cleanup?: () => void | Promise; +} + export interface RouteAssets { [routeId: string]: { chunkIds?: (string | number)[]; diff --git a/packages/runtime/plugin-runtime/tests/core/react/wrapper.test.tsx b/packages/runtime/plugin-runtime/tests/core/react/wrapper.test.tsx new file mode 100644 index 000000000000..439089c6a0db --- /dev/null +++ b/packages/runtime/plugin-runtime/tests/core/react/wrapper.test.tsx @@ -0,0 +1,49 @@ +import React, { useContext } from 'react'; +import { renderToString } from 'react-dom/server'; +import { + getInitialContext, + InternalRuntimeContext, + RuntimeContext, +} from '../../../src/core/context'; +import { wrapRuntimeContextProvider } from '../../../src/core/react/wrapper'; + +describe('wrapRuntimeContextProvider', () => { + it('should keep routerServerSnapshot internal-only', () => { + let runtimeValue: Record | undefined; + let internalValue: Record | undefined; + + const Probe = () => { + runtimeValue = useContext(RuntimeContext) as Record; + internalValue = useContext(InternalRuntimeContext) as Record< + string, + unknown + >; + return null; + }; + + const context = getInitialContext(false); + context.routerFramework = 'custom-router'; + context.routerInstance = { kind: 'internal-router' }; + context.routerServerSnapshot = { + matchedRouteIds: ['route-a'], + }; + + renderToString( + wrapRuntimeContextProvider( + , + context as Record as any, + ), + ); + + expect(internalValue?.routerServerSnapshot).toEqual({ + matchedRouteIds: ['route-a'], + }); + expect(internalValue?.routerFramework).toBe('custom-router'); + expect(internalValue?.routerInstance).toEqual({ + kind: 'internal-router', + }); + expect(runtimeValue?.routerFramework).toBe('custom-router'); + expect(runtimeValue?.routerInstance).toBeUndefined(); + expect(runtimeValue?.routerServerSnapshot).toBeUndefined(); + }); +}); diff --git a/packages/runtime/plugin-runtime/tests/router/cliExtension.test.ts b/packages/runtime/plugin-runtime/tests/router/cliExtension.test.ts new file mode 100644 index 000000000000..62134e2b58d0 --- /dev/null +++ b/packages/runtime/plugin-runtime/tests/router/cliExtension.test.ts @@ -0,0 +1,338 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import type { Entrypoint } from '@modern-js/types'; +import { fs, NESTED_ROUTE_SPEC_FILE } from '@modern-js/utils'; +import { routerPlugin } from '../../src/router/cli'; +import { + getEntrypointRoutesDir, + isRouteEntry, +} from '../../src/router/cli/entry'; +import { + handleFileChange, + handleGeneratorEntryCode, + handleModifyEntrypoints, +} from '../../src/router/cli/handler'; + +const createConfig = () => + ({ + output: {}, + server: { + ssr: false, + ssrByEntries: {}, + ssrByRouteIds: [], + rsc: false, + }, + }) as any; + +const createApi = (appContext: any, hooks?: any) => + ({ + getAppContext: () => appContext, + getNormalizedConfig: createConfig, + getHooks: () => + hooks || { + modifyFileSystemRoutes: { + call: async (params: any) => params, + }, + onBeforeGenerateRoutes: { + call: async (params: any) => params, + }, + }, + }) as any; + +describe('router cli extension points', () => { + let tempDir: string | undefined; + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } + }); + + test('tracks plugin-owned route directory metadata', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'modern-router-cli-')); + const entryDir = path.join(tempDir, 'src', 'main'); + const viewsDir = path.join(entryDir, 'views'); + await mkdir(viewsDir, { recursive: true }); + + const [entrypoint] = await handleModifyEntrypoints( + [ + { + entryName: 'main', + isMainEntry: true, + entry: entryDir, + absoluteEntryDir: entryDir, + isAutoMount: true, + } as Entrypoint, + ], + 'views', + ); + + expect(entrypoint.nestedRoutesEntry).toBe(viewsDir); + expect(getEntrypointRoutesDir(entrypoint)).toBe('views'); + expect(isRouteEntry(entryDir, 'views')).toBe(viewsDir); + }); + + test('generates routes for plugin-owned entries and returns routes by entry', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'modern-router-cli-')); + const appDirectory = tempDir; + const srcDirectory = path.join(tempDir, 'src'); + const internalDirectory = path.join(tempDir, 'node_modules', '.modern-js'); + const entryDir = path.join(srcDirectory, 'main'); + const viewsDir = path.join(entryDir, 'views'); + await mkdir(viewsDir, { recursive: true }); + await writeFile( + path.join(viewsDir, 'layout.tsx'), + 'export default function Layout() { return null; }', + ); + await writeFile( + path.join(viewsDir, 'page.tsx'), + 'export default function Page() { return null; }', + ); + + const [entrypoint] = await handleModifyEntrypoints( + [ + { + entryName: 'main', + isMainEntry: true, + entry: entryDir, + absoluteEntryDir: entryDir, + isAutoMount: true, + } as Entrypoint, + ], + 'views', + ); + + const appContext = { + appDirectory, + srcDirectory, + internalDirectory, + internalSrcAlias: '@_modern_js_src', + metaName: 'modern-js', + packageName: 'test-app', + serverRoutes: [{ entryName: 'main', urlPath: '/' }], + entrypoints: [entrypoint], + }; + + const routesByEntry = await handleGeneratorEntryCode( + createApi(appContext), + [entrypoint], + { + entrypointsKey: 'fake-router', + generateCodeOptions: { enableTanstackTypes: false }, + }, + ); + + expect(routesByEntry.main).toHaveLength(1); + expect(JSON.stringify(routesByEntry.main)).toContain('"id":"page"'); + + const generatedRoutes = await readFile( + path.join(internalDirectory, 'main', 'routes.js'), + 'utf-8', + ); + expect(generatedRoutes).toContain('@_modern_js_src/main/views/page'); + + const runtimeContext = await readFile( + path.join(internalDirectory, 'main', 'runtime-global-context.js'), + 'utf-8', + ); + expect(runtimeContext).toContain("import { routes } from './routes'"); + }); + + test('regenerates only the scoped route entries for file changes', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'modern-router-cli-')); + const appDirectory = tempDir; + const srcDirectory = path.join(tempDir, 'src'); + const pluginEntryDir = path.join(srcDirectory, 'main'); + const builtInEntryDir = path.join(srcDirectory, 'dashboard'); + await mkdir(path.join(pluginEntryDir, 'views'), { recursive: true }); + await mkdir(path.join(builtInEntryDir, 'routes'), { recursive: true }); + + const [pluginEntry] = await handleModifyEntrypoints( + [ + { + entryName: 'main', + isMainEntry: true, + entry: pluginEntryDir, + absoluteEntryDir: pluginEntryDir, + isAutoMount: true, + } as Entrypoint, + ], + 'views', + ); + const [builtInEntry] = await handleModifyEntrypoints([ + { + entryName: 'dashboard', + isMainEntry: false, + entry: builtInEntryDir, + absoluteEntryDir: builtInEntryDir, + isAutoMount: true, + } as Entrypoint, + ]); + + const appContext = { + appDirectory, + srcDirectory, + internalSrcAlias: '@_modern_js_src', + metaName: 'modern-js', + entrypoints: [pluginEntry, builtInEntry], + }; + const api = createApi(appContext); + const regeneratePlugin = rstest.fn(async (_params: any) => {}); + const regenerateBuiltIn = rstest.fn(async (_params: any) => {}); + const filename = path.relative( + appDirectory, + path.join(pluginEntryDir, 'views', 'page.tsx'), + ); + + await handleFileChange( + api, + { filename, eventType: 'add' }, + { + entrypointsKey: 'fake-router-file-change', + includeEntry: entrypoint => + getEntrypointRoutesDir(entrypoint) === 'views', + regenerate: regeneratePlugin, + }, + ); + await handleFileChange( + api, + { filename, eventType: 'add' }, + { + entrypointsKey: 'built-in-router', + includeEntry: entrypoint => + getEntrypointRoutesDir(entrypoint) === 'routes', + regenerate: regenerateBuiltIn, + }, + ); + + expect(regeneratePlugin).toHaveBeenCalledTimes(1); + const pluginRegenerateParams = regeneratePlugin.mock.calls[0]?.[0] as any; + expect(pluginRegenerateParams.entrypoints).toHaveLength(1); + expect(pluginRegenerateParams.entrypoints[0]).toMatchObject({ + entryName: pluginEntry.entryName, + nestedRoutesEntry: pluginEntry.nestedRoutesEntry, + }); + expect(regenerateBuiltIn).not.toHaveBeenCalled(); + }); + + test('built-in router ignores plugin-owned routes and merges route spec json', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'modern-router-cli-')); + const appDirectory = tempDir; + const srcDirectory = path.join(tempDir, 'src'); + const distDirectory = path.join(tempDir, 'dist'); + const pluginEntryDir = path.join(srcDirectory, 'main'); + const builtInEntryDir = path.join(srcDirectory, 'dashboard'); + await mkdir(path.join(pluginEntryDir, 'views'), { recursive: true }); + await mkdir(path.join(builtInEntryDir, 'routes'), { recursive: true }); + + const [pluginEntry] = await handleModifyEntrypoints( + [ + { + entryName: 'main', + isMainEntry: true, + entry: pluginEntryDir, + absoluteEntryDir: pluginEntryDir, + isAutoMount: true, + } as Entrypoint, + ], + 'views', + ); + const [builtInEntry] = await handleModifyEntrypoints([ + { + entryName: 'dashboard', + isMainEntry: false, + entry: builtInEntryDir, + absoluteEntryDir: builtInEntryDir, + isAutoMount: true, + } as Entrypoint, + ]); + + const taps: Record = {}; + const api = { + getAppContext: () => ({ + appDirectory, + srcDirectory, + distDirectory, + metaName: 'modern-js', + runtimeConfigFile: 'modern.runtime', + serverRoutes: [{ entryName: 'dashboard', urlPath: '/dashboard' }], + }), + getNormalizedConfig: () => ({ router: { custom: true } }), + addCommand: () => {}, + _internalRuntimePlugins: (tap: any) => { + taps.internalRuntimePlugins = tap; + }, + checkEntryPoint: (tap: any) => { + taps.checkEntryPoint = tap; + }, + config: (tap: any) => { + taps.config = tap; + }, + modifyEntrypoints: (tap: any) => { + taps.modifyEntrypoints = tap; + }, + generateEntryCode: (tap: any) => { + taps.generateEntryCode = tap; + }, + onFileChanged: (tap: any) => { + taps.onFileChanged = tap; + }, + modifyFileSystemRoutes: (tap: any) => { + taps.modifyFileSystemRoutes = tap; + }, + onBeforeGenerateRoutes: (tap: any) => { + taps.onBeforeGenerateRoutes = tap; + }, + }; + routerPlugin().setup!(api as any); + + expect( + taps.internalRuntimePlugins!({ entrypoint: pluginEntry, plugins: [] }) + .plugins, + ).toEqual([]); + expect( + taps.internalRuntimePlugins!({ entrypoint: builtInEntry, plugins: [] }) + .plugins, + ).toEqual([ + { + name: 'router', + path: '@modern-js/runtime/router/internal', + config: { serverBase: ['/dashboard'] }, + }, + ]); + + const specPath = path.join(distDirectory, NESTED_ROUTE_SPEC_FILE); + await fs.outputJSON(specPath, { + main: [{ id: 'plugin-owned-route' }], + }); + + taps.modifyFileSystemRoutes({ + entrypoint: pluginEntry, + routes: [{ id: 'plugin-route', type: 'nested', origin: 'file-system' }], + }); + taps.modifyFileSystemRoutes({ + entrypoint: builtInEntry, + routes: [ + { + id: 'dashboard-route', + type: 'nested', + origin: 'file-system', + }, + ], + }); + await taps.onBeforeGenerateRoutes({ entrypoint: builtInEntry, code: '' }); + + expect(await fs.readJSON(specPath)).toEqual({ + main: [{ id: 'plugin-owned-route' }], + dashboard: [ + { + id: 'dashboard-route', + type: 'nested', + origin: 'file-system', + }, + ], + }); + }); +}); diff --git a/packages/runtime/plugin-runtime/tests/router/lifecycle.test.tsx b/packages/runtime/plugin-runtime/tests/router/lifecycle.test.tsx new file mode 100644 index 000000000000..9c7fe1d513bb --- /dev/null +++ b/packages/runtime/plugin-runtime/tests/router/lifecycle.test.tsx @@ -0,0 +1,107 @@ +import { getInitialContext } from '../../src/core/context'; +import { + modifyRoutes, + onAfterCreateRouter, + onAfterHydrateRouter, + onBeforeCreateRouter, + onBeforeCreateRoutes, + onBeforeHydrateRouter, +} from '../../src/router/runtime/hooks'; +import { + applyRouterRuntimeState, + applyRouterServerPrepareResult, + createRouterServerSnapshot, + getRouterHydrationScripts, + getRouterMatchedRouteIds, +} from '../../src/router/runtime/lifecycle'; + +describe('router lifecycle seams', () => { + it('should expose generic router runtime state helpers', () => { + const context = getInitialContext(true) as any; + applyRouterRuntimeState(context, { + framework: 'custom-router', + basename: '/shell', + instance: { kind: 'router' }, + matches: [{ routeId: 'route-a', assetRouteId: 'mf/page' }], + hydrationScripts: ['', ''], + serverSnapshot: { + matches: [{ routeId: 'route-a', assetRouteId: 'mf/page' }], + }, + }); + + expect(context.routerFramework).toBe('custom-router'); + expect(context.routerRuntime).toMatchObject({ + framework: 'custom-router', + basename: '/shell', + matchedRouteIds: ['mf/page'], + hydrationScript: '', + hydrationScripts: ['', ''], + }); + expect(context.routerServerSnapshot).toMatchObject({ + framework: 'custom-router', + basename: '/shell', + matchedRouteIds: ['mf/page'], + hydrationScript: '', + hydrationScripts: ['', ''], + }); + expect(getRouterHydrationScripts(context)).toEqual([ + '', + '', + ]); + expect(getRouterMatchedRouteIds(context)).toEqual(['mf/page']); + }); + + it('should normalize and apply generic server prepare results', () => { + const context = getInitialContext(false) as any; + let cleaned = false; + const snapshot = createRouterServerSnapshot({ + framework: 'plugin-router', + basename: '/app', + statusCode: 299, + errors: { root: new Error('plugin error') }, + routerData: { + loaderData: { root: { ok: true } }, + errors: {}, + }, + hydrationScripts: [''], + matches: [{ routeId: 'root', assetRouteId: 'asset-root' }], + }); + + applyRouterServerPrepareResult(context, { + snapshot, + cleanup: () => { + cleaned = true; + }, + state: { + framework: 'plugin-router', + basename: '/app', + instance: { opaque: true }, + }, + }); + + expect(context.routerInstance).toEqual({ opaque: true }); + expect(context.routerServerSnapshot).toMatchObject({ + framework: 'plugin-router', + basename: '/app', + statusCode: 299, + matchedRouteIds: ['asset-root'], + hydrationScript: '', + }); + context.routerRuntime.cleanup(); + expect(cleaned).toBe(true); + }); + + it('should register create and hydrate hook surfaces alongside existing route hooks', () => { + for (const hook of [ + modifyRoutes, + onBeforeCreateRoutes, + onBeforeCreateRouter, + onAfterCreateRouter, + onBeforeHydrateRouter, + onAfterHydrateRouter, + ]) { + expect(hook).toBeDefined(); + expect(typeof (hook as any).call).toBe('function'); + } + }); +}); diff --git a/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToStream/buildTemplate.after.test.ts b/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToStream/buildTemplate.after.test.ts new file mode 100644 index 000000000000..2a5f6e19708c --- /dev/null +++ b/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToStream/buildTemplate.after.test.ts @@ -0,0 +1,122 @@ +import { RenderLevel } from '../../../../src/core/constants'; +import { SSR_DATA_PLACEHOLDER } from '../../../../src/core/server/constants'; +import { buildShellAfterTemplate } from '../../../../src/core/server/stream/afterTemplate'; +import { SSRDataCollector } from '../../../../src/core/server/string/ssrData'; + +describe('SSRDataCollector (stream parity)', () => { + it('should strip denylisted headers from serialized SSR data script', () => { + const chunkSet = { + renderLevel: RenderLevel.SERVER_RENDER, + ssrScripts: '', + jsChunk: '', + cssChunk: '', + }; + + const collector = new SSRDataCollector({ + runtimeContext: { + initialData: {}, + __i18nData__: {}, + } as any, + request: new Request('http://localhost/'), + chunkSet, + ssrContext: { + request: { + params: {}, + query: {}, + pathname: '/', + host: 'localhost', + url: 'http://localhost/', + headers: { + authorization: 'Bearer secret', + cookie: 'sid=abc', + 'x-request-id': 'req-1', + 'x-internal-secret': 'hidden', + }, + }, + reporter: { sessionId: 'session-1' }, + } as any, + ssrConfig: { + unsafeHeaders: ['x-request-id'], + } as any, + }); + + collector.effect(); + + expect(chunkSet.ssrScripts).toMatch('"x-request-id":"req-1"'); + expect(chunkSet.ssrScripts).not.toMatch('authorization'); + expect(chunkSet.ssrScripts).not.toMatch('cookie'); + expect(chunkSet.ssrScripts).not.toMatch('x-internal-secret'); + }); + + it('should append router hydration script from the shared router snapshot', () => { + const chunkSet = { + renderLevel: RenderLevel.SERVER_RENDER, + ssrScripts: '', + jsChunk: '', + cssChunk: '', + }; + + const collector = new SSRDataCollector({ + runtimeContext: { + initialData: {}, + __i18nData__: {}, + routerServerSnapshot: { + hydrationScript: '', + }, + } as any, + request: new Request('http://localhost/'), + chunkSet, + ssrContext: { + request: { + params: {}, + query: {}, + pathname: '/', + host: 'localhost', + url: 'http://localhost/', + headers: {}, + }, + reporter: { sessionId: 'session-1' }, + } as any, + ssrConfig: {} as any, + }); + + collector.effect(); + + expect(chunkSet.ssrScripts).toContain('window.__HYDRATE__ = "router";'); + }); + + it('should inject generic router hydration scripts into stream templates', async () => { + const html = await buildShellAfterTemplate(SSR_DATA_PLACEHOLDER, { + entryName: 'main', + renderLevel: RenderLevel.SERVER_RENDER, + request: new Request('http://localhost/'), + runtimeContext: { + initialData: {}, + __i18nData__: {}, + routeManifest: {}, + ssrContext: { + request: { + params: {}, + query: {}, + pathname: '/', + host: 'localhost', + url: 'http://localhost/', + headers: {}, + }, + reporter: { sessionId: 'session-1' }, + }, + routerServerSnapshot: { + hydrationScripts: [ + '', + '', + ], + }, + } as any, + ssrConfig: {} as any, + config: {} as any, + }); + + expect(html).toContain('window.__STREAM_ROUTER_A__ = true;'); + expect(html).toContain('window.__STREAM_ROUTER_B__ = true;'); + }); +}); diff --git a/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToStream/buildTemplate.before.test.ts b/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToStream/buildTemplate.before.test.ts new file mode 100644 index 000000000000..10a888fe9c1e --- /dev/null +++ b/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToStream/buildTemplate.before.test.ts @@ -0,0 +1,57 @@ +import { CHUNK_CSS_PLACEHOLDER } from '../../../../src/core/server/constants'; +import { buildShellBeforeTemplate } from '../../../../src/core/server/stream/beforeTemplate'; + +describe('buildShellBeforeTemplate', () => { + it('should use shared matched route ids from the router snapshot for css injection', async () => { + const html = await buildShellBeforeTemplate( + `${CHUNK_CSS_PLACEHOLDER}`, + { + entryName: 'main', + runtimeContext: { + routeManifest: { + routeAssets: { + 'route-a': { + referenceCssAssets: ['/assets/route-a.css'], + }, + }, + }, + routerServerSnapshot: { + matchedRouteIds: ['route-a'], + }, + } as any, + config: {} as any, + }, + ); + + expect(html).toContain('/assets/route-a.css'); + }); + + it('should derive css route ids from generic match snapshots', async () => { + const html = await buildShellBeforeTemplate( + `${CHUNK_CSS_PLACEHOLDER}`, + { + entryName: 'main', + runtimeContext: { + routeManifest: { + routeAssets: { + 'asset-route': { + referenceCssAssets: ['/assets/asset-route.css'], + }, + legacy: { + referenceCssAssets: ['/assets/legacy.css'], + }, + }, + }, + routerServerSnapshot: { + matches: [{ routeId: 'router-route', assetRouteId: 'asset-route' }], + }, + tanstackMatchedModernRouteIds: ['legacy'], + } as any, + config: {} as any, + }, + ); + + expect(html).toContain('/assets/asset-route.css'); + expect(html).not.toContain('/assets/legacy.css'); + }); +}); diff --git a/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToString/__snapshots__/entry.test.ts.snap b/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToString/__snapshots__/entry.test.ts.snap new file mode 100644 index 000000000000..783e45357ab0 --- /dev/null +++ b/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToString/__snapshots__/entry.test.ts.snap @@ -0,0 +1,7 @@ +// Rstest Snapshot v1 + +exports[`SSR data script generation > should inject inline script correctly 1`] = `""`; + +exports[`SSR data script generation > should inject inline scripts with nonce correctly 1`] = `""`; + +exports[`SSR data script generation > should inject json script correctly 1`] = `""`; diff --git a/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToString/entry.test.ts b/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToString/entry.test.ts new file mode 100644 index 000000000000..a4c548272a75 --- /dev/null +++ b/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToString/entry.test.ts @@ -0,0 +1,109 @@ +import { RenderLevel } from '../../../../src/core/constants'; +import { SSRDataCollector } from '../../../../src/core/server/string/ssrData'; + +const createScripts = (options?: { + useJsonScript?: boolean; + nonce?: string; + unsafeHeaders?: string[]; + routerServerSnapshot?: { + routerData?: { + loaderData?: Record; + errors?: Record; + }; + hydrationScript?: string; + hydrationScripts?: string[]; + }; +}) => { + const chunkSet = { + renderLevel: RenderLevel.SERVER_RENDER, + ssrScripts: '', + jsChunk: '', + cssChunk: '', + }; + + const collector = new SSRDataCollector({ + runtimeContext: { + initialData: { name: 'modern.js' }, + __i18nData__: {}, + routerServerSnapshot: options?.routerServerSnapshot, + } as any, + request: new Request('http://localhost/'), + chunkSet, + ssrContext: { + request: { + params: {}, + query: {}, + pathname: '/', + host: 'localhost', + url: 'http://localhost/', + headers: { + authorization: 'Bearer secret', + cookie: 'sid=abc', + 'x-request-id': 'req-1', + 'x-internal-secret': 'hidden', + }, + }, + reporter: { sessionId: 'session-1' }, + } as any, + ssrConfig: { + unsafeHeaders: options?.unsafeHeaders, + } as any, + nonce: options?.nonce, + useJsonScript: options?.useJsonScript, + }); + + collector.effect(); + return chunkSet.ssrScripts; +}; + +describe('SSR data script generation', () => { + it('should inject json script correctly', () => { + expect(createScripts({ useJsonScript: true })).toMatchSnapshot(); + }); + + it('should inject inline scripts with nonce correctly', () => { + expect(createScripts({ nonce: 'test-nonce' })).toMatchSnapshot(); + }); + + it('should inject inline script correctly', () => { + expect(createScripts()).toMatchSnapshot(); + }); + + it('should strip denylisted headers from serialized SSR payload', () => { + const scripts = createScripts({ unsafeHeaders: ['x-request-id'] }); + expect(scripts).toMatch('"x-request-id":"req-1"'); + expect(scripts).not.toMatch('authorization'); + expect(scripts).not.toMatch('cookie'); + expect(scripts).not.toMatch('x-internal-secret'); + }); + + it('should use router snapshot data and hydration script when present', () => { + const scripts = createScripts({ + routerServerSnapshot: { + routerData: { + loaderData: { route: { ok: true } }, + errors: {}, + }, + hydrationScript: '', + }, + }); + + expect(scripts).toContain('window.__ROUTER_SSR__ = true;'); + expect(scripts).toContain('loaderData'); + expect(scripts).toContain('"ok":true'); + }); + + it('should serialize generic router hydration scripts when present', () => { + const scripts = createScripts({ + routerServerSnapshot: { + hydrationScripts: [ + '', + '', + ], + }, + }); + + expect(scripts).toContain('window.__ROUTER_A__ = true;'); + expect(scripts).toContain('window.__ROUTER_B__ = true;'); + }); +}); diff --git a/packages/runtime/plugin-runtime/tests/ssr/serverRender/requestHandler.test.tsx b/packages/runtime/plugin-runtime/tests/ssr/serverRender/requestHandler.test.tsx new file mode 100644 index 000000000000..eab34996e58c --- /dev/null +++ b/packages/runtime/plugin-runtime/tests/ssr/serverRender/requestHandler.test.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { + setGlobalContext, + setGlobalInternalRuntimeContext, +} from '../../../src/core/context'; +import { SSRErrors } from '../../../src/core/server/tracer'; + +describe('createRequestHandler router snapshot fallback', () => { + it('should honor loader status and errors from routerServerSnapshot when routerContext is absent', async () => { + const onErrorCalls: unknown[][] = []; + const onError = (...args: unknown[]) => { + onErrorCalls.push(args); + }; + ( + globalThis as typeof globalThis & { + __webpack_require__?: { u: (chunkId: unknown) => string }; + } + ).__webpack_require__ = { + u: chunkId => String(chunkId), + }; + + setGlobalContext({ + entryName: 'main', + App: () => React.createElement('div', null, 'app'), + enableRsc: false, + }); + setGlobalInternalRuntimeContext({ + hooks: { + wrapRoot: { + call: (App: React.ComponentType) => App, + }, + onBeforeRender: { + call: async (context: any) => { + context.routerServerSnapshot = { + statusCode: 418, + errors: { + root: new Error('loader failed'), + }, + }; + }, + }, + }, + } as any); + + const { createRequestHandler } = await import( + '../../../src/core/server/requestHandler' + ); + const requestHandler = await createRequestHandler(async () => { + return new Response('ok', { status: 200 }); + }); + + const response = await requestHandler(new Request('http://localhost/'), { + resource: { + entryName: 'main', + route: { + urlPath: '/', + }, + htmlTemplate: '', + } as any, + config: { + ssr: true, + } as any, + params: {}, + reporter: undefined, + monitors: undefined, + locals: {}, + loaderContext: {}, + onTiming: () => {}, + onError, + } as any); + + expect(response.status).toBe(418); + expect(onErrorCalls).toHaveLength(1); + expect(onErrorCalls[0]?.[1]).toBe(SSRErrors.LOADER_ERROR); + }); + + it('should run generic router cleanup after handling the request', async () => { + let cleaned = false; + + setGlobalContext({ + entryName: 'main', + App: () => React.createElement('div', null, 'app'), + enableRsc: false, + }); + setGlobalInternalRuntimeContext({ + hooks: { + wrapRoot: { + call: (App: React.ComponentType) => App, + }, + onBeforeRender: { + call: async (context: any) => { + context.routerRuntime = { + framework: 'custom-router', + cleanup: () => { + cleaned = true; + }, + }; + }, + }, + }, + } as any); + + const { createRequestHandler } = await import( + '../../../src/core/server/requestHandler' + ); + const requestHandler = await createRequestHandler(async () => { + return new Response('ok', { status: 200 }); + }); + + const response = await requestHandler(new Request('http://localhost/'), { + resource: { + entryName: 'main', + route: { + urlPath: '/', + }, + htmlTemplate: '', + } as any, + config: { + ssr: true, + } as any, + params: {}, + reporter: undefined, + monitors: undefined, + locals: {}, + loaderContext: {}, + onTiming: () => {}, + onError: () => {}, + } as any); + + expect(response.status).toBe(200); + expect(cleaned).toBe(true); + }); +}); diff --git a/packages/runtime/plugin-tanstack/package.json b/packages/runtime/plugin-tanstack/package.json index 056a5887f5f7..a803a129287f 100644 --- a/packages/runtime/plugin-tanstack/package.json +++ b/packages/runtime/plugin-tanstack/package.json @@ -16,7 +16,7 @@ "modern.js", "tanstack-router" ], - "version": "3.0.5", + "version": "3.2.0", "engines": { "node": ">=20" }, @@ -73,10 +73,10 @@ "@modern-js/types": "workspace:*", "@modern-js/utils": "workspace:*", "@swc/helpers": "^0.5.17", - "@tanstack/react-router": "1.161.4" + "@tanstack/react-router": "1.168.26" }, "peerDependencies": { - "@modern-js/runtime": "workspace:^3.0.5", + "@modern-js/runtime": "workspace:^3.2.0", "react": ">=17.0.2", "react-dom": ">=17.0.2" }, @@ -84,16 +84,16 @@ "@modern-js/app-tools": "workspace:*", "@modern-js/rslib": "workspace:*", "@modern-js/runtime": "workspace:*", + "@rslib/core": "0.21.4", "@scripts/rstest-config": "workspace:*", - "@rslib/core": "0.19.6", + "@tanstack/history": "1.161.6", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", - "@tanstack/history": "1.161.4", "@types/node": "^20", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "react": "^19.2.4", - "react-dom": "^19.2.4", + "react": "^19.2.6", + "react-dom": "^19.2.6", "typescript": "^5" }, "sideEffects": false, diff --git a/packages/runtime/plugin-tanstack/rstest.config.ts b/packages/runtime/plugin-tanstack/rstest.config.mts similarity index 82% rename from packages/runtime/plugin-tanstack/rstest.config.ts rename to packages/runtime/plugin-tanstack/rstest.config.mts index b31dda9ca697..78962056ea77 100644 --- a/packages/runtime/plugin-tanstack/rstest.config.ts +++ b/packages/runtime/plugin-tanstack/rstest.config.mts @@ -22,7 +22,11 @@ export default { withTestPreset({ name: 'plugin-tanstack-node', testEnvironment: 'node', - include: ['tests/router/routeTree.test.ts'], + include: [ + 'tests/router/cli.test.ts', + 'tests/router/tanstackTypes.test.ts', + 'tests/router/routeTree.test.ts', + ], extends: commonConfig, }), withTestPreset({ diff --git a/packages/runtime/plugin-tanstack/src/cli/index.ts b/packages/runtime/plugin-tanstack/src/cli/index.ts index aa682855d577..785211d92fee 100644 --- a/packages/runtime/plugin-tanstack/src/cli/index.ts +++ b/packages/runtime/plugin-tanstack/src/cli/index.ts @@ -1,255 +1,388 @@ import path from 'node:path'; -import type { AppTools, CliPlugin } from '@modern-js/app-tools'; +import type { + AppNormalizedConfig, + AppTools, + AppToolsContext, + CliPlugin, +} from '@modern-js/app-tools'; +import type { CLIPluginAPI } from '@modern-js/plugin'; import type { Entrypoint, NestedRouteForCli, PageRoute, ServerRoute, } from '@modern-js/types'; -import type { RouterConfig } from '@modern-js/runtime'; import { - fs, filterRoutesForServer, - findExists, + fs, NESTED_ROUTE_SPEC_FILE, } from '@modern-js/utils'; import { - getEntrypointRoutesDir, - handleFileChange, - handleGeneratorEntryCode, - handleModifyEntrypoints, - isRouteEntry, -} from '@modern-js/runtime/cli'; -import { - writeTanstackRegisterFile, - writeTanstackRouterTypesForEntry, + generateTanstackRouterTypesSourceForEntry, + isTanstackRouterFrameworkEnabled, } from './tanstackTypes'; -const DEFAULT_TANSTACK_ROUTES_DIR = 'views'; +export { + generateTanstackRouterTypesSourceForEntry, + isTanstackRouterFrameworkEnabled, +} from './tanstackTypes'; + +const DEFAULT_ROUTES_DIR = 'routes'; const DEFAULT_GENERATED_DIR_NAME = 'modern-tanstack'; -const TANSTACK_ENTRYPOINTS_KEY = '__tanstack_router_entries__'; -const TANSTACK_RUNTIME_MODULE = '@modern-js/plugin-tanstack/runtime'; -const JS_OR_TS_EXTS = [ - '.js', - '.jsx', - '.ts', - '.tsx', - '.mjs', - '.mts', - '.cjs', - '.cts', -] as const; - -function hasTanstackRouterConfigInRuntimeFile(runtimeConfigBase: string) { - const runtimeConfigFile = findExists( - JS_OR_TS_EXTS.map(ext => `${runtimeConfigBase}${ext}`), - ); +const ENTRYPOINTS_KEY = '@modern-js/plugin-tanstack'; - if (!runtimeConfigFile) { - return false; +export type TanstackRouterPluginOptions = { + routesDir?: string; + generatedDirName?: string; +}; + +type RuntimeRouterCliHelpers = { + getEntrypointRoutesDir: (entrypoint: Entrypoint) => string | null; + handleFileChange: ( + api: CLIPluginAPI, + event: unknown, + options?: { + includeEntry?: (entrypoint: Entrypoint) => boolean; + regenerate?: (params: { + api: CLIPluginAPI; + appContext: ReturnType['getAppContext']>; + resolvedConfig: AppNormalizedConfig; + entrypoints: Entrypoint[]; + }) => Promise; + entrypointsKey?: string; + }, + ) => Promise; + handleGeneratorEntryCode: ( + api: CLIPluginAPI, + entrypoints: Entrypoint[], + options?: { + entrypointsKey?: string; + generateCodeOptions?: { + enableTanstackTypes?: boolean; + }; + }, + ) => Promise>; + handleModifyEntrypoints: ( + entrypoints: Entrypoint[], + routesDir?: string, + options?: { + routesOwner?: string; + }, + ) => Promise; + isRouteEntry: (dir: string, routesDir?: string) => string | false; +}; + +let runtimeRouterCli: RuntimeRouterCliHelpers | undefined; + +function getRuntimeRouterCli(): RuntimeRouterCliHelpers { + if (runtimeRouterCli) { + return runtimeRouterCli; + } + + const cli = + require('@modern-js/runtime/cli') as Partial; + if (cli.handleGeneratorEntryCode && cli.getEntrypointRoutesDir) { + runtimeRouterCli = cli as RuntimeRouterCliHelpers; + return runtimeRouterCli; } + throw new Error( + '@modern-js/plugin-tanstack requires @modern-js/runtime/cli router helper exports from tpcore-02.', + ); +} + +async function writeFileIfChanged(filePath: string, content: string) { try { - const content = fs.readFileSync(runtimeConfigFile, 'utf-8'); - return /tanstackRouter\s*:/.test(content); + const previous = (await fs.pathExists(filePath)) + ? await fs.readFile(filePath, 'utf-8') + : null; + if (previous === content) { + return; + } } catch { - return false; + // Fall through and rewrite the generated file. } + + await fs.outputFile(filePath, content, 'utf-8'); } -type TanstackRouteEntrypointLike = { - entry?: string; - nestedRoutesEntry?: string; -}; +function createRegisterDtsContent(opts: { + entries: string[]; + runtimeModule: string; +}) { + const importStatements = opts.entries + .map( + (entryName, index) => + `import type { router as router${index} } from './${entryName}/router.gen';`, + ) + .join('\n'); + const routerUnionType = opts.entries + .map((_, index) => `typeof router${index}`) + .join(' | '); -function isTanstackRouteEntrypoint( - entrypoint: TanstackRouteEntrypointLike, - routesDir: string, -) { - const entrypointRoutesDir = getEntrypointRoutesDir(entrypoint); - if (entrypointRoutesDir) { - return entrypointRoutesDir === routesDir; + return `// This file is auto-generated by Modern.js. Do not edit manually. + +${importStatements} + +declare module '${opts.runtimeModule}' { + interface Register { + router: ${routerUnionType}; } +} +`; +} + +export async function writeTanstackRegisterFile(opts: { + entries: string[]; + generatedDirName?: string; + runtimeModule?: string; + srcDirectory: string; +}) { + const { + entries, + generatedDirName = DEFAULT_GENERATED_DIR_NAME, + runtimeModule = '@modern-js/plugin-tanstack/runtime', + srcDirectory, + } = opts; - if (entrypoint.nestedRoutesEntry) { - return path.basename(entrypoint.nestedRoutesEntry) === routesDir; + if (entries.length === 0) { + return; } - return Boolean(entrypoint.entry && isRouteEntry(entrypoint.entry, routesDir)); + const registerDtsPath = path.join( + srcDirectory, + generatedDirName, + 'register.gen.d.ts', + ); + + await writeFileIfChanged( + registerDtsPath, + createRegisterDtsContent({ entries, runtimeModule }), + ); } -export interface TanstackRouterPluginOptions extends Partial { - routesDir?: string; +export async function writeTanstackRouterTypesForEntries(opts: { + appContext: AppToolsContext; generatedDirName?: string; + routesByEntry: Record; +}) { + const { + appContext, + generatedDirName = DEFAULT_GENERATED_DIR_NAME, + routesByEntry, + } = opts; + + const entryNames = Object.keys(routesByEntry); + + await Promise.all( + entryNames.map(async entryName => { + const { routerGenTs } = await generateTanstackRouterTypesSourceForEntry({ + appContext, + entryName, + generatedDirName, + routes: routesByEntry[entryName], + }); + + await writeFileIfChanged( + path.join( + appContext.srcDirectory, + generatedDirName, + entryName, + 'router.gen.ts', + ), + routerGenTs, + ); + }), + ); + + const mainEntryName = appContext.entrypoints?.find( + entrypoint => entrypoint.isMainEntry, + )?.entryName; + const registerEntries = entryNames.sort((a, b) => { + if (mainEntryName && a === mainEntryName) { + return -1; + } + + if (mainEntryName && b === mainEntryName) { + return 1; + } + + return a.localeCompare(b); + }); + + await writeTanstackRegisterFile({ + entries: registerEntries, + generatedDirName, + srcDirectory: appContext.srcDirectory, + }); } -export const tanstackRouterPlugin = ( +export function tanstackRouterPlugin( options: TanstackRouterPluginOptions = {}, -): CliPlugin => ({ - name: '@modern-js/plugin-tanstack', - required: ['@modern-js/runtime'], - setup: api => { - const nestedRoutesForServer: Record = {}; - const { metaName } = api.getAppContext(); - const routesDir = options.routesDir || DEFAULT_TANSTACK_ROUTES_DIR; - const generatedDirName = - options.generatedDirName || DEFAULT_GENERATED_DIR_NAME; - - api._internalRuntimePlugins(({ entrypoint, plugins }) => { - const { serverRoutes, srcDirectory, runtimeConfigFile } = api.getAppContext(); - const hasRuntimeTanstackConfig = hasTanstackRouterConfigInRuntimeFile( - path.join(srcDirectory, runtimeConfigFile), - ); - const { routesDir: _routesDir, generatedDirName: _generatedDirName, ...runtimeConfig } = - options; - const hasInlineRuntimeConfig = Object.keys(runtimeConfig).length > 0; - const serverBase = serverRoutes - .filter((route: ServerRoute) => route.entryName === entrypoint.entryName) - .map(route => route.urlPath) - .sort((left, right) => (left.length - right.length > 0 ? -1 : 1)); - - if ( - isTanstackRouteEntrypoint(entrypoint, routesDir) || - hasRuntimeTanstackConfig || - hasInlineRuntimeConfig - ) { +): CliPlugin { + const routesDir = options.routesDir || DEFAULT_ROUTES_DIR; + const generatedDirName = + options.generatedDirName || DEFAULT_GENERATED_DIR_NAME; + + return { + name: '@modern-js/plugin-tanstack', + required: ['@modern-js/runtime'], + setup: api => { + const nestedRoutesForServer: Record = {}; + + const isTanstackEntrypoint = (entrypoint: Entrypoint) => { + const { getEntrypointRoutesDir } = getRuntimeRouterCli(); + return getEntrypointRoutesDir(entrypoint) === routesDir; + }; + + api._internalRuntimePlugins(({ entrypoint, plugins }) => { + if (!isTanstackEntrypoint(entrypoint as Entrypoint)) { + return { entrypoint, plugins }; + } + + const { metaName, serverRoutes } = api.getAppContext(); + const serverBase = serverRoutes + .filter( + (route: ServerRoute) => route.entryName === entrypoint.entryName, + ) + .map(route => route.urlPath) + .sort((a, b) => (a.length - b.length > 0 ? -1 : 1)); + plugins.push({ name: 'tanstackRouter', path: `@${metaName}/plugin-tanstack/runtime`, - config: { - serverBase, - ...runtimeConfig, - }, + config: { serverBase }, }); - } - return { entrypoint, plugins }; - }); + return { entrypoint, plugins }; + }); - api.checkEntryPoint(({ path: entryPath, entry }) => { - return { - path: entryPath, - entry: entry || isRouteEntry(entryPath, routesDir), - }; - }); - - api.config(() => { - return { - source: { - include: [ - /[\\/]node_modules[\\/]@tanstack[\\/]react-router[\\/]/, - /[\\/]node_modules[\\/]@tanstack[\\/]history[\\/]/, - path.resolve(__dirname, '../runtime').replace('cjs', 'esm'), - ], - }, - }; - }); + api.checkEntryPoint(({ path: entryPath, entry }) => { + const { isRouteEntry } = getRuntimeRouterCli(); + return { + path: entryPath, + entry: entry || isRouteEntry(entryPath, routesDir), + }; + }); - api.modifyEntrypoints(async ({ entrypoints }) => { - return { - entrypoints: await handleModifyEntrypoints(entrypoints, routesDir), - }; - }); - - api.generateEntryCode(async ({ entrypoints }) => { - await generateTanstackEntryCode(api, entrypoints, generatedDirName); - }); - - api.onFileChanged(async event => { - await handleFileChange(api, event, { - includeEntry: entrypoint => isTanstackRouteEntrypoint(entrypoint, routesDir), - regenerate: async ({ api, entrypoints }) => { - await generateTanstackEntryCode(api, entrypoints, generatedDirName); - }, - entrypointsKey: TANSTACK_ENTRYPOINTS_KEY, + api.config(() => { + return { + source: { + include: [ + /[\\/]node_modules[\\/]@tanstack[\\/]react-router[\\/]/, + path.resolve(__dirname, '../runtime').replace('cjs', 'esm'), + ], + }, + }; }); - }); - api.modifyFileSystemRoutes(({ entrypoint, routes }) => { - if (isTanstackRouteEntrypoint(entrypoint, routesDir)) { - nestedRoutesForServer[entrypoint.entryName] = filterRoutesForServer( - routes as (NestedRouteForCli | PageRoute)[], - ); - } + api.modifyEntrypoints(async ({ entrypoints }) => { + const { handleModifyEntrypoints } = getRuntimeRouterCli(); + return { + entrypoints: await handleModifyEntrypoints(entrypoints, routesDir, { + routesOwner: ENTRYPOINTS_KEY, + }), + }; + }); - return { - entrypoint, - routes, - }; - }); + api.generateEntryCode(async ({ entrypoints }) => { + const tanstackEntrypoints = entrypoints.filter(isTanstackEntrypoint); - api.onBeforeGenerateRoutes(async ({ entrypoint, code }) => { - if (isTanstackRouteEntrypoint(entrypoint, routesDir)) { - const { distDirectory } = api.getAppContext(); + if (tanstackEntrypoints.length === 0) { + return; + } - const nestedRoutesSpecPath = path.resolve( - distDirectory, - NESTED_ROUTE_SPEC_FILE, + const { handleGeneratorEntryCode } = getRuntimeRouterCli(); + const routesByEntry = await handleGeneratorEntryCode( + api, + tanstackEntrypoints, + { + entrypointsKey: ENTRYPOINTS_KEY, + generateCodeOptions: { + enableTanstackTypes: false, + }, + }, ); - const existingNestedRoutes = (await fs.pathExists(nestedRoutesSpecPath)) - ? ((await fs.readJSON(nestedRoutesSpecPath)) as Record) - : {}; - await fs.outputJSON(nestedRoutesSpecPath, { - ...existingNestedRoutes, - ...nestedRoutesForServer, + await writeTanstackRouterTypesForEntries({ + appContext: api.getAppContext(), + generatedDirName, + routesByEntry, }); - } + }); - return { - entrypoint, - code, - }; - }); - }, -}); - -async function generateTanstackEntryCode( - api: Parameters['setup']>>[0], - entrypoints: Entrypoint[], - generatedDirName: string, -) { - const appContext = api.getAppContext(); - const routesByEntry = await handleGeneratorEntryCode( - api, - entrypoints, - TANSTACK_ENTRYPOINTS_KEY, - ); + api.onFileChanged(async event => { + const { handleFileChange } = getRuntimeRouterCli(); + await handleFileChange(api, event, { + entrypointsKey: ENTRYPOINTS_KEY, + includeEntry: entrypoint => { + const { getEntrypointRoutesDir } = getRuntimeRouterCli(); + return getEntrypointRoutesDir(entrypoint) === routesDir; + }, + regenerate: async ({ api, entrypoints }) => { + const { handleGeneratorEntryCode } = getRuntimeRouterCli(); + const routesByEntry = await handleGeneratorEntryCode( + api, + entrypoints, + { + entrypointsKey: ENTRYPOINTS_KEY, + generateCodeOptions: { + enableTanstackTypes: false, + }, + }, + ); - await writeTanstackRegisterFile({ - appContext, - entrypoints, - generatedDirName, - runtimeModule: TANSTACK_RUNTIME_MODULE, - }); + await writeTanstackRouterTypesForEntries({ + appContext: api.getAppContext(), + generatedDirName, + routesByEntry, + }); + }, + }); + }); - await Promise.all( - entrypoints.map(async entrypoint => { - const entryName = entrypoint.entryName; - const routes = routesByEntry[entryName]; - const outPath = path.join( - appContext.srcDirectory, - generatedDirName, - entryName, - 'router.gen.ts', - ); + api.modifyFileSystemRoutes(async ({ entrypoint, routes }) => { + if (isTanstackEntrypoint(entrypoint)) { + nestedRoutesForServer[entrypoint.entryName] = filterRoutesForServer( + routes as (NestedRouteForCli | PageRoute)[], + ); + } - if (routes?.length) { - await writeTanstackRouterTypesForEntry({ - appContext, - entryName, + return { + entrypoint, routes, - generatedDirName, - runtimeModule: TANSTACK_RUNTIME_MODULE, - }); - return; - } + }; + }); - if (await fs.pathExists(outPath)) { - await fs.remove(outPath); - } - }), - ); + api.onBeforeGenerateRoutes(async ({ entrypoint, code }) => { + if (isTanstackEntrypoint(entrypoint)) { + const { distDirectory } = api.getAppContext(); + const nestedRoutesSpecPath = path.resolve( + distDirectory, + NESTED_ROUTE_SPEC_FILE, + ); + const existingNestedRoutes = (await fs.pathExists( + nestedRoutesSpecPath, + )) + ? ((await fs.readJSON(nestedRoutesSpecPath)) as Record< + string, + unknown + >) + : {}; + + await fs.outputJSON(nestedRoutesSpecPath, { + ...existingNestedRoutes, + ...nestedRoutesForServer, + }); + } + + return { + entrypoint, + code, + }; + }); + }, + }; } export default tanstackRouterPlugin; diff --git a/packages/runtime/plugin-tanstack/src/cli/tanstackTypes.ts b/packages/runtime/plugin-tanstack/src/cli/tanstackTypes.ts index 462cc8a42c64..1a36eb630fd6 100644 --- a/packages/runtime/plugin-tanstack/src/cli/tanstackTypes.ts +++ b/packages/runtime/plugin-tanstack/src/cli/tanstackTypes.ts @@ -1,7 +1,29 @@ -import path from 'path'; import type { AppToolsContext } from '@modern-js/app-tools'; -import type { Entrypoint, NestedRouteForCli, PageRoute } from '@modern-js/types'; +import type { NestedRouteForCli, PageRoute } from '@modern-js/types'; import { findExists, formatImportPath, fs, slash } from '@modern-js/utils'; +import path from 'path'; + +const reservedWords = + 'break case class catch const continue debugger default delete do else export extends finally for function if import in instanceof let new return super switch this throw try typeof var void while with yield enum await implements package protected static interface private public'; +const builtins = + 'arguments Infinity NaN undefined null true false eval uneval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Symbol Error EvalError InternalError RangeError ReferenceError SyntaxError TypeError URIError Number Math Date String RegExp Array Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array Float32Array Float64Array Map Set WeakMap WeakSet SIMD ArrayBuffer DataView JSON Promise Generator GeneratorFunction Reflect Proxy Intl'; +const forbidList = new Set(`${reservedWords} ${builtins}`.split(' ')); + +function makeLegalIdentifier(str: string) { + const identifier = str + .replace(/-(\w)/g, (_, letter) => letter.toUpperCase()) + .replace(/[^$_a-zA-Z0-9]/g, '_'); + + if (/\d/.test(identifier[0]) || forbidList.has(identifier)) { + return `_${identifier}`; + } + return identifier || '_'; +} + +function getPathWithoutExt(filename: string) { + const extname = path.extname(filename); + return extname ? filename.slice(0, -extname.length) : filename; +} const JS_OR_TS_EXTS = [ '.js', @@ -14,6 +36,28 @@ const JS_OR_TS_EXTS = [ '.cts', ] as const; +function toTanstackPath(pathname: string): string { + return pathname + .split('/') + .map(segment => { + if (!segment) { + return segment; + } + if (segment === '*') { + return '$'; + } + if (segment.startsWith(':')) { + const name = segment.slice(1); + if (name.endsWith('?')) { + return `{-$${name.slice(0, -1)}}`; + } + return `$${name}`; + } + return segment; + }) + .join('/'); +} + async function resolveFileNoExt(inputNoExtPath: string) { const file = findExists(JS_OR_TS_EXTS.map(ext => `${inputNoExtPath}${ext}`)); return file ? getPathWithoutExt(file) : null; @@ -31,28 +75,6 @@ function normalizeRelativeImport(p: string) { return `./${normalized}`; } -function getPathWithoutExt(filename: string) { - const extname = path.extname(filename); - return extname ? filename.slice(0, -extname.length) : filename; -} - -const reservedWords = - 'break case class catch const continue debugger default delete do else export extends finally for function if import in instanceof let new return super switch this throw try typeof var void while with yield enum await implements package protected static interface private public'; -const builtins = - 'arguments Infinity NaN undefined null true false eval uneval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Symbol Error EvalError InternalError RangeError ReferenceError SyntaxError TypeError URIError Number Math Date String RegExp Array Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array Uint32Array Float32Array Float64Array Map Set WeakMap WeakSet SIMD ArrayBuffer DataView JSON Promise Generator GeneratorFunction Reflect Proxy Intl'; -const forbidList = new Set(`${reservedWords} ${builtins}`.split(' ')); - -function makeLegalIdentifier(str: string) { - const identifier = str - .replace(/-(\w)/g, (_, letter) => letter.toUpperCase()) - .replace(/[^$_a-zA-Z0-9]/g, '_'); - - if (/\d/.test(identifier[0]) || forbidList.has(identifier)) { - return `_${identifier}`; - } - return identifier || '_'; -} - function pickModernLoaderModule(route: NestedRouteForCli | PageRoute) { const loaderPath = (route as any).data || (route as any).loader; if (!loaderPath || typeof loaderPath !== 'string') { @@ -75,142 +97,84 @@ function isIndexRoute(route: NestedRouteForCli | PageRoute) { return (route as any).type === 'nested' && Boolean((route as any).index); } -function toTanstackPath(pathname: string): string { - return pathname - .split('/') - .map(segment => { - if (!segment) { - return segment; - } - if (segment === '*') { - return '$'; - } - if (segment.startsWith(':')) { - const name = segment.slice(1); - if (name.endsWith('?')) { - return `{-$${name.slice(0, -1)}}`; - } - return `$${name}`; - } - return segment; - }) - .join('/'); -} +function createRouteStaticDataSnippet(opts: { + modernRouteId?: string; + loaderName?: string | null; + actionName?: string | null; +}) { + const staticDataLines: string[] = []; -async function writeFileIfChanged(filePath: string, content: string) { - try { - const prev = (await fs.pathExists(filePath)) - ? await fs.readFile(filePath, 'utf-8') - : null; - if (prev !== content) { - await fs.outputFile(filePath, content, 'utf-8'); - } - } catch { - await fs.outputFile(filePath, content, 'utf-8'); + if (opts.modernRouteId) { + staticDataLines.push(`modernRouteId: ${quote(opts.modernRouteId)},`); } -} -export async function writeTanstackRegisterFile(opts: { - appContext: AppToolsContext; - entrypoints: Entrypoint[]; - generatedDirName: string; - runtimeModule: string; -}) { - const { appContext, entrypoints, generatedDirName, runtimeModule } = opts; - const allEntries = Array.from( - new Set(entrypoints.map(entrypoint => entrypoint.entryName).filter(Boolean)), - ); - const mainEntry = entrypoints.find(entrypoint => entrypoint.isMainEntry)?.entryName; + if (opts.loaderName) { + staticDataLines.push(`modernRouteLoader: ${opts.loaderName},`); + } - const registerEntries = allEntries.sort((left, right) => { - if (mainEntry && left === mainEntry) { - return -1; - } + if (opts.actionName) { + staticDataLines.push(`modernRouteAction: ${opts.actionName},`); + } - if (mainEntry && right === mainEntry) { - return 1; - } + if (!staticDataLines.length) { + return null; + } - return left.localeCompare(right); - }); + return `staticData: createRouteStaticData({\n ${staticDataLines.join( + '\n ', + )}\n }),`; +} - const registerDtsPath = path.join( +export async function isTanstackRouterFrameworkEnabled( + appContext: AppToolsContext, +): Promise { + const runtimeConfigBase = path.join( appContext.srcDirectory, - generatedDirName, - 'register.gen.d.ts', + appContext.runtimeConfigFile, ); - - if (registerEntries.length === 0) { - if (await fs.pathExists(registerDtsPath)) { - await fs.remove(registerDtsPath); - } - return; + const runtimeConfigFile = findExists( + JS_OR_TS_EXTS.map(ext => `${runtimeConfigBase}${ext}`), + ); + if (!runtimeConfigFile) { + return false; } - const importStatements = registerEntries - .map( - (entryName, index) => - `import type { router as router${index} } from './${entryName}/router.gen';`, - ) - .join('\n'); - const routerUnionType = registerEntries - .map((_, index) => `typeof router${index}`) - .join(' | '); - const registerContent = `// This file is auto-generated by Modern.js. Do not edit manually. - -${importStatements} - -declare module '${runtimeModule}' { - interface Register { - router: ${routerUnionType}; + try { + const content = await fs.readFile(runtimeConfigFile, 'utf-8'); + // Heuristic: allow both single and double quotes, tolerate whitespace/newlines. + return /framework\s*:\s*['"]tanstack['"]/.test(content); + } catch { + return false; } } -`; - - await writeFileIfChanged(registerDtsPath, registerContent); -} -export async function writeTanstackRouterTypesForEntry(opts: { +export async function generateTanstackRouterTypesSourceForEntry(opts: { appContext: AppToolsContext; entryName: string; + generatedDirName?: string; routes: (NestedRouteForCli | PageRoute)[]; - generatedDirName: string; - runtimeModule: string; -}) { - const { appContext, entryName, routes, generatedDirName, runtimeModule } = opts; - const { routerGenTs } = await generateTanstackRouterTypesSourceForEntry({ +}): Promise<{ + routerGenTs: string; +}> { + const { appContext, entryName, + generatedDirName = 'modern-tanstack', routes, - runtimeModule, - generatedDirName, - }); - - const outPath = path.join( + } = opts; + const outDir = path.join( appContext.srcDirectory, generatedDirName, entryName, - 'router.gen.ts', ); - await writeFileIfChanged(outPath, routerGenTs); -} - -async function generateTanstackRouterTypesSourceForEntry(opts: { - appContext: AppToolsContext; - entryName: string; - routes: (NestedRouteForCli | PageRoute)[]; - runtimeModule: string; - generatedDirName: string; -}): Promise<{ routerGenTs: string }> { - const { appContext, entryName, routes, runtimeModule, generatedDirName } = opts; - const outDir = path.join(appContext.srcDirectory, generatedDirName, entryName); const rootModern = routes.find( - route => route && (route as any).type === 'nested' && (route as any).isRoot, + r => r && (r as any).type === 'nested' && (r as any).isRoot, ) as NestedRouteForCli | undefined; const topLevel = rootModern - ? ((rootModern as any).children as Array) || [] + ? ((rootModern as any).children as Array) || + [] : routes; const imports: string[] = []; @@ -220,24 +184,31 @@ async function generateTanstackRouterTypesSourceForEntry(opts: { let loaderIndex = 0; let routeIndex = 0; - const getImportNameForLoader = async ( + const getImportNamesForLoader = async ( aliasedNoExtPath: string, inline: boolean, + hasAction: boolean, ) => { - const key = `${inline ? 'inline' : 'default'}:${aliasedNoExtPath}`; + const key = `${ + inline ? 'inline' : 'default' + }:${hasAction ? 'action' : 'loader'}:${aliasedNoExtPath}`; const existing = loaderImportMap.get(key); if (existing) { - return existing; + return { + loaderName: existing, + actionName: hasAction ? existing.replace(/^loader_/, 'action_') : null, + }; } const prefix = `${appContext.internalSrcAlias}/`; - let absNoExt: string | null = null; + let absNoExt: string; if (aliasedNoExtPath.startsWith(prefix)) { const rel = aliasedNoExtPath.slice(prefix.length); absNoExt = path.join(appContext.srcDirectory, rel); } else if (path.isAbsolute(aliasedNoExtPath)) { absNoExt = aliasedNoExtPath; } else { + // Unknown format; treat as already relative to src. absNoExt = path.join(appContext.srcDirectory, aliasedNoExtPath); } @@ -246,17 +217,28 @@ async function generateTanstackRouterTypesSourceForEntry(opts: { return null; } - const relImport = normalizeRelativeImport(path.relative(outDir, resolvedNoExt)); - const importName = `loader_${loaderIndex++}`; + const relImport = normalizeRelativeImport( + path.relative(outDir, resolvedNoExt), + ); + const importName = `loader_${loaderIndex++}`; + const actionName = hasAction + ? importName.replace(/^loader_/, 'action_') + : null; if (inline) { - imports.push(`import { loader as ${importName} } from ${quote(relImport)};`); + const specifiers = [`loader as ${importName}`]; + if (actionName) { + specifiers.push(`action as ${actionName}`); + } + imports.push( + `import { ${specifiers.join(', ')} } from ${quote(relImport)};`, + ); } else { imports.push(`import ${importName} from ${quote(relImport)};`); } loaderImportMap.set(key, importName); - return importName; + return { loaderName: importName, actionName }; }; const createRouteVarName = (route: NestedRouteForCli | PageRoute) => { @@ -270,32 +252,51 @@ async function generateTanstackRouterTypesSourceForEntry(opts: { route: NestedRouteForCli | PageRoute; }): Promise => { const { parentVar, route } = opts; + const varName = createRouteVarName(route); + const loaderInfo = pickModernLoaderModule(route); - const loaderName = loaderInfo - ? await getImportNameForLoader(loaderInfo.loaderPath, loaderInfo.inline) + const routeAction = (route as any).action; + const loaderImports = loaderInfo + ? await getImportNamesForLoader( + loaderInfo.loaderPath, + loaderInfo.inline, + Boolean(loaderInfo.inline && routeAction === loaderInfo.loaderPath), + ) : null; + const loaderName = loaderImports?.loaderName || null; + const actionName = loaderImports?.actionName || null; + const rawPath = (route as any).path as string | undefined; const hasSplat = typeof rawPath === 'string' && rawPath.includes('*'); - const routeOptions: string[] = [`getParentRoute: () => ${parentVar},`]; + const routeOpts: string[] = [`getParentRoute: () => ${parentVar},`]; if (isPathlessLayout(route)) { const id = (route as any).id as string | undefined; - routeOptions.push(`id: ${quote(id || 'pathless')},`); + routeOpts.push(`id: ${quote(id || 'pathless')},`); } else { - const pathname = isIndexRoute(route) ? '/' : toTanstackPath(rawPath || ''); - routeOptions.push(`path: ${quote(pathname)},`); + const p = isIndexRoute(route) ? '/' : toTanstackPath(rawPath || ''); + routeOpts.push(`path: ${quote(p)},`); } if (loaderName) { - routeOptions.push( + routeOpts.push( `loader: modernLoaderToTanstack({ hasSplat: ${hasSplat} }, ${loaderName}),`, ); } + const staticDataSnippet = createRouteStaticDataSnippet({ + modernRouteId: (route as any).id as string | undefined, + loaderName, + actionName, + }); + if (staticDataSnippet) { + routeOpts.push(staticDataSnippet); + } + statements.push( - `const ${varName} = createRoute({\n ${routeOptions.join('\n ')}\n});`, + `const ${varName} = createRoute({\n ${routeOpts.join('\n ')}\n});`, ); const children = (route as any).children as @@ -312,20 +313,26 @@ async function generateTanstackRouterTypesSourceForEntry(opts: { }; const rootLoaderInfo = rootModern ? pickModernLoaderModule(rootModern) : null; - const rootLoaderName = rootLoaderInfo?.loaderPath - ? await getImportNameForLoader( + const rootAction = (rootModern as any)?.action; + const rootLoaderImports = rootLoaderInfo?.loaderPath + ? await getImportNamesForLoader( rootLoaderInfo.loaderPath, rootLoaderInfo.inline, + Boolean( + rootLoaderInfo.inline && rootAction === rootLoaderInfo.loaderPath, + ), ) : null; + const rootLoaderName = rootLoaderImports?.loaderName || null; + const rootActionName = rootLoaderImports?.actionName || null; const topLevelVars = await Promise.all( topLevel.map(route => buildRoute({ parentVar: 'rootRoute', route })), ); - const rootOptions: string[] = []; + const rootOpts: string[] = []; if (rootLoaderName) { - rootOptions.push( + rootOpts.push( `loader: modernLoaderToTanstack({ hasSplat: false }, ${rootLoaderName}),`, ); } @@ -340,7 +347,7 @@ import { createRouter, notFound, redirect, -} from '${runtimeModule}'; +} from '@modern-js/plugin-tanstack/runtime'; type ModernRouterContext = { request?: Request; @@ -383,6 +390,28 @@ function mapParamsForModernLoader(params: Record, hasSplat: bool return rest; } +function createRouteStaticData(opts: { + modernRouteId?: string; + modernRouteAction?: unknown; + modernRouteLoader?: unknown; +}) { + const staticData: Record = {}; + + if (opts.modernRouteId) { + staticData.modernRouteId = opts.modernRouteId; + } + + if (opts.modernRouteLoader) { + staticData.modernRouteLoader = opts.modernRouteLoader; + } + + if (opts.modernRouteAction) { + staticData.modernRouteAction = opts.modernRouteAction; + } + + return Object.keys(staticData).length > 0 ? staticData : undefined; +} + function modernLoaderToTanstack any>( opts: { hasSplat: boolean }, modernLoader: TLoader, @@ -447,7 +476,14 @@ function modernLoaderToTanstack any>( ${imports.join('\n')} export const rootRoute = createRootRouteWithContext()({ - ${rootOptions.join('\n ')} + ${rootOpts.join('\n ')} + ${ + createRouteStaticDataSnippet({ + modernRouteId: (rootModern as any)?.id as string | undefined, + loaderName: rootLoaderName, + actionName: rootActionName, + }) || '' + } }); ${statements.join('\n\n')} diff --git a/packages/runtime/plugin-tanstack/src/runtime/dataMutation.tsx b/packages/runtime/plugin-tanstack/src/runtime/dataMutation.tsx index 905c22e23834..ec0a94c846df 100644 --- a/packages/runtime/plugin-tanstack/src/runtime/dataMutation.tsx +++ b/packages/runtime/plugin-tanstack/src/runtime/dataMutation.tsx @@ -1,5 +1,5 @@ -import { useRouter } from '@tanstack/react-router'; import type { AnyRouter } from '@tanstack/react-router'; +import { useRouter } from '@tanstack/react-router'; import type React from 'react'; import { useCallback, useRef, useState } from 'react'; diff --git a/packages/runtime/plugin-tanstack/src/runtime/hooks.ts b/packages/runtime/plugin-tanstack/src/runtime/hooks.ts index 50b3406cc605..8e465800fe8b 100644 --- a/packages/runtime/plugin-tanstack/src/runtime/hooks.ts +++ b/packages/runtime/plugin-tanstack/src/runtime/hooks.ts @@ -1,14 +1,34 @@ import { createSyncHook } from '@modern-js/plugin'; -import type { RouteObject } from '@modern-js/runtime/router'; -import type { TRuntimeContext } from '@modern-js/runtime'; +import type { TRuntimeContext } from '@modern-js/runtime/context'; +import type { RouteObject } from '@modern-js/runtime-utils/router'; +import type { RouterLifecycleContext } from './lifecycle'; const modifyRoutes = createSyncHook<(routes: RouteObject[]) => RouteObject[]>(); const onBeforeCreateRoutes = createSyncHook<(context: TRuntimeContext) => void>(); +const onBeforeCreateRouter = + createSyncHook<(context: RouterLifecycleContext) => void>(); +const onAfterCreateRouter = + createSyncHook<(context: RouterLifecycleContext) => void>(); +const onBeforeHydrateRouter = + createSyncHook<(context: RouterLifecycleContext) => void>(); +const onAfterHydrateRouter = + createSyncHook<(context: RouterLifecycleContext) => void>(); -export { modifyRoutes, onBeforeCreateRoutes }; +export { + modifyRoutes, + onAfterCreateRouter, + onAfterHydrateRouter, + onBeforeCreateRouter, + onBeforeCreateRoutes, + onBeforeHydrateRouter, +}; -export type TanstackRouterExtendsHooks = { +export type RouterExtendsHooks = { modifyRoutes: typeof modifyRoutes; onBeforeCreateRoutes: typeof onBeforeCreateRoutes; + onBeforeCreateRouter: typeof onBeforeCreateRouter; + onAfterCreateRouter: typeof onAfterCreateRouter; + onBeforeHydrateRouter: typeof onBeforeHydrateRouter; + onAfterHydrateRouter: typeof onAfterHydrateRouter; }; diff --git a/packages/runtime/plugin-tanstack/src/runtime/index.tsx b/packages/runtime/plugin-tanstack/src/runtime/index.tsx index ef51e45294c7..c6ddca23a191 100644 --- a/packages/runtime/plugin-tanstack/src/runtime/index.tsx +++ b/packages/runtime/plugin-tanstack/src/runtime/index.tsx @@ -1,25 +1,24 @@ export * from '@tanstack/react-router'; export { useMatch } from '@tanstack/react-router'; -export { Link, NavLink } from './prefetchLink'; +export type { + Fetcher, + FetcherState, + FetcherSubmitOptions, + FormProps, + SubmitOptions, +} from './dataMutation'; export { Form, RouteActionResponseError, useFetcher, } from './dataMutation'; -export { tanstackRouterPlugin } from './plugin'; +export { + tanstackRouterPlugin, + tanstackRouterPlugin as default, +} from './plugin'; export type { LinkProps, NavLinkProps, PrefetchBehavior, } from './prefetchLink'; -export type { - Fetcher, - FetcherState, - FetcherSubmitOptions, - FormProps, - SubmitOptions, -} from './dataMutation'; -export type { - TanstackRouterExtendsHooks, -} from './hooks'; -export type { TanstackRouterRuntimeConfig } from './plugin'; +export { Link, NavLink } from './prefetchLink'; diff --git a/packages/runtime/plugin-tanstack/src/runtime/lifecycle.ts b/packages/runtime/plugin-tanstack/src/runtime/lifecycle.ts new file mode 100644 index 000000000000..7352c3bb4bd0 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/lifecycle.ts @@ -0,0 +1,150 @@ +import type { TInternalRuntimeContext } from '@modern-js/runtime/context'; +import type { RouteObject } from '@modern-js/runtime-utils/router'; +import type { + InternalRouterRuntimeState, + InternalRouterServerSnapshot, + RouterFramework, + RouterRouteMatchSnapshot, + RouterServerPrepareResult, +} from './types'; + +export type RouterLifecyclePhase = 'ssr-prepare' | 'client-create' | 'hydrate'; + +export type RouterLifecycleContext = { + framework: RouterFramework; + phase: RouterLifecyclePhase; + routes: RouteObject[]; + runtimeContext: TInternalRuntimeContext; + basename?: string; + hydrationData?: unknown; + router?: unknown; + matches?: RouterRouteMatchSnapshot[]; + cleanup?: () => void | Promise; + serverSnapshot?: InternalRouterServerSnapshot; +}; + +type RouterSnapshotLike = Partial; + +function toHydrationScripts(state: { + hydrationScript?: string; + hydrationScripts?: string[]; +}) { + if (state.hydrationScripts?.length) { + return state.hydrationScripts; + } + + return state.hydrationScript ? [state.hydrationScript] : undefined; +} + +function getMatchedRouteIdsFromMatches(matches?: RouterRouteMatchSnapshot[]) { + const routeIds = matches + ?.map(match => match.assetRouteId ?? match.routeId) + .filter((routeId): routeId is string => typeof routeId === 'string'); + + return routeIds?.length ? routeIds : undefined; +} + +export function createRouterServerSnapshot( + state: RouterSnapshotLike, +): InternalRouterServerSnapshot { + const hydrationScripts = toHydrationScripts(state); + const matchedRouteIds = + state.matchedRouteIds ?? getMatchedRouteIdsFromMatches(state.matches); + + return { + ...state, + ...(hydrationScripts?.length + ? { + hydrationScript: state.hydrationScript ?? hydrationScripts[0], + hydrationScripts, + } + : {}), + ...(matchedRouteIds ? { matchedRouteIds } : {}), + }; +} + +export function createRouterRuntimeState( + state: InternalRouterRuntimeState, +): InternalRouterRuntimeState { + const hasSnapshotState = + Boolean(state.serverSnapshot) || + Boolean(state.hydrationScript) || + Boolean(state.hydrationScripts?.length) || + Boolean(state.matchedRouteIds?.length) || + Boolean(state.matches?.length); + const serverSnapshot = state.serverSnapshot + ? createRouterServerSnapshot({ + ...state.serverSnapshot, + framework: state.serverSnapshot.framework ?? state.framework, + basename: state.serverSnapshot.basename ?? state.basename, + hydrationScript: + state.serverSnapshot.hydrationScript ?? state.hydrationScript, + hydrationScripts: + state.serverSnapshot.hydrationScripts ?? state.hydrationScripts, + matchedRouteIds: + state.serverSnapshot.matchedRouteIds ?? state.matchedRouteIds, + matches: state.serverSnapshot.matches ?? state.matches, + }) + : hasSnapshotState + ? createRouterServerSnapshot({ + framework: state.framework, + basename: state.basename, + hydrationScript: state.hydrationScript, + hydrationScripts: state.hydrationScripts, + matchedRouteIds: state.matchedRouteIds, + matches: state.matches, + }) + : undefined; + const hydrationScripts = toHydrationScripts({ + hydrationScript: state.hydrationScript ?? serverSnapshot?.hydrationScript, + hydrationScripts: + state.hydrationScripts ?? serverSnapshot?.hydrationScripts, + }); + const matchedRouteIds = + state.matchedRouteIds ?? + serverSnapshot?.matchedRouteIds ?? + getMatchedRouteIdsFromMatches(state.matches); + + return { + ...state, + ...(hydrationScripts?.length + ? { + hydrationScript: state.hydrationScript ?? hydrationScripts[0], + hydrationScripts, + } + : {}), + ...(matchedRouteIds ? { matchedRouteIds } : {}), + ...(serverSnapshot ? { serverSnapshot } : {}), + }; +} + +export function applyRouterRuntimeState( + runtimeContext: TInternalRuntimeContext, + state: InternalRouterRuntimeState, +) { + const normalized = createRouterRuntimeState(state); + const mutableRuntimeContext = runtimeContext as any; + mutableRuntimeContext.routerFramework = normalized.framework; + mutableRuntimeContext.routerInstance = normalized.instance; + mutableRuntimeContext.routerHydrationScript = normalized.hydrationScript; + mutableRuntimeContext.routerMatchedRouteIds = normalized.matchedRouteIds; + mutableRuntimeContext.routerRuntime = normalized; + if (normalized.serverSnapshot) { + mutableRuntimeContext.routerServerSnapshot = normalized.serverSnapshot; + } + + return runtimeContext; +} + +export function applyRouterServerPrepareResult( + runtimeContext: TInternalRuntimeContext, + result: RouterServerPrepareResult, +) { + const state = createRouterRuntimeState({ + ...result.state, + cleanup: result.cleanup ?? result.state.cleanup, + serverSnapshot: result.snapshot ?? result.state.serverSnapshot, + }); + applyRouterRuntimeState(runtimeContext, state); + return runtimeContext; +} diff --git a/packages/runtime/plugin-tanstack/src/runtime/plugin.node.tsx b/packages/runtime/plugin-tanstack/src/runtime/plugin.node.tsx new file mode 100644 index 000000000000..b00f11948fc2 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/plugin.node.tsx @@ -0,0 +1,331 @@ +/// + +import { + getGlobalLayoutApp, + getGlobalRoutes, + InternalRuntimeContext, + type TInternalRuntimeContext, +} from '@modern-js/runtime/context'; +import type { RuntimePlugin } from '@modern-js/runtime/plugin'; +import { merge } from '@modern-js/runtime-utils/merge'; +import { + createRequestContext, + type RequestContext, +} from '@modern-js/runtime-utils/node'; +import type { RouteObject } from '@modern-js/runtime-utils/router'; +import { time } from '@modern-js/runtime-utils/time'; +import { LOADER_REPORTER_NAME } from '@modern-js/utils/universal/constants'; +import { + type AnyRouter, + createMemoryHistory, + createRouter, + RouterProvider, +} from '@tanstack/react-router'; +import { attachRouterServerSsrUtils } from '@tanstack/react-router/ssr/server'; +import type React from 'react'; +import { Suspense, useContext } from 'react'; +import { createModernBasepathRewrite } from './basepathRewrite'; +import { + modifyRoutes as modifyRoutesHook, + onAfterCreateRouter as onAfterCreateRouterHook, + onAfterHydrateRouter as onAfterHydrateRouterHook, + onBeforeCreateRouter as onBeforeCreateRouterHook, + onBeforeCreateRoutes as onBeforeCreateRoutesHook, + onBeforeHydrateRouter as onBeforeHydrateRouterHook, + type RouterExtendsHooks, +} from './hooks'; +import { + applyRouterServerPrepareResult, + createRouterServerSnapshot, + type RouterLifecycleContext, +} from './lifecycle'; +import { + createRouteTreeFromRouteObjects, + getModernRouteIdsFromMatches, +} from './routeTree'; +import type { InternalRouterServerSnapshot, RouterConfig } from './types'; +import { createRouteObjectsFromConfig, urlJoin } from './utils'; + +type ModernTanstackRouterContext = { + request: Request; + requestContext: RequestContext>; +}; + +function htmlEscapeAttr(value: string) { + return value.replace(/&/g, '&').replace(/"/g, '"'); +} + +function routerManagedTagToHtml(tag: any): string { + if (!tag || tag.tag !== 'script') { + return ''; + } + + const attrs: Record = tag.attrs || {}; + const attrsStr = Object.entries(attrs) + .filter(([, v]) => v != null && v !== false) + .map(([k, v]) => { + const name = k === 'className' ? 'class' : k; + if (v === true) { + return name; + } + return `${name}="${htmlEscapeAttr(String(v))}"`; + }) + .join(' '); + + const open = attrsStr.length ? ``; +} + +function routerManagedTagsToHtml(tags: any): string[] { + const normalizedTags = Array.isArray(tags) ? tags : [tags]; + return normalizedTags.map(routerManagedTagToHtml).filter(Boolean); +} + +function createGetSsrHref(request: Request): string { + const url = new URL(request.url); + return `${url.pathname}${url.search}${url.hash}`; +} + +function stripSyntheticNotFoundRoute(routes: RouteObject[]): RouteObject[] { + return routes + .filter(route => !(route.path === '*' && !route.id && !route.loader)) + .map(route => { + if (!route.children?.length) { + return route; + } + return { + ...route, + children: stripSyntheticNotFoundRoute(route.children), + }; + }); +} + +function collectRouterErrors( + tanstackRouter: AnyRouter, +): Record | undefined { + const matches = Array.isArray((tanstackRouter as any).state?.matches) + ? (tanstackRouter as any).state.matches + : []; + const errors = matches.reduce((acc: Record, match: any) => { + if (!match?.error) { + return acc; + } + + const routeId = + typeof match.routeId === 'string' + ? match.routeId + : typeof match.route?.id === 'string' + ? match.route.id + : `match-${Object.keys(acc).length}`; + + acc[routeId] = match.error; + return acc; + }, {}); + + return Object.keys(errors).length > 0 ? errors : undefined; +} + +export const tanstackRouterPlugin = ( + userConfig: Partial = {}, +): RuntimePlugin<{ + extendHooks: RouterExtendsHooks; +}> => { + return { + name: '@modern-js/plugin-router-tanstack', + registryHooks: { + modifyRoutes: modifyRoutesHook, + onAfterCreateRouter: onAfterCreateRouterHook, + onAfterHydrateRouter: onAfterHydrateRouterHook, + onBeforeCreateRouter: onBeforeCreateRouterHook, + onBeforeCreateRoutes: onBeforeCreateRoutesHook, + onBeforeHydrateRouter: onBeforeHydrateRouterHook, + }, + setup: api => { + api.onBeforeRender(async (context, interrupt) => { + const pluginConfig: Record = api.getRuntimeConfig(); + const mergedConfig = merge( + pluginConfig.router || {}, + userConfig, + ) as RouterConfig; + + const { basename = '', routesConfig, createRoutes } = mergedConfig; + + const finalRouteConfig = { + routes: getGlobalRoutes(), + globalApp: getGlobalLayoutApp(), + ...routesConfig, + }; + + if (!finalRouteConfig.routes && !createRoutes) { + return; + } + + const hooks = api.getHooks(); + await hooks.onBeforeCreateRoutes.call(context); + + const routeObjects = createRoutes + ? createRoutes() + : createRouteObjectsFromConfig({ + routesConfig: finalRouteConfig, + ssrMode: context.ssrContext?.mode, + }) || []; + const normalizedRouteObjects = createRoutes + ? routeObjects + : stripSyntheticNotFoundRoute(routeObjects); + const modifiedRouteObjects = hooks.modifyRoutes.call( + normalizedRouteObjects, + ); + + if (!modifiedRouteObjects.length) { + return; + } + + const { request, nonce, baseUrl } = context.ssrContext!; + + const _basename = + baseUrl === '/' ? urlJoin(baseUrl, basename || '') : baseUrl; + + const initialHref = createGetSsrHref(request.raw); + + const requestContext = createRequestContext( + context.ssrContext?.loaderContext, + ) as RequestContext>; + + const controller = new AbortController(); + const ssrRequest = new Request(request.raw.url, { + method: 'GET', + headers: request.raw.headers, + signal: controller.signal, + }); + + const routerContext: ModernTanstackRouterContext = { + request: ssrRequest, + requestContext, + }; + + const routeTree = createRouteTreeFromRouteObjects(modifiedRouteObjects); + const history = createMemoryHistory({ + initialEntries: [initialHref], + }); + + const rewrite = createModernBasepathRewrite(_basename); + const routerLifecycleContext: RouterLifecycleContext = { + framework: 'tanstack', + phase: 'ssr-prepare', + routes: modifiedRouteObjects, + runtimeContext: context as TInternalRuntimeContext, + basename: _basename, + }; + hooks.onBeforeCreateRouter.call(routerLifecycleContext); + + const tanstackRouter = createRouter({ + routeTree, + history, + basepath: '/', + rewrite, + origin: new URL(request.raw.url).origin, + ssr: { nonce }, + context: routerContext as any, + }); + + attachRouterServerSsrUtils({ + router: tanstackRouter as any, + manifest: undefined, + }); + + const end = time(); + + try { + await tanstackRouter.load(); + } finally { + const cost = end(); + context.ssrContext?.onTiming?.(LOADER_REPORTER_NAME, cost); + } + + if ((tanstackRouter as any).state?.redirect) { + const resolved = (tanstackRouter as any).resolveRedirect + ? (tanstackRouter as any).resolveRedirect( + (tanstackRouter as any).state.redirect, + ) + : (tanstackRouter as any).state.redirect; + + try { + (tanstackRouter as any).serverSsr?.cleanup?.(); + } catch {} + + return interrupt(resolved as any); + } + + context.ssrContext?.response.status(tanstackRouter.state.statusCode); + + await (tanstackRouter as any).serverSsr?.dehydrate?.(); + + const ssrScriptTags = ( + tanstackRouter as any + ).serverSsr?.takeBufferedScripts?.(); + const hydrationScripts = routerManagedTagsToHtml(ssrScriptTags); + const matchedRouteIds = getModernRouteIdsFromMatches( + tanstackRouter as any, + ); + const routerServerSnapshot: InternalRouterServerSnapshot = + createRouterServerSnapshot({ + framework: 'tanstack', + basename: _basename, + statusCode: tanstackRouter.state.statusCode, + errors: collectRouterErrors(tanstackRouter as any), + matchedRouteIds, + hydrationScripts, + }); + const runtimeContext = applyRouterServerPrepareResult( + context as TInternalRuntimeContext, + { + snapshot: routerServerSnapshot, + cleanup: () => (tanstackRouter as any).serverSsr?.cleanup?.(), + state: { + framework: 'tanstack', + basename: _basename, + instance: tanstackRouter as any, + hydrationScripts, + matchedRouteIds, + serverSnapshot: routerServerSnapshot, + }, + }, + ); + hooks.onAfterCreateRouter.call({ + ...routerLifecycleContext, + router: tanstackRouter as any, + serverSnapshot: routerServerSnapshot, + runtimeContext, + }); + }); + + api.wrapRoot(App => { + const getRouteApp = () => { + return (props => { + const context = useContext( + InternalRuntimeContext, + ) as any as TInternalRuntimeContext; + const router = + context.routerInstance ?? context.routerRuntime?.instance; + if (!router) { + return App ? : null; + } + + const routerWrapper = ( + + + + ); + + return App ? {routerWrapper} : routerWrapper; + }) as React.FC; + }; + + return getRouteApp(); + }); + }, + }; +}; + +export default tanstackRouterPlugin; diff --git a/packages/runtime/plugin-tanstack/src/runtime/plugin.tsx b/packages/runtime/plugin-tanstack/src/runtime/plugin.tsx index eb0e0f0d5705..7ed17c874ad1 100644 --- a/packages/runtime/plugin-tanstack/src/runtime/plugin.tsx +++ b/packages/runtime/plugin-tanstack/src/runtime/plugin.tsx @@ -1,52 +1,55 @@ +/// + +import { + getGlobalLayoutApp, + getGlobalRoutes, + InternalRuntimeContext, +} from '@modern-js/runtime/context'; +import type { RuntimePlugin } from '@modern-js/runtime/plugin'; import { merge } from '@modern-js/runtime-utils/merge'; +import type { RouteObject } from '@modern-js/runtime-utils/router'; import { - RouterProvider, createBrowserHistory, createHashHistory, - createMemoryHistory, createRouter, + RouterProvider, useLocation, useMatches, useNavigate, useRouter, } from '@tanstack/react-router'; -import type { RouteObject } from '@modern-js/runtime/router'; -import { - type RouterConfig, - type RuntimePlugin, - type TRuntimeContext, -} from '@modern-js/runtime'; -import { - InternalRuntimeContext, - getGlobalLayoutApp, - getGlobalRoutes, -} from '@modern-js/runtime/context'; -import { - createRouteObjectsFromConfig, - urlJoin, -} from '@modern-js/runtime/router/internal'; +import { RouterClient } from '@tanstack/react-router/ssr/client'; import * as React from 'react'; import { useContext, useMemo } from 'react'; import { createModernBasepathRewrite } from './basepathRewrite'; import { modifyRoutes as modifyRoutesHook, + onAfterCreateRouter as onAfterCreateRouterHook, + onAfterHydrateRouter as onAfterHydrateRouterHook, + onBeforeCreateRouter as onBeforeCreateRouterHook, onBeforeCreateRoutes as onBeforeCreateRoutesHook, + onBeforeHydrateRouter as onBeforeHydrateRouterHook, + type RouterExtendsHooks, } from './hooks'; -import type { TanstackRouterExtendsHooks } from './hooks'; +import { + applyRouterRuntimeState, + type RouterLifecycleContext, +} from './lifecycle'; import { createRouteTreeFromRouteObjects } from './routeTree'; +import type { RouterConfig } from './types'; +import { createRouteObjectsFromConfig, urlJoin } from './utils'; -function normalizeBase(base: string) { - if (base.length > 1 && base.endsWith('/')) return base.slice(0, -1); - return base || '/'; +function normalizeBase(b: string) { + if (b.length > 1 && b.endsWith('/')) { + return b.slice(0, -1); + } + return b || '/'; } function isSegmentPrefix(pathname: string, base: string) { - const normalizedBase = normalizeBase(base); - const normalizedPathname = pathname || '/'; - return ( - normalizedPathname === normalizedBase || - normalizedPathname.startsWith(`${normalizedBase}/`) - ); + const b = normalizeBase(base); + const p = pathname || '/'; + return p === b || p.startsWith(`${b}/`); } function stripSyntheticNotFoundRoute(routes: RouteObject[]): RouteObject[] { @@ -63,20 +66,20 @@ function stripSyntheticNotFoundRoute(routes: RouteObject[]): RouteObject[] { }); } -export interface TanstackRouterRuntimeConfig extends Partial { - routesDir?: string; -} - export const tanstackRouterPlugin = ( - userConfig: TanstackRouterRuntimeConfig = {}, + userConfig: Partial = {}, ): RuntimePlugin<{ - extendHooks: TanstackRouterExtendsHooks; + extendHooks: RouterExtendsHooks; }> => { return { - name: '@modern-js/plugin-tanstack', + name: '@modern-js/plugin-router-tanstack', registryHooks: { modifyRoutes: modifyRoutesHook, + onAfterCreateRouter: onAfterCreateRouterHook, + onAfterHydrateRouter: onAfterHydrateRouterHook, + onBeforeCreateRouter: onBeforeCreateRouterHook, onBeforeCreateRoutes: onBeforeCreateRoutesHook, + onBeforeHydrateRouter: onBeforeHydrateRouterHook, }, setup: api => { api.onBeforeRender(context => { @@ -89,9 +92,8 @@ export const tanstackRouterPlugin = ( }); api.wrapRoot(App => { - const runtimeConfig = api.getRuntimeConfig() as Record; const mergedConfig = merge( - runtimeConfig.tanstackRouter || {}, + api.getRuntimeConfig().router || {}, userConfig, ) as RouterConfig; @@ -114,14 +116,14 @@ export const tanstackRouterPlugin = ( } const hooks = api.getHooks() as any; + let cachedRouteObjects: RouteObject[] | undefined; - const getRouteObjects = (context: TRuntimeContext) => { + const getRouteObjects = () => { if (typeof cachedRouteObjects !== 'undefined') { return cachedRouteObjects; } - hooks.onBeforeCreateRoutes.call(context); const routeObjects = createRoutes ? createRoutes() : createRouteObjectsFromConfig({ @@ -150,20 +152,13 @@ export const tanstackRouterPlugin = ( let cachedRouterBasepath: string | null = null; const RouterWrapper = () => { - const runtimeContext = useContext( - InternalRuntimeContext, - ) as unknown as TRuntimeContext & { - _internalRouterBaseName?: string; - }; + const runtimeContext = useContext(InternalRuntimeContext); - const isBrowser = typeof window !== 'undefined'; - const requestPathname = - runtimeContext.request instanceof Request - ? new URL(runtimeContext.request.url).pathname - : '/'; - const pathname = isBrowser ? location.pathname : requestPathname; - const baseUrl = selectBasePath(pathname).replace(/^[\\/]*/, '/'); - const resolvedBasename = + const baseUrl = selectBasePath(location.pathname).replace( + /^\/*/, + '/', + ); + const _basename = baseUrl === '/' ? urlJoin( baseUrl, @@ -175,33 +170,42 @@ export const tanstackRouterPlugin = ( if (cachedRouteTree) { return cachedRouteTree; } - - const routeObjects = getRouteObjects(runtimeContext); + const routeObjects = getRouteObjects(); if (!routeObjects.length) { return null; } - cachedRouteTree = createRouteTreeFromRouteObjects(routeObjects); return cachedRouteTree; - }, [runtimeContext]); + }, []); if (!routeTree) { return App ? : null; } const router = useMemo(() => { - if (cachedRouter && cachedRouterBasepath === resolvedBasename) { + const lifecycleContext: RouterLifecycleContext = { + framework: 'tanstack', + phase: 'client-create', + routes: getRouteObjects(), + runtimeContext, + basename: _basename, + }; + hooks.onBeforeCreateRouter.call(lifecycleContext); + + if (cachedRouter && cachedRouterBasepath === _basename) { + hooks.onAfterCreateRouter.call({ + ...lifecycleContext, + router: cachedRouter, + runtimeContext, + }); return cachedRouter; } - const history = isBrowser - ? supportHtml5History - ? createBrowserHistory() - : createHashHistory() - : createMemoryHistory({ - initialEntries: [pathname || '/'], - }); - const rewrite = createModernBasepathRewrite(resolvedBasename); + const history = supportHtml5History + ? createBrowserHistory() + : createHashHistory(); + + const rewrite = createModernBasepathRewrite(_basename); cachedRouter = createRouter({ routeTree, @@ -210,13 +214,57 @@ export const tanstackRouterPlugin = ( history, context: {}, }); - cachedRouterBasepath = resolvedBasename; + cachedRouterBasepath = _basename; + hooks.onAfterCreateRouter.call({ + ...lifecycleContext, + router: cachedRouter, + runtimeContext, + }); return cachedRouter; - }, [resolvedBasename, routeTree, supportHtml5History]); + }, [_basename, routeTree, supportHtml5History, runtimeContext]); + const runtimeState = applyRouterRuntimeState(runtimeContext, { + framework: 'tanstack', + basename: _basename, + instance: router, + }); + const lifecycleContext: RouterLifecycleContext = { + framework: 'tanstack', + phase: 'client-create', + routes: getRouteObjects(), + runtimeContext: runtimeState, + basename: _basename, + router, + }; - const routerContent = ; - return App ? {routerContent} : routerContent; + const hasSSRBootstrap = + typeof window !== 'undefined' && (window as any).$_TSR; + if (hasSSRBootstrap) { + hooks.onBeforeHydrateRouter.call({ + ...lifecycleContext, + phase: 'hydrate', + router, + runtimeContext: runtimeState, + }); + } + + const RouterContent = hasSSRBootstrap ? ( + + + + ) : ( + + ); + if (hasSSRBootstrap) { + hooks.onAfterHydrateRouter.call({ + ...lifecycleContext, + phase: 'hydrate', + router, + runtimeContext: runtimeState, + }); + } + + return App ? {RouterContent} : RouterContent; }; return RouterWrapper as any; @@ -224,3 +272,5 @@ export const tanstackRouterPlugin = ( }, }; }; + +export default tanstackRouterPlugin; diff --git a/packages/runtime/plugin-tanstack/src/runtime/prefetchLink.tsx b/packages/runtime/plugin-tanstack/src/runtime/prefetchLink.tsx index 27daa3531102..37a9bdc9e997 100644 --- a/packages/runtime/plugin-tanstack/src/runtime/prefetchLink.tsx +++ b/packages/runtime/plugin-tanstack/src/runtime/prefetchLink.tsx @@ -66,4 +66,5 @@ const LinkComponentImpl = (props: any) => { }; export const Link = LinkComponentImpl as LinkComponent; + export const NavLink = LinkComponentImpl as LinkComponent; diff --git a/packages/runtime/plugin-tanstack/src/runtime/routeTree.ts b/packages/runtime/plugin-tanstack/src/runtime/routeTree.ts index bdd9dabaa943..42060b137355 100644 --- a/packages/runtime/plugin-tanstack/src/runtime/routeTree.ts +++ b/packages/runtime/plugin-tanstack/src/runtime/routeTree.ts @@ -349,7 +349,7 @@ function createRouteFromRouteObject(opts: { const children = routeObject.children; if (children && children.length > 0) { - const childRoutes = children.map(child => + const childRoutes = children.map((child: RouteObject) => createRouteFromRouteObject({ parent: route, routeObject: child }), ); (route as any).addChildren(childRoutes); @@ -430,7 +430,7 @@ export function createRouteTreeFromModernRoutes( routes: Array, ): TanstackRootRoute { const rootModern = routes.find( - route => route && (route as any).type === 'nested' && (route as any).isRoot, + r => r && (r as any).type === 'nested' && (r as any).isRoot, ) as NestedRoute | undefined; const rootComponent = (rootModern as any)?.component; @@ -511,7 +511,7 @@ export function createRouteTreeFromRouteObjects( export function getModernRouteIdsFromMatches(router: AnyRouter): string[] { const matches = router.state.matches || []; const ids = matches - .map((match: any) => match.route?.options?.staticData?.modernRouteId) + .map((m: any) => m.route?.options?.staticData?.modernRouteId) .filter(Boolean); return Array.from(new Set(ids)); } diff --git a/packages/runtime/plugin-tanstack/src/runtime/ssr-shim.d.ts b/packages/runtime/plugin-tanstack/src/runtime/ssr-shim.d.ts new file mode 100644 index 000000000000..f6903d934128 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/ssr-shim.d.ts @@ -0,0 +1,12 @@ +declare module '@tanstack/react-router/ssr/client' { + export function RouterClient(props: { + router: unknown; + }): import('react').JSX.Element; +} + +declare module '@tanstack/react-router/ssr/server' { + export function attachRouterServerSsrUtils(opts: { + router: unknown; + manifest?: unknown; + }): void; +} diff --git a/packages/runtime/plugin-tanstack/src/runtime/types.ts b/packages/runtime/plugin-tanstack/src/runtime/types.ts new file mode 100644 index 000000000000..56bdc39d923d --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/types.ts @@ -0,0 +1,83 @@ +import type { RequestContext } from '@modern-js/runtime-utils/node'; +import type { RouteObject } from '@modern-js/runtime-utils/router'; +import type { NestedRoute, PageRoute } from '@modern-js/types'; +import type React from 'react'; + +export type BuiltInRouterFramework = 'react-router' | 'tanstack'; +export type RouterFramework = BuiltInRouterFramework | (string & {}); + +export type RouterConfig = { + framework?: RouterFramework; + routesConfig: { + globalApp?: React.ComponentType; + routes?: (NestedRoute | PageRoute)[]; + }; + oldVersion?: boolean; + serverBase?: string[]; + supportHtml5History?: boolean; + basename?: string; + createRoutes?: () => RouteObject[]; + future?: Partial<{ + v7_startTransition: boolean; + }>; + unstable_reloadOnURLMismatch?: boolean; +}; + +export interface RouterRouteMatchSnapshot { + routeId: string; + assetRouteId?: string; + pathname?: string; + params?: Record; +} + +export interface InternalRouterServerSnapshot { + framework?: RouterFramework; + basename?: string; + statusCode?: number; + errors?: Record; + routerData?: { + loaderData?: Record; + errors?: Record; + }; + hydrationScript?: string; + hydrationScripts?: string[]; + matchedRouteIds?: string[]; + matches?: RouterRouteMatchSnapshot[]; +} + +export interface InternalRouterRuntimeState { + framework: RouterFramework; + basename?: string; + instance?: unknown; + hydrationScript?: string; + hydrationScripts?: string[]; + matchedRouteIds?: string[]; + matches?: RouterRouteMatchSnapshot[]; + serverSnapshot?: InternalRouterServerSnapshot; + cleanup?: () => void | Promise; +} + +export interface RouterServerPrepareResult { + state: InternalRouterRuntimeState; + snapshot?: InternalRouterServerSnapshot; + redirect?: Response; + cleanup?: () => void | Promise; +} + +interface DataFunctionArgs { + request: Request; + params: Record; + context?: D; +} + +export type LoaderFunctionArgs< + P extends Record = Record, +> = DataFunctionArgs>; + +type DataFunctionValue = Response | NonNullable | null; + +export type LoaderFunction = < + P extends Record = Record, +>( + args: LoaderFunctionArgs

, +) => Promise | DataFunctionValue; diff --git a/packages/runtime/plugin-tanstack/src/runtime/utils.tsx b/packages/runtime/plugin-tanstack/src/runtime/utils.tsx new file mode 100644 index 000000000000..510e6a2b5847 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/utils.tsx @@ -0,0 +1,158 @@ +import type { RouteObject } from '@modern-js/runtime-utils/router'; +import type { NestedRoute, PageRoute, SSRMode } from '@modern-js/types'; +import React from 'react'; +import { DefaultNotFound } from './DefaultNotFound'; + +type RouterConfig = { + routesConfig: { + globalApp?: React.ComponentType; + routes?: (NestedRoute | PageRoute)[]; + }; +}; + +type LayoutWrapperProps = { + [key: string]: unknown; +}; + +type GlobalAppProps = { + Component: React.ComponentType; + [key: string]: unknown; +}; + +export type ModernRouteObject = RouteObject & { + isClientComponent?: boolean; + hasClientLoader?: boolean; + hasLoader?: boolean; + hasAction?: boolean; + lazyImport?: () => Promise<{ default: React.ComponentType }>; +}; + +export function getRouteObjects( + routes: (NestedRoute | PageRoute)[], + { + globalApp, + ssrMode, + props, + }: { + globalApp?: React.ComponentType; + ssrMode?: SSRMode; + props?: Record; + }, +) { + const createLayoutElement = ( + Component: React.ComponentType, + ): React.ComponentType => { + const GlobalLayout = globalApp; + if (!GlobalLayout) { + return Component; + } + + const LayoutWrapper = (props: LayoutWrapperProps) => { + const LayoutComponent = GlobalLayout; + return ; + }; + + return LayoutWrapper; + }; + + const routeObjects: RouteObject[] = []; + + for (const route of routes) { + if (route.type === 'nested') { + const nestedRouteObject = { + path: route.path, + id: route.id, + loader: route.loader, + action: route.action, + hasErrorBoundary: route.hasErrorBoundary, + shouldRevalidate: route.shouldRevalidate, + handle: { + ...route.handle, + ...(typeof route.config === 'object' ? route.config?.handle : {}), + }, + index: route.index, + hasLoader: Boolean(route.loader), + hasClientLoader: Boolean(route.clientData), + hasAction: Boolean(route.action), + ...(route.isClientComponent ? { isClientComponent: true } : {}), + Component: route.component ? route.component : undefined, + errorElement: route.error ? : undefined, + children: route.children + ? route.children.map( + child => + getRouteObjects([child], { globalApp, ssrMode, props })[0], + ) + : undefined, + } as ModernRouteObject; + + routeObjects.push(nestedRouteObject); + } else if ( + typeof route.component === 'function' || + typeof route.component === 'object' + ) { + const LayoutComponent = createLayoutElement( + route.component as React.ComponentType, + ); + const routeObject: RouteObject = { + path: route.path, + element: React.createElement(LayoutComponent), + }; + + routeObjects.push(routeObject); + } + } + + routeObjects.push({ + path: '*', + element: , + }); + + return routeObjects; +} + +export function createRouteObjectsFromConfig({ + routesConfig, + props, + ssrMode, +}: { + routesConfig: RouterConfig['routesConfig']; + props?: Record; + ssrMode?: SSRMode; +}): RouteObject[] | null { + if (!routesConfig) { + return null; + } + const { routes, globalApp } = routesConfig; + if (!routes) { + return null; + } + return getRouteObjects(routes, { + globalApp, + ssrMode, + props, + }); +} + +export const urlJoin = (...parts: string[]) => { + const separator = '/'; + const replace = new RegExp(`${separator}{1,}`, 'g'); + return standardSlash(parts.join(separator).replace(replace, separator)); +}; + +export function standardSlash(str: string) { + let addr = str; + if (!addr || typeof addr !== 'string') { + return addr; + } + if (addr.startsWith('.')) { + addr = addr.slice(1); + } + if (!addr.startsWith('/')) { + addr = `/${addr}`; + } + if (addr.endsWith('/') && addr !== '/') { + addr = addr.slice(0, addr.length - 1); + } + + return addr; +} diff --git a/packages/runtime/plugin-tanstack/tests/router/cli.test.ts b/packages/runtime/plugin-tanstack/tests/router/cli.test.ts new file mode 100644 index 000000000000..866ca54c9ec3 --- /dev/null +++ b/packages/runtime/plugin-tanstack/tests/router/cli.test.ts @@ -0,0 +1,386 @@ +import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import type { Entrypoint } from '@modern-js/types'; +import { fs, NESTED_ROUTE_SPEC_FILE } from '@modern-js/utils'; +import { + tanstackRouterPlugin, + writeTanstackRegisterFile, + writeTanstackRouterTypesForEntries, +} from '../../src/cli'; + +const runtimeCliMocks = { + handleFileChange: rstest.fn(), + handleGeneratorEntryCode: rstest.fn(), +}; + +rstest.mock('@modern-js/runtime/cli', () => { + const routesDirMetaKey = '__modernRoutesDir'; + + return { + __esModule: true, + getEntrypointRoutesDir: (entrypoint: any) => + entrypoint[routesDirMetaKey] || + (entrypoint.nestedRoutesEntry + ? path.basename(entrypoint.nestedRoutesEntry) + : null), + handleFileChange: runtimeCliMocks.handleFileChange, + handleGeneratorEntryCode: runtimeCliMocks.handleGeneratorEntryCode, + handleModifyEntrypoints: async ( + entrypoints: Entrypoint[], + routesDir = 'routes', + ) => + entrypoints.map(entrypoint => { + const routesEntry = path.join(entrypoint.absoluteEntryDir!, routesDir); + return { + ...entrypoint, + nestedRoutesEntry: routesEntry, + [routesDirMetaKey]: routesDir, + }; + }), + isRouteEntry: (dir: string, routesDir = 'routes') => { + const routesEntry = path.join(dir, routesDir); + return fs.existsSync(routesEntry) ? routesEntry : false; + }, + }; +}); + +describe('tanstack router cli plugin', () => { + let tempDir: string | undefined; + + afterEach(async () => { + runtimeCliMocks.handleFileChange.mockReset(); + runtimeCliMocks.handleGeneratorEntryCode.mockReset(); + + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } + }); + + test('writes plugin-owned router types and register metadata', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-cli-')); + const srcDirectory = path.join(tempDir, 'src'); + await mkdir(srcDirectory, { recursive: true }); + + await writeTanstackRouterTypesForEntries({ + appContext: { + srcDirectory, + internalSrcAlias: '@/_', + entrypoints: [ + { entryName: 'dashboard', isMainEntry: false }, + { entryName: 'main', isMainEntry: true }, + ], + } as any, + generatedDirName: 'generated-router', + routesByEntry: { + dashboard: [], + main: [], + }, + }); + + const mainRouter = await readFile( + path.join(srcDirectory, 'generated-router', 'main', 'router.gen.ts'), + 'utf-8', + ); + expect(mainRouter).toContain( + "} from '@modern-js/plugin-tanstack/runtime';", + ); + + const register = await readFile( + path.join(srcDirectory, 'generated-router', 'register.gen.d.ts'), + 'utf-8', + ); + expect(register).toContain("from './main/router.gen'"); + expect(register.indexOf("from './main/router.gen'")).toBeLessThan( + register.indexOf("from './dashboard/router.gen'"), + ); + expect(register).toContain( + "declare module '@modern-js/plugin-tanstack/runtime'", + ); + }); + + test('can write register metadata without routes for custom entry lists', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-cli-')); + const srcDirectory = path.join(tempDir, 'src'); + + await writeTanstackRegisterFile({ + entries: ['main'], + generatedDirName: 'tanstack', + srcDirectory, + }); + + const register = await readFile( + path.join(srcDirectory, 'tanstack', 'register.gen.d.ts'), + 'utf-8', + ); + expect(register).toContain('router: typeof router0'); + expect(register).toContain( + "declare module '@modern-js/plugin-tanstack/runtime'", + ); + }); + + test('claims custom routes, injects runtime plugin, and merges route specs', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-cli-')); + const srcDirectory = path.join(tempDir, 'src'); + const distDirectory = path.join(tempDir, 'dist'); + const entryDir = path.join(srcDirectory, 'main'); + const viewsDir = path.join(entryDir, 'views'); + await mkdir(viewsDir, { recursive: true }); + + const taps: Record = {}; + const api = { + getAppContext: () => ({ + srcDirectory, + distDirectory, + metaName: 'modern-js', + serverRoutes: [{ entryName: 'main', urlPath: '/dashboard' }], + }), + _internalRuntimePlugins: (tap: any) => { + taps.internalRuntimePlugins = tap; + }, + checkEntryPoint: (tap: any) => { + taps.checkEntryPoint = tap; + }, + config: (tap: any) => { + taps.config = tap; + }, + modifyEntrypoints: (tap: any) => { + taps.modifyEntrypoints = tap; + }, + generateEntryCode: (tap: any) => { + taps.generateEntryCode = tap; + }, + onFileChanged: (tap: any) => { + taps.onFileChanged = tap; + }, + modifyFileSystemRoutes: (tap: any) => { + taps.modifyFileSystemRoutes = tap; + }, + onBeforeGenerateRoutes: (tap: any) => { + taps.onBeforeGenerateRoutes = tap; + }, + }; + + tanstackRouterPlugin({ routesDir: 'views' }).setup!(api as any); + + expect(taps.checkEntryPoint({ path: entryDir, entry: false })).toEqual({ + path: entryDir, + entry: viewsDir, + }); + + const { entrypoints } = await taps.modifyEntrypoints({ + entrypoints: [ + { + entryName: 'main', + entry: entryDir, + absoluteEntryDir: entryDir, + isAutoMount: true, + isMainEntry: true, + } as Entrypoint, + ], + }); + const [entrypoint] = entrypoints; + expect(entrypoint.nestedRoutesEntry).toBe(viewsDir); + + expect( + taps.internalRuntimePlugins({ entrypoint, plugins: [] }).plugins, + ).toEqual([ + { + name: 'tanstackRouter', + path: '@modern-js/plugin-tanstack/runtime', + config: { serverBase: ['/dashboard'] }, + }, + ]); + + const specPath = path.join(distDirectory, NESTED_ROUTE_SPEC_FILE); + await fs.outputJSON(specPath, { + existing: [{ id: 'keep-me' }], + }); + + await taps.modifyFileSystemRoutes({ + entrypoint, + routes: [ + { + id: 'main-route', + type: 'nested', + origin: 'file-system', + }, + ], + }); + await taps.onBeforeGenerateRoutes({ entrypoint, code: '' }); + + expect(await fs.readJSON(specPath)).toEqual({ + existing: [{ id: 'keep-me' }], + main: [ + { + id: 'main-route', + type: 'nested', + origin: 'file-system', + }, + ], + }); + }); + + test('generates plugin-owned TanStack route files through core route generation', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-cli-')); + const srcDirectory = path.join(tempDir, 'src'); + const entryDir = path.join(srcDirectory, 'main'); + const viewsDir = path.join(entryDir, 'views'); + await mkdir(path.join(viewsDir, 'mf'), { recursive: true }); + await fs.outputFile( + path.join(viewsDir, 'mf', 'page.data.ts'), + [ + 'export const loader = () => ({ count: 0 });', + 'export const action = () => Response.json({ count: 1 });', + ].join('\n'), + ); + + const entrypoint = { + entryName: 'main', + entry: entryDir, + absoluteEntryDir: entryDir, + isAutoMount: true, + isMainEntry: true, + nestedRoutesEntry: viewsDir, + __modernRoutesDir: 'views', + } as Entrypoint; + runtimeCliMocks.handleGeneratorEntryCode.mockResolvedValue({ + main: [ + { + type: 'nested', + id: 'layout', + isRoot: true, + children: [ + { + type: 'nested', + id: 'mf/page', + path: 'mf', + data: '@/_/main/views/mf/page.data', + action: '@/_/main/views/mf/page.data', + }, + ], + }, + ], + }); + + const taps: Record = {}; + const api = { + getAppContext: () => ({ + srcDirectory, + internalSrcAlias: '@/_', + entrypoints: [entrypoint], + }), + _internalRuntimePlugins: () => {}, + checkEntryPoint: (tap: any) => { + taps.checkEntryPoint = tap; + }, + config: (tap: any) => { + taps.config = tap; + }, + modifyEntrypoints: (tap: any) => { + taps.modifyEntrypoints = tap; + }, + generateEntryCode: (tap: any) => { + taps.generateEntryCode = tap; + }, + onFileChanged: (tap: any) => { + taps.onFileChanged = tap; + }, + modifyFileSystemRoutes: (tap: any) => { + taps.modifyFileSystemRoutes = tap; + }, + onBeforeGenerateRoutes: (tap: any) => { + taps.onBeforeGenerateRoutes = tap; + }, + }; + + tanstackRouterPlugin({ + generatedDirName: 'tanstack-generated', + routesDir: 'views', + }).setup!(api as any); + + await taps.generateEntryCode({ entrypoints: [entrypoint] }); + + expect(runtimeCliMocks.handleGeneratorEntryCode).toHaveBeenCalledWith( + api, + [entrypoint], + { + entrypointsKey: '@modern-js/plugin-tanstack', + generateCodeOptions: { + enableTanstackTypes: false, + }, + }, + ); + + const routerGen = await readFile( + path.join(srcDirectory, 'tanstack-generated', 'main', 'router.gen.ts'), + 'utf-8', + ); + expect(routerGen).toContain("} from '@modern-js/plugin-tanstack/runtime';"); + expect(routerGen).toContain('modernRouteAction: action_0'); + + const register = await readFile( + path.join(srcDirectory, 'tanstack-generated', 'register.gen.d.ts'), + 'utf-8', + ); + expect(register).toContain( + "declare module '@modern-js/plugin-tanstack/runtime'", + ); + }); + + test('regenerates plugin-owned TanStack files for scoped file changes', async () => { + const regenerateEvent = { + eventType: 'add', + filename: 'src/main/views/page.tsx', + }; + const entrypoint = { + entryName: 'main', + __modernRoutesDir: 'views', + } as any as Entrypoint; + const api = { + getAppContext: () => ({ + srcDirectory: '/tmp/app/src', + internalSrcAlias: '@/_', + entrypoints: [entrypoint], + }), + _internalRuntimePlugins: () => {}, + checkEntryPoint: () => {}, + config: () => {}, + modifyEntrypoints: () => {}, + generateEntryCode: () => {}, + onFileChanged: (tap: any) => { + api.onFileChangedTap = tap; + }, + modifyFileSystemRoutes: () => {}, + onBeforeGenerateRoutes: () => {}, + onFileChangedTap: undefined as any, + }; + + tanstackRouterPlugin({ routesDir: 'views' }).setup!(api as any); + + runtimeCliMocks.handleFileChange.mockImplementationOnce( + async (_api, _event, options) => { + expect(options.entrypointsKey).toBe('@modern-js/plugin-tanstack'); + expect(options.includeEntry(entrypoint)).toBe(true); + expect( + options.includeEntry({ + ...entrypoint, + __modernRoutesDir: 'routes', + }), + ).toBe(false); + expect(typeof options.regenerate).toBe('function'); + }, + ); + + await api.onFileChangedTap(regenerateEvent); + + expect(runtimeCliMocks.handleFileChange).toHaveBeenCalledWith( + api, + regenerateEvent, + expect.objectContaining({ + entrypointsKey: '@modern-js/plugin-tanstack', + }), + ); + }); +}); diff --git a/packages/runtime/plugin-tanstack/tests/router/dataMutation.test.tsx b/packages/runtime/plugin-tanstack/tests/router/dataMutation.test.tsx index 7eee48030d65..bb32c1369c0f 100644 --- a/packages/runtime/plugin-tanstack/tests/router/dataMutation.test.tsx +++ b/packages/runtime/plugin-tanstack/tests/router/dataMutation.test.tsx @@ -1,11 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import React from 'react'; -import { act } from 'react'; +import React, { act } from 'react'; import type { Fetcher } from '../../src/runtime/dataMutation'; -import { - Form, - useFetcher, -} from '../../src/runtime/dataMutation'; +import { Form, useFetcher } from '../../src/runtime/dataMutation'; type RouteHandler = (args: { request: Request; diff --git a/packages/runtime/plugin-tanstack/tests/router/tanstackTypes.test.ts b/packages/runtime/plugin-tanstack/tests/router/tanstackTypes.test.ts new file mode 100644 index 000000000000..a3d2cb757ed7 --- /dev/null +++ b/packages/runtime/plugin-tanstack/tests/router/tanstackTypes.test.ts @@ -0,0 +1,62 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { generateTanstackRouterTypesSourceForEntry } from '../../src/cli/tanstackTypes'; + +describe('tanstack router type generation', () => { + let tempDir: string | undefined; + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } + }); + + test('emits inline data actions into route static data', async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'modern-tanstack-types-')); + const srcDirectory = path.join(tempDir, 'src'); + const routeDir = path.join(srcDirectory, 'routes', 'mf'); + await mkdir(routeDir, { recursive: true }); + await writeFile( + path.join(routeDir, 'page.data.ts'), + [ + 'export const loader = () => ({ count: 0 });', + 'export const action = () => Response.json({ count: 1 });', + ].join('\n'), + ); + + const { routerGenTs } = await generateTanstackRouterTypesSourceForEntry({ + appContext: { + srcDirectory, + internalSrcAlias: '@/_', + } as any, + entryName: 'index', + routes: [ + { + type: 'nested', + id: 'layout', + isRoot: true, + children: [ + { + type: 'nested', + id: 'mf/page', + path: 'mf', + data: '@/_/routes/mf/page.data', + action: '@/_/routes/mf/page.data', + }, + ], + }, + ] as any, + }); + + expect(routerGenTs).toContain( + 'import { loader as loader_0, action as action_0 } from "../../routes/mf/page.data";', + ); + expect(routerGenTs).toContain('modernRouteLoader: loader_0'); + expect(routerGenTs).toContain('modernRouteAction: action_0'); + expect(routerGenTs).toContain( + "} from '@modern-js/plugin-tanstack/runtime';", + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f1fdff6a3cc..57df2b915134 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,7 +38,7 @@ importers: version: 2.29.8(@types/node@25.6.0) '@commitlint/cli': specifier: ^20.5.3 - version: 20.5.3(@types/node@25.6.0)(conventional-commits-parser@6.3.0)(typescript@5.9.3) + version: 20.5.3(@types/node@25.6.0)(conventional-commits-parser@6.3.0)(typescript@6.0.3) '@commitlint/config-conventional': specifier: ^20.5.3 version: 20.5.3 @@ -489,7 +489,7 @@ importers: version: 5.1.36 styled-components: specifier: ^5.3.1 - version: 5.3.11(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6) + version: 5.3.11(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6) typescript: specifier: ^5.3.3 version: 5.9.3 @@ -768,6 +768,70 @@ importers: specifier: ^5 version: 5.9.3 + packages/runtime/plugin-tanstack: + dependencies: + '@modern-js/plugin': + specifier: workspace:* + version: link:../../toolkit/plugin + '@modern-js/runtime-utils': + specifier: workspace:* + version: link:../../toolkit/runtime-utils + '@modern-js/types': + specifier: workspace:* + version: link:../../toolkit/types + '@modern-js/utils': + specifier: workspace:* + version: link:../../toolkit/utils + '@swc/helpers': + specifier: ^0.5.17 + version: 0.5.21 + '@tanstack/react-router': + specifier: 1.168.26 + version: 1.168.26(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + devDependencies: + '@modern-js/app-tools': + specifier: workspace:* + version: link:../../solutions/app-tools + '@modern-js/rslib': + specifier: workspace:* + version: link:../../../scripts/rslib + '@modern-js/runtime': + specifier: workspace:* + version: link:../plugin-runtime + '@rslib/core': + specifier: 0.21.4 + version: 0.21.4(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0)(typescript@5.9.3) + '@scripts/rstest-config': + specifier: workspace:* + version: link:../../../scripts/rstest-config + '@tanstack/history': + specifier: 1.161.6 + version: 1.161.6 + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@types/node': + specifier: ^20 + version: 20.19.27 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + react: + specifier: ^19.2.6 + version: 19.2.6 + react-dom: + specifier: ^19.2.6 + version: 19.2.6(react@19.2.6) + typescript: + specifier: ^5 + version: 5.9.3 + packages/runtime/render: dependencies: '@modern-js/types': @@ -1977,7 +2041,7 @@ importers: version: 2.0.1 puppeteer: specifier: ^24.37.5 - version: 24.39.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + version: 24.43.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) rimraf: specifier: ^6.1.3 version: 6.1.3 @@ -2974,10 +3038,10 @@ importers: version: link:../../../../../packages/runtime/plugin-runtime '@module-federation/modern-js-v3': specifier: 2.0.0 - version: 2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react-router-dom@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-router@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) + version: 2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react-router-dom@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-router@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) i18next: specifier: 25.7.4 - version: 25.7.4(typescript@5.9.3) + version: 25.7.4(typescript@6.0.3) react: specifier: ^19.2.6 version: 19.2.6 @@ -2986,7 +3050,7 @@ importers: version: 19.2.6(react@19.2.6) react-i18next: specifier: 15.7.4 - version: 15.7.4(i18next@25.7.4(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + version: 15.7.4(i18next@25.7.4(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3) devDependencies: '@modern-js/app-tools': specifier: workspace:* @@ -3005,10 +3069,10 @@ importers: version: link:../../../../../packages/runtime/plugin-runtime '@module-federation/modern-js-v3': specifier: 2.0.0 - version: 2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react-router-dom@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-router@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) + version: 2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react-router-dom@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-router@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) i18next: specifier: 25.7.4 - version: 25.7.4(typescript@5.9.3) + version: 25.7.4(typescript@6.0.3) react: specifier: ^19.2.6 version: 19.2.6 @@ -3017,7 +3081,7 @@ importers: version: 19.2.6(react@19.2.6) react-i18next: specifier: 15.7.4 - version: 15.7.4(i18next@25.7.4(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + version: 15.7.4(i18next@25.7.4(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3) devDependencies: '@modern-js/app-tools': specifier: workspace:* @@ -3036,13 +3100,13 @@ importers: version: link:../../../../../packages/runtime/plugin-runtime '@module-federation/modern-js-v3': specifier: 2.0.0 - version: 2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react-router-dom@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-router@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) + version: 2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react-router-dom@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-router@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) '@module-federation/runtime': specifier: 2.0.0 version: 2.0.0 i18next: specifier: 25.7.4 - version: 25.7.4(typescript@5.9.3) + version: 25.7.4(typescript@6.0.3) react: specifier: ^19.2.6 version: 19.2.6 @@ -3051,7 +3115,7 @@ importers: version: 19.2.6(react@19.2.6) react-i18next: specifier: 15.7.4 - version: 15.7.4(i18next@25.7.4(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + version: 15.7.4(i18next@25.7.4(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3) devDependencies: '@modern-js/app-tools': specifier: workspace:* @@ -3302,7 +3366,7 @@ importers: version: 19.2.3(@types/react@19.2.14) ts-node: specifier: ^10.9.2 - version: 10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@20.19.27)(typescript@5.9.3) + version: 10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@20.19.27)(typescript@6.0.3) tests/integration/routes: dependencies: @@ -3372,6 +3436,74 @@ importers: specifier: ^5 version: 5.9.3 + tests/integration/routes-tanstack: + dependencies: + '@modern-js/plugin-tanstack': + specifier: workspace:* + version: link:../../../packages/runtime/plugin-tanstack + '@modern-js/runtime': + specifier: workspace:* + version: link:../../../packages/runtime/plugin-runtime + react: + specifier: ^19.2.6 + version: 19.2.6 + react-dom: + specifier: ^19.2.6 + version: 19.2.6(react@19.2.6) + devDependencies: + '@modern-js/app-tools': + specifier: workspace:* + version: link:../../../packages/solutions/app-tools + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + typescript: + specifier: 6.0.3 + version: 6.0.3 + + tests/integration/routes-tanstack-create-routes: + dependencies: + '@modern-js/plugin-tanstack': + specifier: workspace:* + version: link:../../../packages/runtime/plugin-tanstack + '@modern-js/runtime': + specifier: workspace:* + version: link:../../../packages/runtime/plugin-runtime + react: + specifier: ^19.2.6 + version: 19.2.6 + react-dom: + specifier: ^19.2.6 + version: 19.2.6(react@19.2.6) + devDependencies: + '@modern-js/app-tools': + specifier: workspace:* + version: link:../../../packages/solutions/app-tools + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + typescript: + specifier: 6.0.3 + version: 6.0.3 + tests/integration/rsbuild-hook: dependencies: '@modern-js/app-tools': @@ -4454,7 +4586,7 @@ importers: version: 10.2.19(@testing-library/dom@10.4.1)(bufferutil@4.1.0)(prettier@2.8.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(utf-8-validate@5.0.10) storybook-addon-modernjs: specifier: 3.3.3 - version: 3.3.3(@modern-js/app-tools@packages+solutions+app-tools)(@modern-js/plugin@3.1.5(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@rsbuild/core@2.0.0(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0))(storybook-builder-rsbuild@3.3.3(@rsbuild/core@2.0.0(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0))(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.2.19(@testing-library/dom@10.4.1)(bufferutil@4.1.0)(prettier@2.8.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3))(typescript@5.9.3) + version: 3.3.3(@modern-js/app-tools@packages+solutions+app-tools)(@modern-js/plugin@3.2.0(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@rsbuild/core@2.0.0(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0))(storybook-builder-rsbuild@3.3.3(@rsbuild/core@2.0.0(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0))(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.2.19(@testing-library/dom@10.4.1)(bufferutil@4.1.0)(prettier@2.8.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3))(typescript@5.9.3) storybook-react-rsbuild: specifier: 3.3.3 version: 3.3.3(@rsbuild/core@2.0.0(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0))(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(rollup@3.30.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(bufferutil@4.1.0)(prettier@2.8.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.27.2)) @@ -4552,7 +4684,7 @@ importers: version: 19.2.6(react@19.2.6) tailwindcss: specifier: ^2.2.19 - version: 2.2.19(autoprefixer@10.4.27(postcss@8.5.14))(postcss@8.5.14)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@25.6.0)(typescript@5.9.3)) + version: 2.2.19(autoprefixer@10.4.27(postcss@8.5.14))(postcss@8.5.14)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@25.6.0)(typescript@6.0.3)) tests/integration/tailwindcss/fixtures/tailwindcss-v3: dependencies: @@ -4573,7 +4705,7 @@ importers: version: 19.2.6(react@19.2.6) styled-components: specifier: ^5.3.1 - version: 5.3.11(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6) + version: 5.3.11(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6) tailwindcss: specifier: ^3.4.19 version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) @@ -5792,10 +5924,22 @@ packages: resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/diff-sequences@30.4.0': + resolution: {integrity: sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@30.4.1': + resolution: {integrity: sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/get-type@30.1.0': resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/pattern@30.4.0': + resolution: {integrity: sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/schemas@29.6.3': resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5804,10 +5948,18 @@ packages: resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/schemas@30.4.1': + resolution: {integrity: sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/types@29.6.3': resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/types@30.4.1': + resolution: {integrity: sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -6049,15 +6201,15 @@ packages: '@modern-js/plugin@2.70.4': resolution: {integrity: sha512-vqoioz6JbU+fIirmTMmbvHTYrWNM6IhMyOgZJ6kjHHeu/EdXyaYSp8X1DzPW7IXPWQavr0VuLq0E+Sav+G6HYg==} - '@modern-js/plugin@3.1.5': - resolution: {integrity: sha512-DRRNKwwQFSJKCIYZa/GJ8YfUAuz+HsE9q+3o8ojjPRy9RqKQpaidorijHwa8CIobDj98G+vri8eff2e78s/Zvw==} + '@modern-js/plugin@3.2.0': + resolution: {integrity: sha512-bKYQK9qfMIL2UtUHld8cNMh7EvQ1uocRFA30i1hFprgdZhWYMVfcq/nBb3HdsUFoycuJUeh+EFqTFrOXXkAESg==} '@modern-js/polyfill-lib@1.0.2': resolution: {integrity: sha512-UdBEpS0kwBYm43n60FEZFvZ3dUhqlzzvZkIMwiQGYHvlpXuAN/30GrWnp2WUdd5+eM5ObRCJ1bSJ7+UYouxPAQ==} engines: {node: '>=8'} - '@modern-js/runtime-utils@3.1.5': - resolution: {integrity: sha512-iJr2WiBpeLYFYV1eTNg2dC07jMacEs26EYueJ0ZsKCo1TyxtVW2L07k+rfqJZ6HURvzCRYoSywnEYjzuPaqxIQ==} + '@modern-js/runtime-utils@3.2.0': + resolution: {integrity: sha512-AIOGmRhsEwhWeNoo2a30yBG+7XvX/PdzAip5ImZryyHwEYkytwnEHZSr1tjzuZE4Ku0KFVcsbzi5sW7+udVFBw==} peerDependencies: react: '>=17.0.2' react-dom: '>=17.0.2' @@ -6127,17 +6279,17 @@ packages: '@modern-js/types@2.70.4': resolution: {integrity: sha512-P3Pe6S34GwJMQOYOHQx/mkcu8oEPwLU+Y3LwKslMNa43ooIgx0NEUzwl/N93bapw3LnoCQpwAOK0qQALaXnd9A==} - '@modern-js/types@3.1.5': - resolution: {integrity: sha512-mibM+7I9pxyFRp31UQ5/HI/wc88Y4WXZdkdEFviOnH0P0VW3AcaAI3cA9dFQDJ12mD4Nopy+RmOgJfEvMg4Zmw==} + '@modern-js/types@3.2.0': + resolution: {integrity: sha512-q7A7+yrrHm7nThKG+cLx0PZ8PuTpj1lbDxRYp10tp0V/TTbvWMVSYT0WVZ/E+rorbBke302ILIU1UOv2li/6+g==} '@modern-js/utils@2.70.4': resolution: {integrity: sha512-LQrwyGlFhsH2BmZxStF0TPeStm6aumf4N1J+ZyObLw5URrN4o8vCyeyqrPVciICeoTqhHg2GIArJWB5PXRcUig==} - '@modern-js/utils@3.1.5': - resolution: {integrity: sha512-s30hszKh5TpocXVzfOsPM+O8GyEyPveEB8+Zg3qNSFX/jcZHcUhzplBTjW1V67VvhveokabppqaQoVu2dydEBw==} + '@modern-js/utils@3.2.0': + resolution: {integrity: sha512-tEjNFL0rvnJ/f4jjiGxIHQAd0Lz8ihc2x0JoopeByLRZf1Ds7+DXLtezBP1H8o265epJPkvpQ74gJKRtyM2IRA==} peerDependencies: - react: ^19.2.4 - react-dom: ^19.2.4 + react: ^19.2.6 + react-dom: ^19.2.6 peerDependenciesMeta: react: optional: true @@ -6483,8 +6635,8 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@puppeteer/browsers@2.13.0': - resolution: {integrity: sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==} + '@puppeteer/browsers@2.13.2': + resolution: {integrity: sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==} engines: {node: '>=18'} hasBin: true @@ -7510,9 +7662,6 @@ packages: '@swc/helpers@0.5.17': resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} - '@swc/helpers@0.5.18': - resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} - '@swc/helpers@0.5.21': resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} @@ -7617,6 +7766,31 @@ packages: '@tailwindcss/postcss@4.1.18': resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + '@tanstack/history@1.161.6': + resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} + engines: {node: '>=20.19'} + + '@tanstack/react-router@1.168.26': + resolution: {integrity: sha512-+MV+U5KfMUQGZIU/x8MU3FMRSujxLs678v2jhu1Y8P9ndQBKLVOBYKFY+vv/ypxBUYiyDiOsZkDxPJC8UPo/Ig==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.168.18': + resolution: {integrity: sha512-rheeg/+hIHSVw9IDzcc5NJlKamKtKJN/c8rPG9XEmLwHvA4C1WRN/yjMTGgoGNU0xKKjL2AzvUhYMSaBdelbEA==} + engines: {node: '>=20.19'} + hasBin: true + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -7916,6 +8090,9 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/jest@30.0.0': + resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} @@ -8056,6 +8233,9 @@ packages: '@types/signale@1.4.7': resolution: {integrity: sha512-nc0j37QupTT7OcYeH3gRE1ZfzUalEUsDKJsJ3IsJr0pjjFZTjtrX1Bsn6Kv56YXI/H9rNSwAkIPRxNlZI8GyQw==} + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/styled-components@5.1.36': resolution: {integrity: sha512-pGMRNY5G2rNDKEv2DOiFYa7Ft1r0jrhmgBwHhOMzPTgCjO76bCot0/4uEfqj7K0Jf1KdQmDtAuaDk9EAs9foSw==} @@ -8879,6 +9059,10 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + cipher-base@1.0.7: resolution: {integrity: sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==} engines: {node: '>= 0.10'} @@ -9105,6 +9289,9 @@ packages: cookie-es@1.2.3: resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} @@ -9166,15 +9353,6 @@ packages: typescript: optional: true - cosmiconfig@9.0.0: - resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} - engines: {node: '>=14'} - peerDependencies: - typescript: '>=4.9.5' - peerDependenciesMeta: - typescript: - optional: true - cosmiconfig@9.0.1: resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} engines: {node: '>=14'} @@ -9683,8 +9861,8 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - devtools-protocol@0.0.1581282: - resolution: {integrity: sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==} + devtools-protocol@0.0.1608973: + resolution: {integrity: sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==} dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} @@ -9948,6 +10126,10 @@ packages: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -10060,6 +10242,10 @@ packages: resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} engines: {node: '>=0.10.0'} + expect@30.4.1: + resolution: {integrity: sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + express@4.22.1: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} @@ -11059,6 +11245,10 @@ packages: resolution: {integrity: sha512-vne1mzQUTR+qsMLeCBL9+/tgnDXRyc2pygLGl/WsgA+EZKIiB5Ehu0CiVTHIIk30zhJ24uGz4M5Ppse37aR0Hg==} engines: {node: '>=12'} + isbot@5.1.40: + resolution: {integrity: sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -11086,10 +11276,34 @@ packages: resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-diff@30.4.1: + resolution: {integrity: sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-matcher-utils@30.4.1: + resolution: {integrity: sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@30.4.1: + resolution: {integrity: sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-mock@30.4.1: + resolution: {integrity: sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-regex-util@30.4.0: + resolution: {integrity: sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-util@29.7.0: resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-util@30.4.1: + resolution: {integrity: sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -12875,6 +13089,10 @@ packages: resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + pretty-format@30.4.1: + resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + pretty-hrtime@1.0.3: resolution: {integrity: sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==} engines: {node: '>= 0.8'} @@ -12941,12 +13159,12 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - puppeteer-core@24.39.1: - resolution: {integrity: sha512-AMqQIKoEhPS6CilDzw0Gd1brLri3emkC+1N2J6ZCCuY1Cglo56M63S0jOeBZDQlemOiRd686MYVMl9ELJBzN3A==} + puppeteer-core@24.43.1: + resolution: {integrity: sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==} engines: {node: '>=18'} - puppeteer@24.39.1: - resolution: {integrity: sha512-68Zc9QpcVvfxp2C+3UL88TyUogEAn5tSylXidbEuEXvhiqK1+v65zeBU5ubinAgEHMGr3dcSYqvYrGtdzsPI3w==} + puppeteer@24.43.1: + resolution: {integrity: sha512-/FSOViCrqRdb1HDocpsM9Z1giA71gTQPUt3SpHGVRALKAy/rJr1fLFYZW9F23qPxqVxTHQnbh/5B5opJST3kAw==} engines: {node: '>=18'} hasBin: true @@ -13285,6 +13503,9 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-is@19.2.6: + resolution: {integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==} + react-lazy-with-preload@2.2.1: resolution: {integrity: sha512-ONSb8gizLE5jFpdHAclZ6EAAKuFX2JydnFXPPPjoUImZlLjGtKzyBS8SJgJq7CpLgsGKh9QCZdugJyEEOVC16Q==} @@ -13814,6 +14035,16 @@ packages: serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.4: + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} + engines: {node: '>=10'} + serve-static@1.16.3: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} @@ -13992,6 +14223,10 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -14286,10 +14521,6 @@ packages: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} - engines: {node: '>=6'} - tapable@2.3.2: resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} @@ -14566,8 +14797,8 @@ packages: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} - typed-query-selector@2.12.1: - resolution: {integrity: sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==} + typed-query-selector@2.12.2: + resolution: {integrity: sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==} typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} @@ -14582,6 +14813,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + ua-parser-js@0.7.41: resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==} hasBin: true @@ -15965,11 +16201,11 @@ snapshots: react-dom: 19.2.6(react@19.2.6) react-is: 17.0.2 - '@commitlint/cli@20.5.3(@types/node@25.6.0)(conventional-commits-parser@6.3.0)(typescript@5.9.3)': + '@commitlint/cli@20.5.3(@types/node@25.6.0)(conventional-commits-parser@6.3.0)(typescript@6.0.3)': dependencies: '@commitlint/format': 20.5.0 '@commitlint/lint': 20.5.3 - '@commitlint/load': 20.5.3(@types/node@25.6.0)(typescript@5.9.3) + '@commitlint/load': 20.5.3(@types/node@25.6.0)(typescript@6.0.3) '@commitlint/read': 20.5.0(conventional-commits-parser@6.3.0) '@commitlint/types': 20.5.0 tinyexec: 1.0.2 @@ -16014,14 +16250,14 @@ snapshots: '@commitlint/rules': 20.5.3 '@commitlint/types': 20.5.0 - '@commitlint/load@20.5.3(@types/node@25.6.0)(typescript@5.9.3)': + '@commitlint/load@20.5.3(@types/node@25.6.0)(typescript@6.0.3)': dependencies: '@commitlint/config-validator': 20.5.0 '@commitlint/execute-rule': 20.0.0 '@commitlint/resolve-extends': 20.5.3 '@commitlint/types': 20.5.0 - cosmiconfig: 9.0.1(typescript@5.9.3) - cosmiconfig-typescript-loader: 6.2.0(@types/node@25.6.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) + cosmiconfig: 9.0.1(typescript@6.0.3) + cosmiconfig-typescript-loader: 6.2.0(@types/node@25.6.0)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3) es-toolkit: 1.46.1 is-plain-obj: 4.1.0 picocolors: 1.1.1 @@ -16417,8 +16653,19 @@ snapshots: '@jest/diff-sequences@30.0.1': {} + '@jest/diff-sequences@30.4.0': {} + + '@jest/expect-utils@30.4.1': + dependencies: + '@jest/get-type': 30.1.0 + '@jest/get-type@30.1.0': {} + '@jest/pattern@30.4.0': + dependencies: + '@types/node': 25.6.0 + jest-regex-util: 30.4.0 + '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.8 @@ -16427,12 +16674,26 @@ snapshots: dependencies: '@sinclair/typebox': 0.34.41 + '@jest/schemas@30.4.1': + dependencies: + '@sinclair/typebox': 0.34.41 + '@jest/types@29.6.3': dependencies: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.19.27 + '@types/node': 25.6.0 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jest/types@30.4.1': + dependencies: + '@jest/pattern': 30.4.0 + '@jest/schemas': 30.4.1 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 25.6.0 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -16759,7 +17020,7 @@ snapshots: '@modern-js/node-bundle-require': 2.70.4 '@modern-js/plugin': 2.70.4 '@modern-js/utils': 2.70.4 - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.21 '@modern-js/module-tools@2.70.4(@types/node@20.19.27)(typescript@5.9.3)': dependencies: @@ -16771,11 +17032,11 @@ snapshots: '@modern-js/plugin': 2.70.4 '@modern-js/plugin-changeset': 2.70.4(@types/node@20.19.27) '@modern-js/plugin-i18n': 2.70.4 - '@modern-js/swc-plugins': 0.6.11(@swc/helpers@0.5.18) + '@modern-js/swc-plugins': 0.6.11(@swc/helpers@0.5.21) '@modern-js/types': 2.70.4 '@modern-js/utils': 2.70.4 '@rollup/pluginutils': 4.2.1 - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.21 convert-source-map: 1.9.0 enhanced-resolve: 5.17.1 esbuild: 0.27.2 @@ -16826,11 +17087,11 @@ snapshots: '@modern-js/utils': 2.70.4 '@swc/helpers': 0.5.21 - '@modern-js/plugin@3.1.5(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@modern-js/plugin@3.2.0(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@modern-js/runtime-utils': 3.1.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@modern-js/types': 3.1.5 - '@modern-js/utils': 3.1.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@modern-js/runtime-utils': 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@modern-js/types': 3.2.0 + '@modern-js/utils': 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@rsbuild/core': 2.0.0(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0) '@swc/helpers': 0.5.21 jiti: 2.7.0 @@ -16889,10 +17150,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@modern-js/runtime-utils@3.1.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@modern-js/runtime-utils@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@modern-js/types': 3.1.5 - '@modern-js/utils': 3.1.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@modern-js/types': 3.2.0 + '@modern-js/utils': 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@swc/helpers': 0.5.21 lru-cache: 10.4.3 react-router: 7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -16926,7 +17187,7 @@ snapshots: '@modern-js/swc-plugins-win32-x64-msvc@0.6.11': optional: true - '@modern-js/swc-plugins@0.6.11(@swc/helpers@0.5.18)': + '@modern-js/swc-plugins@0.6.11(@swc/helpers@0.5.21)': optionalDependencies: '@modern-js/swc-plugins-darwin-arm64': 0.6.11 '@modern-js/swc-plugins-darwin-x64': 0.6.11 @@ -16936,11 +17197,11 @@ snapshots: '@modern-js/swc-plugins-linux-x64-musl': 0.6.11 '@modern-js/swc-plugins-win32-arm64-msvc': 0.6.11 '@modern-js/swc-plugins-win32-x64-msvc': 0.6.11 - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.21 '@modern-js/types@2.70.4': {} - '@modern-js/types@3.1.5': + '@modern-js/types@3.2.0': optional: true '@modern-js/utils@2.70.4': @@ -16950,7 +17211,7 @@ snapshots: lodash: 4.18.1 rslog: 1.3.2 - '@modern-js/utils@3.1.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@modern-js/utils@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@swc/helpers': 0.5.21 caniuse-lite: 1.0.30001791 @@ -16983,9 +17244,9 @@ snapshots: '@module-federation/bridge-shared@2.0.0': {} - '@module-federation/cli@2.0.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)': + '@module-federation/cli@2.0.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)': dependencies: - '@module-federation/dts-plugin': 2.0.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@module-federation/dts-plugin': 2.0.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) '@module-federation/sdk': 2.0.0 chalk: 3.0.0 commander: 11.1.0 @@ -17007,7 +17268,7 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - '@module-federation/dts-plugin@2.0.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)': + '@module-federation/dts-plugin@2.0.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)': dependencies: '@module-federation/error-codes': 2.0.0 '@module-federation/managers': 2.0.0 @@ -17024,7 +17285,7 @@ snapshots: log4js: 6.9.1 node-schedule: 2.1.1 rambda: 9.4.2 - typescript: 5.9.3 + typescript: 6.0.3 ws: 8.18.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil @@ -17032,24 +17293,24 @@ snapshots: - supports-color - utf-8-validate - '@module-federation/enhanced@2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21)))': + '@module-federation/enhanced@2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21)))': dependencies: '@module-federation/bridge-react-webpack-plugin': 2.0.0 - '@module-federation/cli': 2.0.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@module-federation/cli': 2.0.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) '@module-federation/data-prefetch': 2.0.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@module-federation/dts-plugin': 2.0.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@module-federation/dts-plugin': 2.0.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) '@module-federation/error-codes': 2.0.0 '@module-federation/inject-external-runtime-core-plugin': 2.0.0(@module-federation/runtime-tools@2.0.0) '@module-federation/managers': 2.0.0 - '@module-federation/manifest': 2.0.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@module-federation/rspack': 2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@module-federation/manifest': 2.0.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) + '@module-federation/rspack': 2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) '@module-federation/runtime-tools': 2.0.0 '@module-federation/sdk': 2.0.0 btoa: 1.2.1 schema-utils: 4.3.3 upath: 2.0.1 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 webpack: 5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21)) transitivePeerDependencies: - '@rspack/core' @@ -17072,9 +17333,9 @@ snapshots: find-pkg: 2.0.0 fs-extra: 9.1.0 - '@module-federation/manifest@2.0.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)': + '@module-federation/manifest@2.0.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)': dependencies: - '@module-federation/dts-plugin': 2.0.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@module-federation/dts-plugin': 2.0.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) '@module-federation/managers': 2.0.0 '@module-federation/sdk': 2.0.0 chalk: 3.0.0 @@ -17087,16 +17348,16 @@ snapshots: - utf-8-validate - vue-tsc - '@module-federation/modern-js-v3@2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react-router-dom@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-router@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21)))': + '@module-federation/modern-js-v3@2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react-router-dom@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-router@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21)))': dependencies: '@module-federation/bridge-react': 2.0.0(react-dom@19.2.6(react@19.2.6))(react-router-dom@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-router@7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) - '@module-federation/cli': 2.0.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) - '@module-federation/enhanced': 2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) - '@module-federation/node': 2.7.31(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) - '@module-federation/rsbuild-plugin': 2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) + '@module-federation/cli': 2.0.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) + '@module-federation/enhanced': 2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) + '@module-federation/node': 2.7.31(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) + '@module-federation/rsbuild-plugin': 2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) '@module-federation/runtime': 2.0.0 '@module-federation/sdk': 2.0.0 - '@swc/helpers': 0.5.18 + '@swc/helpers': 0.5.21 fs-extra: 11.3.0 jiti: 2.4.2 lru-cache: 10.4.3 @@ -17107,7 +17368,7 @@ snapshots: optionalDependencies: react-router: 7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-router-dom: 7.13.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - '@rsbuild/core' - '@rspack/core' @@ -17117,9 +17378,9 @@ snapshots: - utf-8-validate - webpack - '@module-federation/node@2.7.31(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21)))': + '@module-federation/node@2.7.31(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21)))': dependencies: - '@module-federation/enhanced': 2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) + '@module-federation/enhanced': 2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) '@module-federation/runtime': 2.0.0 '@module-federation/sdk': 2.0.0 btoa: 1.2.1 @@ -17138,10 +17399,10 @@ snapshots: - utf-8-validate - vue-tsc - '@module-federation/rsbuild-plugin@2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21)))': + '@module-federation/rsbuild-plugin@2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21)))': dependencies: - '@module-federation/enhanced': 2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) - '@module-federation/node': 2.7.31(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) + '@module-federation/enhanced': 2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) + '@module-federation/node': 2.7.31(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@5.0.10)(webpack@5.104.1(@swc/core@1.15.33(@swc/helpers@0.5.21))) '@module-federation/sdk': 2.0.0 fs-extra: 11.3.0 transitivePeerDependencies: @@ -17156,19 +17417,19 @@ snapshots: - vue-tsc - webpack - '@module-federation/rspack@2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)': + '@module-federation/rspack@2.0.0(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)': dependencies: '@module-federation/bridge-react-webpack-plugin': 2.0.0 - '@module-federation/dts-plugin': 2.0.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@module-federation/dts-plugin': 2.0.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) '@module-federation/inject-external-runtime-core-plugin': 2.0.0(@module-federation/runtime-tools@2.0.0) '@module-federation/managers': 2.0.0 - '@module-federation/manifest': 2.0.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10) + '@module-federation/manifest': 2.0.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10) '@module-federation/runtime-tools': 2.0.0 '@module-federation/sdk': 2.0.0 '@rspack/core': 2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21) btoa: 1.2.1 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - bufferutil - debug @@ -17371,7 +17632,7 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@puppeteer/browsers@2.13.0': + '@puppeteer/browsers@2.13.2': dependencies: debug: 4.4.3(supports-color@5.5.0) extract-zip: 2.0.1 @@ -18610,10 +18871,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@swc/helpers@0.5.18': - dependencies: - tslib: 2.8.1 - '@swc/helpers@0.5.21': dependencies: tslib: 2.8.1 @@ -18703,6 +18960,33 @@ snapshots: postcss: 8.5.14 tailwindcss: 4.1.18 + '@tanstack/history@1.161.6': {} + + '@tanstack/react-router@1.168.26(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/history': 1.161.6 + '@tanstack/react-store': 0.9.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/router-core': 1.168.18 + isbot: 5.1.40 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@tanstack/react-store@0.9.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + use-sync-external-store: 1.6.0(react@19.2.6) + + '@tanstack/router-core@1.168.18': + dependencies: + '@tanstack/history': 1.161.6 + cookie-es: 3.1.1 + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) + + '@tanstack/store@0.9.3': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.27.1 @@ -18763,7 +19047,7 @@ snapshots: '@types/accepts@1.3.7': dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/aria-query@5.0.4': {} @@ -18791,13 +19075,13 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.0.4 '@types/keyv': 3.1.4 - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/responselike': 1.0.3 '@types/chai@5.2.3': @@ -18807,18 +19091,18 @@ snapshots: '@types/cloneable-readable@2.0.3': dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/configstore@2.1.1': {} '@types/connect-history-api-fallback@1.5.4': dependencies: '@types/express-serve-static-core': 5.1.0 - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/connect@3.4.38': dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/content-disposition@0.5.9': {} @@ -18831,11 +19115,11 @@ snapshots: '@types/connect': 3.4.38 '@types/express': 4.17.25 '@types/keygrip': 1.0.6 - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/cors@2.8.19': dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/d3-array@3.2.2': {} @@ -18984,14 +19268,14 @@ snapshots: '@types/express-serve-static-core@4.19.7': dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/qs': 6.15.1 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 '@types/express-serve-static-core@5.1.0': dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/qs': 6.15.1 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -19014,12 +19298,12 @@ snapshots: '@types/glob@5.0.38': dependencies: '@types/minimatch': 3.0.5 - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/glob@7.2.0': dependencies: '@types/minimatch': 3.0.5 - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/hast@3.0.4': dependencies: @@ -19040,7 +19324,7 @@ snapshots: '@types/http-proxy@1.17.17': dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/inquirer@8.2.12': dependencies: @@ -19063,6 +19347,11 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 + '@types/jest@30.0.0': + dependencies: + expect: 30.4.1 + pretty-format: 30.2.0 + '@types/js-yaml@4.0.9': {} '@types/json-schema@7.0.15': {} @@ -19073,7 +19362,7 @@ snapshots: '@types/keyv@3.1.4': dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/koa-compose@3.2.9': dependencies: @@ -19088,7 +19377,7 @@ snapshots: '@types/http-errors': 2.0.5 '@types/keygrip': 1.0.6 '@types/koa-compose': 3.2.9 - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/less@3.0.8': {} @@ -19128,7 +19417,7 @@ snapshots: '@types/mkdirp@0.5.2': dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/ms@2.1.0': {} @@ -19166,18 +19455,18 @@ snapshots: '@types/recursive-readdir@2.2.4': dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/resolve@1.20.6': {} '@types/responselike@1.0.3': dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/rimraf@2.0.5': dependencies: '@types/glob': 7.2.0 - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/semver@7.5.8': {} @@ -19186,25 +19475,27 @@ snapshots: '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/send@1.2.1': dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/serialize-javascript@5.0.4': {} '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/send': 0.17.6 '@types/signal-exit@3.0.4': {} '@types/signale@1.4.7': dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 + + '@types/stack-utils@2.0.3': {} '@types/styled-components@5.1.36': dependencies: @@ -19216,7 +19507,7 @@ snapshots: dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 20.19.27 + '@types/node': 25.6.0 form-data: 4.0.5 '@types/supertest@2.0.16': @@ -19225,11 +19516,11 @@ snapshots: '@types/tapable@2.3.0': dependencies: - tapable: 2.3.2 + tapable: 2.3.3 '@types/through@0.0.33': dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/tmp@0.0.33': {} @@ -19252,7 +19543,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/yargs-parser@21.0.3': {} @@ -19262,7 +19553,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 optional: true '@ungap/structured-clone@1.3.0': {} @@ -19810,6 +20101,18 @@ snapshots: - '@babel/core' - supports-color + babel-plugin-styled-components@2.1.4(@babel/core@7.29.0)(styled-components@5.3.11(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6))(supports-color@5.5.0): + dependencies: + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-module-imports': 7.28.6(supports-color@5.5.0) + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) + lodash: 4.18.1 + picomatch: 2.3.1 + styled-components: 5.3.11(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6) + transitivePeerDependencies: + - '@babel/core' + - supports-color + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -20175,14 +20478,16 @@ snapshots: chrome-trace-event@1.0.4: {} - chromium-bidi@14.0.0(devtools-protocol@0.0.1581282): + chromium-bidi@14.0.0(devtools-protocol@0.0.1608973): dependencies: - devtools-protocol: 0.0.1581282 + devtools-protocol: 0.0.1608973 mitt: 3.0.1 zod: 3.25.76 ci-info@3.9.0: {} + ci-info@4.4.0: {} + cipher-base@1.0.7: dependencies: inherits: 2.0.4 @@ -20373,6 +20678,8 @@ snapshots: cookie-es@1.2.3: {} + cookie-es@3.1.1: {} + cookie-signature@1.0.7: {} cookie@0.7.2: {} @@ -20411,12 +20718,12 @@ snapshots: dependencies: layout-base: 2.0.1 - cosmiconfig-typescript-loader@6.2.0(@types/node@25.6.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): + cosmiconfig-typescript-loader@6.2.0(@types/node@25.6.0)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3): dependencies: '@types/node': 25.6.0 - cosmiconfig: 9.0.1(typescript@5.9.3) + cosmiconfig: 9.0.1(typescript@6.0.3) jiti: 2.6.1 - typescript: 5.9.3 + typescript: 6.0.3 cosmiconfig@7.1.0: dependencies: @@ -20435,7 +20742,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - cosmiconfig@9.0.0(typescript@5.9.3): + cosmiconfig@9.0.1(typescript@5.9.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 @@ -20444,14 +20751,14 @@ snapshots: optionalDependencies: typescript: 5.9.3 - cosmiconfig@9.0.1(typescript@5.9.3): + cosmiconfig@9.0.1(typescript@6.0.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 js-yaml: 4.1.1 parse-json: 5.2.0 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 create-ecdh@4.0.4: dependencies: @@ -21031,7 +21338,7 @@ snapshots: dependencies: dequal: 2.0.3 - devtools-protocol@0.0.1581282: {} + devtools-protocol@0.0.1608973: {} dezalgo@1.0.4: dependencies: @@ -21185,7 +21492,7 @@ snapshots: engine.io@6.6.4(bufferutil@4.1.0)(utf-8-validate@5.0.10): dependencies: '@types/cors': 2.8.19 - '@types/node': 20.19.27 + '@types/node': 25.6.0 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 @@ -21201,12 +21508,12 @@ snapshots: enhanced-resolve@5.17.1: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.0 + tapable: 2.3.3 enhanced-resolve@5.19.0: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.0 + tapable: 2.3.3 enquirer@2.3.6: dependencies: @@ -21336,6 +21643,8 @@ snapshots: escape-string-regexp@1.0.5: {} + escape-string-regexp@2.0.0: {} + escape-string-regexp@5.0.0: {} escodegen@2.1.0: @@ -21488,6 +21797,15 @@ snapshots: dependencies: homedir-polyfill: 1.0.3 + expect@30.4.1: + dependencies: + '@jest/expect-utils': 30.4.1 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-util: 30.4.1 + express@4.22.1: dependencies: accepts: 1.3.8 @@ -22037,7 +22355,7 @@ snapshots: happy-dom@20.8.9(bufferutil@4.1.0)(utf-8-validate@5.0.10): dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -22383,6 +22701,12 @@ snapshots: optionalDependencies: typescript: 5.9.3 + i18next@25.7.4(typescript@6.0.3): + dependencies: + '@babel/runtime': 7.28.4 + optionalDependencies: + typescript: 6.0.3 + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -22703,6 +23027,8 @@ snapshots: isbot@3.8.0: {} + isbot@5.1.40: {} + isexe@2.0.0: {} isobject@3.0.1: {} @@ -22732,24 +23058,68 @@ snapshots: chalk: 4.1.2 pretty-format: 30.2.0 + jest-diff@30.4.1: + dependencies: + '@jest/diff-sequences': 30.4.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.4.1 + + jest-matcher-utils@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.4.1 + pretty-format: 30.4.1 + + jest-message-util@30.4.1: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 30.4.1 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-util: 30.4.1 + picomatch: 4.0.3 + pretty-format: 30.4.1 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 25.6.0 + jest-util: 30.4.1 + + jest-regex-util@30.4.0: {} + jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.19.27 + '@types/node': 25.6.0 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 picomatch: 2.3.1 + jest-util@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 25.6.0 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.3 + jest-worker@27.5.1: dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@29.7.0: dependencies: - '@types/node': 20.19.27 + '@types/node': 25.6.0 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -24502,13 +24872,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.14 - postcss-load-config@3.1.4(postcss@8.5.14)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@25.6.0)(typescript@5.9.3)): + postcss-load-config@3.1.4(postcss@8.5.14)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@25.6.0)(typescript@6.0.3)): dependencies: lilconfig: 2.1.0 yaml: 1.10.2 optionalDependencies: postcss: 8.5.14 - ts-node: 10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@25.6.0)(typescript@5.9.3) + ts-node: 10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@25.6.0)(typescript@6.0.3) postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.14)(tsx@4.21.0)(yaml@2.8.2): dependencies: @@ -24836,6 +25206,13 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + pretty-format@30.4.1: + dependencies: + '@jest/schemas': 30.4.1 + ansi-styles: 5.2.0 + react-is-18: react-is@18.3.1 + react-is-19: react-is@19.2.6 + pretty-hrtime@1.0.3: {} pretty-ms@9.3.0: @@ -24908,13 +25285,13 @@ snapshots: punycode@2.3.1: {} - puppeteer-core@24.39.1(bufferutil@4.1.0)(utf-8-validate@5.0.10): + puppeteer-core@24.43.1(bufferutil@4.1.0)(utf-8-validate@5.0.10): dependencies: - '@puppeteer/browsers': 2.13.0 - chromium-bidi: 14.0.0(devtools-protocol@0.0.1581282) + '@puppeteer/browsers': 2.13.2 + chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) debug: 4.4.3(supports-color@5.5.0) - devtools-protocol: 0.0.1581282 - typed-query-selector: 2.12.1 + devtools-protocol: 0.0.1608973 + typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) transitivePeerDependencies: @@ -24925,14 +25302,14 @@ snapshots: - supports-color - utf-8-validate - puppeteer@24.39.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10): + puppeteer@24.43.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10): dependencies: - '@puppeteer/browsers': 2.13.0 - chromium-bidi: 14.0.0(devtools-protocol@0.0.1581282) - cosmiconfig: 9.0.0(typescript@5.9.3) - devtools-protocol: 0.0.1581282 - puppeteer-core: 24.39.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) - typed-query-selector: 2.12.1 + '@puppeteer/browsers': 2.13.2 + chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) + cosmiconfig: 9.0.1(typescript@5.9.3) + devtools-protocol: 0.0.1608973 + puppeteer-core: 24.43.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) + typed-query-selector: 2.12.2 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -25368,12 +25745,24 @@ snapshots: react-dom: 19.2.6(react@19.2.6) typescript: 5.9.3 + react-i18next@15.7.4(i18next@25.7.4(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3): + dependencies: + '@babel/runtime': 7.28.4 + html-parse-stringify: 3.0.1 + i18next: 25.7.4(typescript@6.0.3) + react: 19.2.6 + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + typescript: 6.0.3 + react-is@16.13.1: {} react-is@17.0.2: {} react-is@18.3.1: {} + react-is@19.2.6: {} + react-lazy-with-preload@2.2.1: {} react-reconciler@0.33.0(react@19.2.6): @@ -25920,6 +26309,12 @@ snapshots: dependencies: randombytes: 2.1.0 + seroval-plugins@1.5.4(seroval@1.5.4): + dependencies: + seroval: 1.5.4 + + seroval@1.5.4: {} + serve-static@1.16.3: dependencies: encodeurl: 2.0.0 @@ -26170,6 +26565,10 @@ snapshots: sprintf-js@1.1.3: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackframe@1.3.4: {} standard-as-callback@2.1.0: {} @@ -26187,7 +26586,7 @@ snapshots: std-env@3.10.0: {} - storybook-addon-modernjs@3.3.3(@modern-js/app-tools@packages+solutions+app-tools)(@modern-js/plugin@3.1.5(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@rsbuild/core@2.0.0(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0))(storybook-builder-rsbuild@3.3.3(@rsbuild/core@2.0.0(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0))(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.2.19(@testing-library/dom@10.4.1)(bufferutil@4.1.0)(prettier@2.8.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3))(typescript@5.9.3): + storybook-addon-modernjs@3.3.3(@modern-js/app-tools@packages+solutions+app-tools)(@modern-js/plugin@3.2.0(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@rsbuild/core@2.0.0(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0))(storybook-builder-rsbuild@3.3.3(@rsbuild/core@2.0.0(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0))(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.2.19(@testing-library/dom@10.4.1)(bufferutil@4.1.0)(prettier@2.8.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3))(typescript@5.9.3): dependencies: '@modern-js/app-tools': link:packages/solutions/app-tools '@rsbuild/core': 2.0.0(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0) @@ -26195,7 +26594,7 @@ snapshots: rslog: 1.3.2 storybook-builder-rsbuild: 3.3.3(@rsbuild/core@2.0.0(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0))(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.2.19(@testing-library/dom@10.4.1)(bufferutil@4.1.0)(prettier@2.8.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3) optionalDependencies: - '@modern-js/plugin': 3.1.5(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@modern-js/plugin': 3.2.0(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) typescript: 5.9.3 storybook-builder-rsbuild@3.3.3(@rsbuild/core@2.0.0(@module-federation/runtime-tools@2.0.0)(core-js@3.48.0))(@rspack/core@2.0.2(@module-federation/runtime-tools@2.0.0)(@swc/helpers@0.5.21))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.2.19(@testing-library/dom@10.4.1)(bufferutil@4.1.0)(prettier@2.8.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(utf-8-validate@5.0.10))(tslib@2.8.1)(typescript@5.9.3): @@ -26406,6 +26805,24 @@ snapshots: transitivePeerDependencies: - '@babel/core' + styled-components@5.3.11(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6): + dependencies: + '@babel/helper-module-imports': 7.27.1(supports-color@5.5.0) + '@babel/traverse': 7.28.5(supports-color@5.5.0) + '@emotion/is-prop-valid': 1.4.0 + '@emotion/stylis': 0.8.5 + '@emotion/unitless': 0.7.5 + babel-plugin-styled-components: 2.1.4(@babel/core@7.29.0)(styled-components@5.3.11(@babel/core@7.29.0)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6))(supports-color@5.5.0) + css-to-react-native: 3.2.0 + hoist-non-react-statics: 3.3.2 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-is: 19.2.6 + shallowequal: 1.1.0 + supports-color: 5.5.0 + transitivePeerDependencies: + - '@babel/core' + stylehacks@6.1.1(postcss@8.5.14): dependencies: browserslist: 4.28.2 @@ -26518,7 +26935,7 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - tailwindcss@2.2.19(autoprefixer@10.4.27(postcss@8.5.14))(postcss@8.5.14)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@25.6.0)(typescript@5.9.3)): + tailwindcss@2.2.19(autoprefixer@10.4.27(postcss@8.5.14))(postcss@8.5.14)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@25.6.0)(typescript@6.0.3)): dependencies: arg: 5.0.2 autoprefixer: 10.4.27(postcss@8.5.14) @@ -26544,7 +26961,7 @@ snapshots: object-hash: 2.2.0 postcss: 8.5.14 postcss-js: 3.0.3 - postcss-load-config: 3.1.4(postcss@8.5.14)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@25.6.0)(typescript@5.9.3)) + postcss-load-config: 3.1.4(postcss@8.5.14)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@25.6.0)(typescript@6.0.3)) postcss-nested: 5.0.6(postcss@8.5.14) postcss-selector-parser: 6.1.2 postcss-value-parser: 4.2.0 @@ -26589,8 +27006,6 @@ snapshots: tapable@2.2.1: {} - tapable@2.3.0: {} - tapable@2.3.2: {} tapable@2.3.3: {} @@ -26832,6 +27247,26 @@ snapshots: optionalDependencies: '@swc/core': 1.15.33(@swc/helpers@0.5.21) + ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@20.19.27)(typescript@6.0.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 20.19.27 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 8.0.3 + make-error: 1.3.6 + typescript: 6.0.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.15.33(@swc/helpers@0.5.21) + ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@25.6.0)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -26852,6 +27287,27 @@ snapshots: optionalDependencies: '@swc/core': 1.15.33(@swc/helpers@0.5.21) + ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@25.6.0)(typescript@6.0.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 25.6.0 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 8.0.3 + make-error: 1.3.6 + typescript: 6.0.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.15.33(@swc/helpers@0.5.21) + optional: true + tsconfig-paths-webpack-plugin@4.1.0: dependencies: chalk: 4.1.2 @@ -26937,7 +27393,7 @@ snapshots: es-errors: 1.3.0 is-typed-array: 1.1.15 - typed-query-selector@2.12.1: {} + typed-query-selector@2.12.2: {} typedarray-to-buffer@3.1.5: dependencies: @@ -26947,6 +27403,8 @@ snapshots: typescript@5.9.3: {} + typescript@6.0.3: {} + ua-parser-js@0.7.41: {} ufo@1.6.1: {} diff --git a/tests/integration/routes-tanstack-create-routes/modern.config.ts b/tests/integration/routes-tanstack-create-routes/modern.config.ts new file mode 100644 index 000000000000..9a0a3dcf7b59 --- /dev/null +++ b/tests/integration/routes-tanstack-create-routes/modern.config.ts @@ -0,0 +1,19 @@ +import { appTools, defineConfig } from '@modern-js/app-tools'; +import { tanstackRouterPlugin } from '@modern-js/plugin-tanstack'; + +export default defineConfig({ + plugins: [appTools(), tanstackRouterPlugin()], + output: { + polyfill: 'off', + disableTsChecker: true, + minify: false, + }, + server: { + ssr: { + mode: 'string', + }, + }, + performance: { + buildCache: false, + }, +}); diff --git a/tests/integration/routes-tanstack-create-routes/package.json b/tests/integration/routes-tanstack-create-routes/package.json new file mode 100644 index 000000000000..b519a84352b4 --- /dev/null +++ b/tests/integration/routes-tanstack-create-routes/package.json @@ -0,0 +1,24 @@ +{ + "private": true, + "name": "routes-tanstack-create-routes", + "version": "2.66.0", + "scripts": { + "dev": "modern dev", + "build": "modern build", + "serve": "modern serve" + }, + "dependencies": { + "@modern-js/plugin-tanstack": "workspace:*", + "@modern-js/runtime": "workspace:*", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*", + "@types/jest": "^30.0.0", + "@types/node": "^25.6.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "typescript": "6.0.3" + } +} diff --git a/tests/integration/routes-tanstack-create-routes/src/App.tsx b/tests/integration/routes-tanstack-create-routes/src/App.tsx new file mode 100644 index 000000000000..38baa307fa93 --- /dev/null +++ b/tests/integration/routes-tanstack-create-routes/src/App.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from 'react'; + +export default function App({ children }: { children?: ReactNode }) { + return <>{children}; +} diff --git a/tests/integration/routes-tanstack-create-routes/src/modern-app-env.d.ts b/tests/integration/routes-tanstack-create-routes/src/modern-app-env.d.ts new file mode 100644 index 000000000000..1e851dcf7213 --- /dev/null +++ b/tests/integration/routes-tanstack-create-routes/src/modern-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tests/integration/routes-tanstack-create-routes/src/modern-tanstack/register.gen.d.ts b/tests/integration/routes-tanstack-create-routes/src/modern-tanstack/register.gen.d.ts new file mode 100644 index 000000000000..f466022f2243 --- /dev/null +++ b/tests/integration/routes-tanstack-create-routes/src/modern-tanstack/register.gen.d.ts @@ -0,0 +1,9 @@ +// This file is auto-generated by Modern.js. Do not edit manually. + +import type { router as router0 } from './index/router.gen'; + +declare module '@modern-js/plugin-tanstack/runtime' { + interface Register { + router: typeof router0; + } +} diff --git a/tests/integration/routes-tanstack-create-routes/src/modern.runtime.tsx b/tests/integration/routes-tanstack-create-routes/src/modern.runtime.tsx new file mode 100644 index 000000000000..aa3cc5bb779c --- /dev/null +++ b/tests/integration/routes-tanstack-create-routes/src/modern.runtime.tsx @@ -0,0 +1,77 @@ +import { Link, Outlet, useMatch } from '@modern-js/plugin-tanstack/runtime'; +import { defineRuntimeConfig, type RuntimePlugin } from '@modern-js/runtime'; +import type { RouteObject } from '@modern-js/runtime/router'; + +const probeRouterHooksPlugin = (): RuntimePlugin => ({ + name: 'probe-tanstack-router-hooks', + setup: api => { + (api as any).onBeforeCreateRoutes((context: any) => { + (globalThis as any).__tanstackBeforeCreateRoutes = true; + context?.ssrContext?.response?.setHeader?.( + 'x-tanstack-before-create-routes', + '1', + ); + }); + + (api as any).modifyRoutes((routes: RouteObject[]) => { + const rewrite = (items: RouteObject[]): RouteObject[] => + items.map(route => { + const next: RouteObject = { ...route }; + if (next.path === 'original') { + next.path = 'modified'; + } + if (Array.isArray(next.children) && next.children.length) { + next.children = rewrite(next.children); + } + return next; + }); + + return rewrite(routes); + }); + }, +}); + +function RootLayout() { + const rootMatch = useMatch({ strict: false, from: '__root__' as any }); + const rootLoaderData = (rootMatch as any)?.loaderData || {}; + return ( +

+
{rootLoaderData.root || 'missing-root'}
+ + modified + + +
+ ); +} + +function ModifiedPage() { + const match = useMatch({ strict: false, from: '/modified' as any }); + const loaderData = (match as any)?.loaderData || {}; + return
modified:{loaderData.hook ? 'hooked' : 'missing'}
; +} + +export default defineRuntimeConfig({ + plugins: [probeRouterHooksPlugin()], + router: { + framework: 'tanstack', + createRoutes: () => [ + { + id: 'root', + path: '/', + loader: () => ({ root: 'ok' }), + Component: RootLayout, + children: [ + { + id: 'original', + path: 'original', + loader: () => ({ + hook: Boolean((globalThis as any).__tanstackBeforeCreateRoutes), + }), + Component: ModifiedPage, + }, + ], + }, + ], + }, +}); diff --git a/tests/integration/routes-tanstack-create-routes/tests/create-routes-contract.test.ts b/tests/integration/routes-tanstack-create-routes/tests/create-routes-contract.test.ts new file mode 100644 index 000000000000..3fc83177cbbd --- /dev/null +++ b/tests/integration/routes-tanstack-create-routes/tests/create-routes-contract.test.ts @@ -0,0 +1,92 @@ +/** + * @jest-environment node + */ +import fs from 'fs'; +import path from 'path'; +import { + acquireFixtureLock, + type ReleaseFixtureLock, +} from '../../../utils/fixtureLock'; +import { modernBuild } from '../../../utils/modernTestUtils'; + +const projectRoot = path.resolve(__dirname, '../../..'); +const repoRoot = path.resolve(__dirname, '../../../..'); +const appDir = path.join( + projectRoot, + 'integration/routes-tanstack-create-routes', +); + +const readFixture = (relativePath: string) => + fs.readFileSync(path.join(projectRoot, relativePath), 'utf8'); + +const readFixtureJson = (relativePath: string) => + JSON.parse(readFixture(relativePath)); + +describe('tanstack create-routes contracts', () => { + let releaseFixtureLock: ReleaseFixtureLock | undefined; + + beforeAll(async () => { + releaseFixtureLock = await acquireFixtureLock(appDir); + await modernBuild(appDir); + }); + + afterAll(async () => { + await releaseFixtureLock?.(); + }); + + test('publishes the tanstack router plugin runtime subpath export', () => { + const packageJson = JSON.parse( + fs.readFileSync( + path.join(repoRoot, 'packages/runtime/plugin-tanstack/package.json'), + 'utf8', + ), + ); + + expect(packageJson.exports['./runtime']).toEqual( + expect.objectContaining({ + types: './dist/types/runtime/index.d.ts', + default: './dist/esm/runtime/index.mjs', + }), + ); + expect(packageJson.typesVersions['*'].runtime).toEqual([ + './dist/types/runtime/index.d.ts', + ]); + }); + + test('generated register file augments tanstack router register interface', () => { + const code = readFixture( + 'integration/routes-tanstack-create-routes/src/modern-tanstack/register.gen.d.ts', + ); + + expect(code).toContain( + "import type { router as router0 } from './index/router.gen';", + ); + expect(code).toContain( + "declare module '@modern-js/plugin-tanstack/runtime'", + ); + expect(code).toContain('interface Register'); + expect(code).toContain('router: typeof router0;'); + }); + + test('generated route manifest preserves SPA/SSR hybrid route semantics', () => { + const routeManifest = readFixtureJson( + 'integration/routes-tanstack-create-routes/dist/route.json', + ); + + expect(Array.isArray(routeManifest.routes)).toBe(true); + expect(routeManifest.routes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + urlPath: '/', + entryName: 'index', + entryPath: 'html/index/index.html', + isSPA: true, + isSSR: true, + isStream: false, + isRSC: false, + bundle: 'bundles/index.js', + }), + ]), + ); + }); +}); diff --git a/tests/integration/routes-tanstack-create-routes/tsconfig.json b/tests/integration/routes-tanstack-create-routes/tsconfig.json new file mode 100644 index 000000000000..38458cbc9675 --- /dev/null +++ b/tests/integration/routes-tanstack-create-routes/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": false, + "jsx": "preserve", + "baseUrl": "./", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src", "config"] +} diff --git a/tests/integration/routes-tanstack/modern.config.ts b/tests/integration/routes-tanstack/modern.config.ts new file mode 100644 index 000000000000..89e2defe01d3 --- /dev/null +++ b/tests/integration/routes-tanstack/modern.config.ts @@ -0,0 +1,24 @@ +import { appTools, defineConfig } from '@modern-js/app-tools'; +import { tanstackRouterPlugin } from '@modern-js/plugin-tanstack'; + +export default defineConfig({ + plugins: [appTools(), tanstackRouterPlugin()], + output: { + polyfill: 'off', + disableTsChecker: true, + minify: false, + }, + server: { + ssrByEntries: { + string: { + mode: 'string', + }, + stream: { + mode: 'stream', + }, + }, + }, + performance: { + buildCache: false, + }, +}); diff --git a/tests/integration/routes-tanstack/package.json b/tests/integration/routes-tanstack/package.json new file mode 100644 index 000000000000..d6494b2f467c --- /dev/null +++ b/tests/integration/routes-tanstack/package.json @@ -0,0 +1,24 @@ +{ + "private": true, + "name": "routes-tanstack", + "version": "2.66.0", + "scripts": { + "dev": "modern dev", + "build": "modern build", + "serve": "modern serve" + }, + "dependencies": { + "@modern-js/plugin-tanstack": "workspace:*", + "@modern-js/runtime": "workspace:*", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*", + "@types/jest": "^30.0.0", + "@types/node": "^25.6.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "typescript": "6.0.3" + } +} diff --git a/tests/integration/routes-tanstack/src/modern-app-env.d.ts b/tests/integration/routes-tanstack/src/modern-app-env.d.ts new file mode 100644 index 000000000000..1e851dcf7213 --- /dev/null +++ b/tests/integration/routes-tanstack/src/modern-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tests/integration/routes-tanstack/src/modern-tanstack/register.gen.d.ts b/tests/integration/routes-tanstack/src/modern-tanstack/register.gen.d.ts new file mode 100644 index 000000000000..35b2ed56a417 --- /dev/null +++ b/tests/integration/routes-tanstack/src/modern-tanstack/register.gen.d.ts @@ -0,0 +1,10 @@ +// This file is auto-generated by Modern.js. Do not edit manually. + +import type { router as router0 } from './stream/router.gen'; +import type { router as router1 } from './string/router.gen'; + +declare module '@modern-js/plugin-tanstack/runtime' { + interface Register { + router: typeof router0 | typeof router1; + } +} diff --git a/tests/integration/routes-tanstack/src/modern-tanstack/stream/router.gen.ts b/tests/integration/routes-tanstack/src/modern-tanstack/stream/router.gen.ts new file mode 100644 index 000000000000..2d9f31dcf334 --- /dev/null +++ b/tests/integration/routes-tanstack/src/modern-tanstack/stream/router.gen.ts @@ -0,0 +1,194 @@ +/* eslint-disable */ +// This file is auto-generated by Modern.js. Do not edit manually. + +import { + createMemoryHistory, + createRootRouteWithContext, + createRoute, + createRouter, + notFound, + redirect, +} from '@modern-js/plugin-tanstack/runtime'; + +type ModernRouterContext = { + request?: Request; + requestContext?: unknown; +}; + +function isResponse(value: unknown): value is Response { + return ( + value != null && + typeof value === 'object' && + typeof (value as any).status === 'number' && + typeof (value as any).headers === 'object' + ); +} + +const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); +function isRedirectResponse(res: Response) { + return redirectStatusCodes.has(res.status); +} + +function throwTanstackRedirect(location: string) { + const target = location || '/'; + try { + void new URL(target); + throw redirect({ href: target }); + } catch { + throw redirect({ to: target }); + } +} + +function mapParamsForModernLoader(params: Record, hasSplat: boolean) { + if (!hasSplat) { + return params; + } + + const { _splat, ...rest } = params as any; + if (typeof _splat !== 'undefined') { + return { ...rest, '*': _splat }; + } + return rest; +} + +function createRouteStaticData(opts: { + modernRouteId?: string; + modernRouteLoader?: unknown; +}) { + const staticData: Record = {}; + + if (opts.modernRouteId) { + staticData.modernRouteId = opts.modernRouteId; + } + + if (opts.modernRouteLoader) { + staticData.modernRouteLoader = opts.modernRouteLoader; + } + + return Object.keys(staticData).length > 0 ? staticData : undefined; +} + +function modernLoaderToTanstack any>( + opts: { hasSplat: boolean }, + modernLoader: TLoader, +) { + type LoaderResult = Awaited>; + + return async (ctx: any): Promise => { + try { + const signal: AbortSignal = + ctx?.abortController?.signal || + ctx?.signal || + new AbortController().signal; + const baseRequest: Request | undefined = + ctx?.context?.request instanceof Request ? ctx.context.request : undefined; + + const href = + typeof ctx?.location === 'string' + ? ctx.location + : ctx?.location?.publicHref || + ctx?.location?.href || + ctx?.location?.url?.href || + ''; + + const request = baseRequest + ? new Request(baseRequest, { signal }) + : new Request(href, { signal }); + + const params = mapParamsForModernLoader(ctx?.params || {}, opts.hasSplat); + + const result = await (modernLoader as any)({ + request, + params, + context: ctx?.context?.requestContext, + }); + + if (isResponse(result)) { + if (isRedirectResponse(result)) { + const location = result.headers.get('Location') || '/'; + throwTanstackRedirect(location); + } + if (result.status === 404) { + throw notFound(); + } + } + + return result as LoaderResult; + } catch (err) { + if (isResponse(err)) { + if (isRedirectResponse(err)) { + const location = err.headers.get('Location') || '/'; + throwTanstackRedirect(location); + } + if (err.status === 404) { + throw notFound(); + } + } + throw err; + } + }; +} + +import loader_0 from "../../stream/routes/layout.loader"; +import { loader as loader_1 } from "../../stream/routes/page.data"; +import { loader as loader_2 } from "../../stream/routes/optional/[id$]/page.data"; +import { loader as loader_3 } from "../../stream/routes/redirect/page.data"; +import { loader as loader_4 } from "../../stream/routes/user/[id]/page.data"; + +export const rootRoute = createRootRouteWithContext()({ + loader: modernLoaderToTanstack({ hasSplat: false }, loader_0), + staticData: createRouteStaticData({ + modernRouteId: "stream_layout", + modernRouteLoader: loader_0, + }), +}); + +const route_stream_page = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + loader: modernLoaderToTanstack({ hasSplat: false }, loader_1), + staticData: createRouteStaticData({ + modernRouteId: "stream_page", + modernRouteLoader: loader_1, + }), +}); + +const route_stream_optional__id$__page = createRoute({ + getParentRoute: () => rootRoute, + path: "optional/{-$id}", + loader: modernLoaderToTanstack({ hasSplat: false }, loader_2), + staticData: createRouteStaticData({ + modernRouteId: "stream_optional/(id$)/page", + modernRouteLoader: loader_2, + }), +}); + +const route_stream_redirect_page = createRoute({ + getParentRoute: () => rootRoute, + path: "redirect", + loader: modernLoaderToTanstack({ hasSplat: false }, loader_3), + staticData: createRouteStaticData({ + modernRouteId: "stream_redirect/page", + modernRouteLoader: loader_3, + }), +}); + +const route_stream_user__id__page = createRoute({ + getParentRoute: () => rootRoute, + path: "user/$id", + loader: modernLoaderToTanstack({ hasSplat: false }, loader_4), + staticData: createRouteStaticData({ + modernRouteId: "stream_user/(id)/page", + modernRouteLoader: loader_4, + }), +}); + +export const routeTree = rootRoute.addChildren([route_stream_page, route_stream_optional__id$__page, route_stream_redirect_page, route_stream_user__id__page]); + +export const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/'], + }), + context: {} as ModernRouterContext, +}); diff --git a/tests/integration/routes-tanstack/src/modern-tanstack/string/router.gen.ts b/tests/integration/routes-tanstack/src/modern-tanstack/string/router.gen.ts new file mode 100644 index 000000000000..a86a20f1bffe --- /dev/null +++ b/tests/integration/routes-tanstack/src/modern-tanstack/string/router.gen.ts @@ -0,0 +1,213 @@ +/* eslint-disable */ +// This file is auto-generated by Modern.js. Do not edit manually. + +import { + createMemoryHistory, + createRootRouteWithContext, + createRoute, + createRouter, + notFound, + redirect, +} from '@modern-js/plugin-tanstack/runtime'; + +type ModernRouterContext = { + request?: Request; + requestContext?: unknown; +}; + +function isResponse(value: unknown): value is Response { + return ( + value != null && + typeof value === 'object' && + typeof (value as any).status === 'number' && + typeof (value as any).headers === 'object' + ); +} + +const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); +function isRedirectResponse(res: Response) { + return redirectStatusCodes.has(res.status); +} + +function throwTanstackRedirect(location: string) { + const target = location || '/'; + try { + void new URL(target); + throw redirect({ href: target }); + } catch { + throw redirect({ to: target }); + } +} + +function mapParamsForModernLoader(params: Record, hasSplat: boolean) { + if (!hasSplat) { + return params; + } + + const { _splat, ...rest } = params as any; + if (typeof _splat !== 'undefined') { + return { ...rest, '*': _splat }; + } + return rest; +} + +function createRouteStaticData(opts: { + modernRouteId?: string; + modernRouteLoader?: unknown; +}) { + const staticData: Record = {}; + + if (opts.modernRouteId) { + staticData.modernRouteId = opts.modernRouteId; + } + + if (opts.modernRouteLoader) { + staticData.modernRouteLoader = opts.modernRouteLoader; + } + + return Object.keys(staticData).length > 0 ? staticData : undefined; +} + +function modernLoaderToTanstack any>( + opts: { hasSplat: boolean }, + modernLoader: TLoader, +) { + type LoaderResult = Awaited>; + + return async (ctx: any): Promise => { + try { + const signal: AbortSignal = + ctx?.abortController?.signal || + ctx?.signal || + new AbortController().signal; + const baseRequest: Request | undefined = + ctx?.context?.request instanceof Request ? ctx.context.request : undefined; + + const href = + typeof ctx?.location === 'string' + ? ctx.location + : ctx?.location?.publicHref || + ctx?.location?.href || + ctx?.location?.url?.href || + ''; + + const request = baseRequest + ? new Request(baseRequest, { signal }) + : new Request(href, { signal }); + + const params = mapParamsForModernLoader(ctx?.params || {}, opts.hasSplat); + + const result = await (modernLoader as any)({ + request, + params, + context: ctx?.context?.requestContext, + }); + + if (isResponse(result)) { + if (isRedirectResponse(result)) { + const location = result.headers.get('Location') || '/'; + throwTanstackRedirect(location); + } + if (result.status === 404) { + throw notFound(); + } + } + + return result as LoaderResult; + } catch (err) { + if (isResponse(err)) { + if (isRedirectResponse(err)) { + const location = err.headers.get('Location') || '/'; + throwTanstackRedirect(location); + } + if (err.status === 404) { + throw notFound(); + } + } + throw err; + } + }; +} + +import loader_0 from "../../string/routes/layout.loader"; +import { loader as loader_1 } from "../../string/routes/page.data"; +import { loader as loader_2 } from "../../string/routes/mutation/page.data"; +import { loader as loader_3 } from "../../string/routes/optional/[id$]/page.data"; +import { loader as loader_4 } from "../../string/routes/redirect/page.data"; +import { loader as loader_5 } from "../../string/routes/user/[id]/page.data"; + +export const rootRoute = createRootRouteWithContext()({ + loader: modernLoaderToTanstack({ hasSplat: false }, loader_0), + staticData: createRouteStaticData({ + modernRouteId: "string_layout", + modernRouteLoader: loader_0, + }), +}); + +const route_string_blocker_page = createRoute({ + getParentRoute: () => rootRoute, + path: "blocker", + staticData: createRouteStaticData({ + modernRouteId: "string_blocker/page", + }), +}); + +const route_string_page = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + loader: modernLoaderToTanstack({ hasSplat: false }, loader_1), + staticData: createRouteStaticData({ + modernRouteId: "string_page", + modernRouteLoader: loader_1, + }), +}); + +const route_string_mutation_page = createRoute({ + getParentRoute: () => rootRoute, + path: "mutation", + loader: modernLoaderToTanstack({ hasSplat: false }, loader_2), + staticData: createRouteStaticData({ + modernRouteId: "string_mutation/page", + modernRouteLoader: loader_2, + }), +}); + +const route_string_optional__id$__page = createRoute({ + getParentRoute: () => rootRoute, + path: "optional/{-$id}", + loader: modernLoaderToTanstack({ hasSplat: false }, loader_3), + staticData: createRouteStaticData({ + modernRouteId: "string_optional/(id$)/page", + modernRouteLoader: loader_3, + }), +}); + +const route_string_redirect_page = createRoute({ + getParentRoute: () => rootRoute, + path: "redirect", + loader: modernLoaderToTanstack({ hasSplat: false }, loader_4), + staticData: createRouteStaticData({ + modernRouteId: "string_redirect/page", + modernRouteLoader: loader_4, + }), +}); + +const route_string_user__id__page = createRoute({ + getParentRoute: () => rootRoute, + path: "user/$id", + loader: modernLoaderToTanstack({ hasSplat: false }, loader_5), + staticData: createRouteStaticData({ + modernRouteId: "string_user/(id)/page", + modernRouteLoader: loader_5, + }), +}); + +export const routeTree = rootRoute.addChildren([route_string_page, route_string_blocker_page, route_string_mutation_page, route_string_optional__id$__page, route_string_redirect_page, route_string_user__id__page]); + +export const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/'], + }), + context: {} as ModernRouterContext, +}); diff --git a/tests/integration/routes-tanstack/src/modern.runtime.tsx b/tests/integration/routes-tanstack/src/modern.runtime.tsx new file mode 100644 index 000000000000..7437c8314e58 --- /dev/null +++ b/tests/integration/routes-tanstack/src/modern.runtime.tsx @@ -0,0 +1,3 @@ +import { defineRuntimeConfig } from '@modern-js/runtime'; + +export default defineRuntimeConfig({}); diff --git a/tests/integration/routes-tanstack/src/stream/routes/layout.loader.ts b/tests/integration/routes-tanstack/src/stream/routes/layout.loader.ts new file mode 100644 index 000000000000..9ad861e625e9 --- /dev/null +++ b/tests/integration/routes-tanstack/src/stream/routes/layout.loader.ts @@ -0,0 +1,5 @@ +export default () => { + return { + message: 'stream-layout', + }; +}; diff --git a/tests/integration/routes-tanstack/src/stream/routes/layout.tsx b/tests/integration/routes-tanstack/src/stream/routes/layout.tsx new file mode 100644 index 000000000000..7444f6c8220a --- /dev/null +++ b/tests/integration/routes-tanstack/src/stream/routes/layout.tsx @@ -0,0 +1,39 @@ +import { Link, Outlet, useMatch } from '@modern-js/plugin-tanstack/runtime'; + +export default function Layout() { + const match = useMatch({ from: '__root__' }); + const message = match.loaderData!.message; + + return ( +
+
{message}
+ + +
+ ); +} diff --git a/tests/integration/routes-tanstack/src/stream/routes/optional/[id$]/page.data.ts b/tests/integration/routes-tanstack/src/stream/routes/optional/[id$]/page.data.ts new file mode 100644 index 000000000000..041aba408723 --- /dev/null +++ b/tests/integration/routes-tanstack/src/stream/routes/optional/[id$]/page.data.ts @@ -0,0 +1,9 @@ +export const loader = ({ + params, +}: { + params: Record; +}) => { + return { + id: params.id ?? 'none', + }; +}; diff --git a/tests/integration/routes-tanstack/src/stream/routes/optional/[id$]/page.tsx b/tests/integration/routes-tanstack/src/stream/routes/optional/[id$]/page.tsx new file mode 100644 index 000000000000..9b61d5a3a7dd --- /dev/null +++ b/tests/integration/routes-tanstack/src/stream/routes/optional/[id$]/page.tsx @@ -0,0 +1,8 @@ +import { useMatch } from '@modern-js/plugin-tanstack/runtime'; + +export default function OptionalPage() { + const match = useMatch({ from: '/optional/{-$id}' }); + const id = match.loaderData!.id; + + return
stream-optional:{id}
; +} diff --git a/tests/integration/routes-tanstack/src/stream/routes/page.data.ts b/tests/integration/routes-tanstack/src/stream/routes/page.data.ts new file mode 100644 index 000000000000..14440680c29b --- /dev/null +++ b/tests/integration/routes-tanstack/src/stream/routes/page.data.ts @@ -0,0 +1,5 @@ +export const loader = () => { + return { + page: 'index', + }; +}; diff --git a/tests/integration/routes-tanstack/src/stream/routes/page.tsx b/tests/integration/routes-tanstack/src/stream/routes/page.tsx new file mode 100644 index 000000000000..d1072059b473 --- /dev/null +++ b/tests/integration/routes-tanstack/src/stream/routes/page.tsx @@ -0,0 +1,8 @@ +import { useMatch } from '@modern-js/plugin-tanstack/runtime'; + +export default function IndexPage() { + const match = useMatch({ from: '/' }); + const page = match.loaderData!.page; + + return
stream-index:{page}
; +} diff --git a/tests/integration/routes-tanstack/src/stream/routes/redirect/page.data.ts b/tests/integration/routes-tanstack/src/stream/routes/redirect/page.data.ts new file mode 100644 index 000000000000..ae19d69866fb --- /dev/null +++ b/tests/integration/routes-tanstack/src/stream/routes/redirect/page.data.ts @@ -0,0 +1,8 @@ +export const loader = () => { + return new Response(null, { + status: 302, + headers: { + Location: '/user/123', + }, + }); +}; diff --git a/tests/integration/routes-tanstack/src/stream/routes/redirect/page.tsx b/tests/integration/routes-tanstack/src/stream/routes/redirect/page.tsx new file mode 100644 index 000000000000..6d12afc7a8f4 --- /dev/null +++ b/tests/integration/routes-tanstack/src/stream/routes/redirect/page.tsx @@ -0,0 +1,3 @@ +export default function RedirectPage() { + return
redirecting
; +} diff --git a/tests/integration/routes-tanstack/src/stream/routes/user/[id]/page.data.ts b/tests/integration/routes-tanstack/src/stream/routes/user/[id]/page.data.ts new file mode 100644 index 000000000000..b9786ca00e95 --- /dev/null +++ b/tests/integration/routes-tanstack/src/stream/routes/user/[id]/page.data.ts @@ -0,0 +1,5 @@ +export const loader = ({ params }: { params: Record }) => { + return { + id: params.id, + }; +}; diff --git a/tests/integration/routes-tanstack/src/stream/routes/user/[id]/page.tsx b/tests/integration/routes-tanstack/src/stream/routes/user/[id]/page.tsx new file mode 100644 index 000000000000..f8b1cffc8cb4 --- /dev/null +++ b/tests/integration/routes-tanstack/src/stream/routes/user/[id]/page.tsx @@ -0,0 +1,8 @@ +import { useMatch } from '@modern-js/plugin-tanstack/runtime'; + +export default function UserPage() { + const match = useMatch({ from: '/user/$id' }); + const id = match.loaderData!.id; + + return
stream-user:{id}
; +} diff --git a/tests/integration/routes-tanstack/src/string/routes/blocker/page.tsx b/tests/integration/routes-tanstack/src/string/routes/blocker/page.tsx new file mode 100644 index 000000000000..f90e7780121e --- /dev/null +++ b/tests/integration/routes-tanstack/src/string/routes/blocker/page.tsx @@ -0,0 +1,52 @@ +import { Link, useBlocker } from '@modern-js/plugin-tanstack/runtime'; +import { useState } from 'react'; + +export default function BlockerPage() { + const [dirty, setDirty] = useState(true); + const blocker = useBlocker({ + shouldBlockFn: () => dirty, + withResolver: true, + }); + + return ( +
+
{dirty ? 'dirty' : 'clean'}
+ + + + leave-home + + + {blocker.status === 'blocked' ? ( +
+ + +
+ ) : null} +
+ ); +} diff --git a/tests/integration/routes-tanstack/src/string/routes/layout.loader.ts b/tests/integration/routes-tanstack/src/string/routes/layout.loader.ts new file mode 100644 index 000000000000..d14d372d463f --- /dev/null +++ b/tests/integration/routes-tanstack/src/string/routes/layout.loader.ts @@ -0,0 +1,5 @@ +export default () => { + return { + message: 'string-layout', + }; +}; diff --git a/tests/integration/routes-tanstack/src/string/routes/layout.tsx b/tests/integration/routes-tanstack/src/string/routes/layout.tsx new file mode 100644 index 000000000000..549789a52e5c --- /dev/null +++ b/tests/integration/routes-tanstack/src/string/routes/layout.tsx @@ -0,0 +1,45 @@ +import { Link, Outlet, useMatch } from '@modern-js/plugin-tanstack/runtime'; + +export default function Layout() { + const match = useMatch({ from: '__root__' }); + const message = match.loaderData!.message; + + return ( +
+
{message}
+ + +
+ ); +} diff --git a/tests/integration/routes-tanstack/src/string/routes/mutation/page.data.ts b/tests/integration/routes-tanstack/src/string/routes/mutation/page.data.ts new file mode 100644 index 000000000000..957d571f8afe --- /dev/null +++ b/tests/integration/routes-tanstack/src/string/routes/mutation/page.data.ts @@ -0,0 +1,30 @@ +const COUNTER_KEY = '__tanstackMutationCounter'; + +function getCount() { + const value = (globalThis as any)[COUNTER_KEY]; + return typeof value === 'number' ? value : 0; +} + +export const loader = () => { + return { + count: getCount(), + }; +}; + +export const action = async ({ request }: { request: Request }) => { + const formData = await request.formData(); + const amount = Number(formData.get('amount') || 1); + const nextCount = getCount() + amount; + (globalThis as any)[COUNTER_KEY] = nextCount; + + return new Response( + JSON.stringify({ + count: nextCount, + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ); +}; diff --git a/tests/integration/routes-tanstack/src/string/routes/mutation/page.tsx b/tests/integration/routes-tanstack/src/string/routes/mutation/page.tsx new file mode 100644 index 000000000000..02ac882ea5b5 --- /dev/null +++ b/tests/integration/routes-tanstack/src/string/routes/mutation/page.tsx @@ -0,0 +1,70 @@ +import { Form, useFetcher, useMatch } from '@modern-js/plugin-tanstack/runtime'; + +export default function MutationPage() { + const match = useMatch({ from: '/mutation' }); + const fetcher = useFetcher(); + const loaderFetcher = useFetcher(); + const FetcherForm = fetcher.Form; + const count = match.loaderData!.count; + const loaderFetcherData = loaderFetcher.data as + | { count?: number } + | undefined; + + return ( +
+
string-mutation:{count}
+
+ + +
+ + + + + + + + + +
+ string-mutation-loader: + {typeof loaderFetcherData?.count === 'number' + ? loaderFetcherData.count + : 'none'} +
+
{fetcher.state}
+
+ ); +} diff --git a/tests/integration/routes-tanstack/src/string/routes/optional/[id$]/page.data.ts b/tests/integration/routes-tanstack/src/string/routes/optional/[id$]/page.data.ts new file mode 100644 index 000000000000..041aba408723 --- /dev/null +++ b/tests/integration/routes-tanstack/src/string/routes/optional/[id$]/page.data.ts @@ -0,0 +1,9 @@ +export const loader = ({ + params, +}: { + params: Record; +}) => { + return { + id: params.id ?? 'none', + }; +}; diff --git a/tests/integration/routes-tanstack/src/string/routes/optional/[id$]/page.tsx b/tests/integration/routes-tanstack/src/string/routes/optional/[id$]/page.tsx new file mode 100644 index 000000000000..7540eca536b3 --- /dev/null +++ b/tests/integration/routes-tanstack/src/string/routes/optional/[id$]/page.tsx @@ -0,0 +1,8 @@ +import { useMatch } from '@modern-js/plugin-tanstack/runtime'; + +export default function OptionalPage() { + const match = useMatch({ from: '/optional/{-$id}' }); + const id = match.loaderData!.id; + + return
string-optional:{id}
; +} diff --git a/tests/integration/routes-tanstack/src/string/routes/page.data.ts b/tests/integration/routes-tanstack/src/string/routes/page.data.ts new file mode 100644 index 000000000000..14440680c29b --- /dev/null +++ b/tests/integration/routes-tanstack/src/string/routes/page.data.ts @@ -0,0 +1,5 @@ +export const loader = () => { + return { + page: 'index', + }; +}; diff --git a/tests/integration/routes-tanstack/src/string/routes/page.tsx b/tests/integration/routes-tanstack/src/string/routes/page.tsx new file mode 100644 index 000000000000..8f451f33110e --- /dev/null +++ b/tests/integration/routes-tanstack/src/string/routes/page.tsx @@ -0,0 +1,8 @@ +import { useMatch } from '@modern-js/plugin-tanstack/runtime'; + +export default function IndexPage() { + const match = useMatch({ from: '/' }); + const page = match.loaderData!.page; + + return
string-index:{page}
; +} diff --git a/tests/integration/routes-tanstack/src/string/routes/redirect/page.data.ts b/tests/integration/routes-tanstack/src/string/routes/redirect/page.data.ts new file mode 100644 index 000000000000..ae19d69866fb --- /dev/null +++ b/tests/integration/routes-tanstack/src/string/routes/redirect/page.data.ts @@ -0,0 +1,8 @@ +export const loader = () => { + return new Response(null, { + status: 302, + headers: { + Location: '/user/123', + }, + }); +}; diff --git a/tests/integration/routes-tanstack/src/string/routes/redirect/page.tsx b/tests/integration/routes-tanstack/src/string/routes/redirect/page.tsx new file mode 100644 index 000000000000..6d12afc7a8f4 --- /dev/null +++ b/tests/integration/routes-tanstack/src/string/routes/redirect/page.tsx @@ -0,0 +1,3 @@ +export default function RedirectPage() { + return
redirecting
; +} diff --git a/tests/integration/routes-tanstack/src/string/routes/user/[id]/page.data.ts b/tests/integration/routes-tanstack/src/string/routes/user/[id]/page.data.ts new file mode 100644 index 000000000000..b9786ca00e95 --- /dev/null +++ b/tests/integration/routes-tanstack/src/string/routes/user/[id]/page.data.ts @@ -0,0 +1,5 @@ +export const loader = ({ params }: { params: Record }) => { + return { + id: params.id, + }; +}; diff --git a/tests/integration/routes-tanstack/src/string/routes/user/[id]/page.tsx b/tests/integration/routes-tanstack/src/string/routes/user/[id]/page.tsx new file mode 100644 index 000000000000..2ee4df23f5d9 --- /dev/null +++ b/tests/integration/routes-tanstack/src/string/routes/user/[id]/page.tsx @@ -0,0 +1,8 @@ +import { useMatch } from '@modern-js/plugin-tanstack/runtime'; + +export default function UserPage() { + const match = useMatch({ from: '/user/$id' }); + const id = match.loaderData!.id; + + return
string-user:{id}
; +} diff --git a/tests/integration/routes-tanstack/src/type-tests/tanstack-router.tsx b/tests/integration/routes-tanstack/src/type-tests/tanstack-router.tsx new file mode 100644 index 000000000000..363d721f84a9 --- /dev/null +++ b/tests/integration/routes-tanstack/src/type-tests/tanstack-router.tsx @@ -0,0 +1,94 @@ +import { + Form, + Link, + useBlocker, + useFetcher, + useMatch, + useNavigate, +} from '@modern-js/plugin-tanstack/runtime'; +import * as React from 'react'; + +export function TanstackRouterTypeTests() { + const navigate = useNavigate(); + const fetcher = useFetcher(); + const blocker = useBlocker({ + shouldBlockFn: () => true, + withResolver: true, + }); + + const userMatch = useMatch({ from: '/user/$id' }); + + const id: string = userMatch.loaderData!.id; + // @ts-expect-error loaderData.id should be a string + const idNumber: number = userMatch.loaderData!.id; + + // Valid: required params for a dynamic route + const goodLink = ; + const goodLinkWithPrefetch = ( + + ); + + // @ts-expect-error params are required for /user/$id + const badLinkMissingParams = ; + + // @ts-expect-error invalid prefetch mode + // biome-ignore format: keep this on one line so @ts-expect-error applies. + const badPrefetchValue = ; + + // Optional param route: params should be optional + const optionalLinkNoParams = ; + const optionalLinkWithParams = ( + + ); + + fetcher.submit( + { amount: 1 }, + { + method: 'post', + action: '/mutation', + }, + ); + fetcher.submit( + {}, + { + method: 'get', + action: '/mutation', + }, + ); + + const mutationForm = ( +
+ +
+ ); + const FetcherForm = fetcher.Form; + const mutationFetcherForm = ( + + + + ); + + // @ts-expect-error unknown route path + const badLinkUnknownRoute = ; + + React.useEffect(() => { + navigate({ to: '/user/$id', params: { id } }); + // @ts-expect-error params are required for /user/$id + navigate({ to: '/user/$id' }); + + navigate({ to: '/optional/{-$id}' }); + navigate({ to: '/optional/{-$id}', params: { id: '123' } }); + }, [id, navigate]); + + return ( + <> + {goodLink} + {goodLinkWithPrefetch} + {optionalLinkNoParams} + {optionalLinkWithParams} + {mutationForm} + {mutationFetcherForm} + {blocker.status} + + ); +} diff --git a/tests/integration/routes-tanstack/tests/tanstack-data-flow-contract.test.ts b/tests/integration/routes-tanstack/tests/tanstack-data-flow-contract.test.ts new file mode 100644 index 000000000000..d86f189bed65 --- /dev/null +++ b/tests/integration/routes-tanstack/tests/tanstack-data-flow-contract.test.ts @@ -0,0 +1,51 @@ +/** + * @jest-environment node + */ +import fs from 'fs'; +import path from 'path'; + +const projectRoot = path.resolve(__dirname, '../../..'); + +const readFixture = (relativePath: string) => + fs.readFileSync(path.join(projectRoot, relativePath), 'utf8'); + +const assertTanstackLoaderContract = (code: string) => { + expect(code).toContain('function modernLoaderToTanstack'); + expect(code).toContain('function createRouteStaticData'); + expect(code).toContain('ctx?.abortController?.signal'); + expect(code).toContain('ctx?.signal ||'); + expect(code).toContain('new Request(href, { signal })'); + expect(code).toContain('mapParamsForModernLoader'); + expect(code).toContain('const { _splat, ...rest } = params as any'); + expect(code).toContain("return { ...rest, '*': _splat }"); + expect(code).toContain('if (isRedirectResponse(result))'); + expect(code).toContain('throwTanstackRedirect(location)'); + expect(code).toContain('if (result.status === 404)'); + expect(code).toContain('throw notFound()'); + expect(code).toContain('staticData: createRouteStaticData({'); + expect(code).toContain('modernRouteId:'); +}; + +describe('tanstack generated data-flow contracts', () => { + test('string mode router bridges modern loaders to tanstack semantics', () => { + const code = readFixture( + 'integration/routes-tanstack/src/modern-tanstack/string/router.gen.ts', + ); + + assertTanstackLoaderContract(code); + expect(code).toContain('path: "mutation"'); + expect(code).toContain('route_string_mutation_page'); + expect(code).toContain('createRouter({'); + }); + + test('stream mode router preserves redirect and notFound mappings', () => { + const code = readFixture( + 'integration/routes-tanstack/src/modern-tanstack/stream/router.gen.ts', + ); + + assertTanstackLoaderContract(code); + expect(code).toContain('route_stream_redirect_page'); + expect(code).toContain('route_stream_user__id__page'); + expect(code).toContain('createRouter({'); + }); +}); diff --git a/tests/integration/routes-tanstack/tsconfig.json b/tests/integration/routes-tanstack/tsconfig.json new file mode 100644 index 000000000000..f2c10d4cd9e9 --- /dev/null +++ b/tests/integration/routes-tanstack/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": false, + "jsx": "preserve", + "baseUrl": "./", + "paths": { + "@/*": ["./src/*"], + "@shared/*": ["./shared/*"] + } + }, + "include": ["src", "shared", "config"] +} diff --git a/tests/rstest.superapp-contracts.config.mts b/tests/rstest.superapp-contracts.config.mts new file mode 100644 index 000000000000..1b932a5aacbb --- /dev/null +++ b/tests/rstest.superapp-contracts.config.mts @@ -0,0 +1,16 @@ +import { withTestPreset } from '@scripts/rstest-config'; + +export default withTestPreset({ + root: __dirname, + testEnvironment: 'node', + globals: true, + include: [ + 'integration/routes-tanstack/tests/tanstack-data-flow-contract.test.ts', + 'integration/routes-tanstack-mf/tests/tanstack-mf-contract.test.ts', + 'integration/i18n/mf/test/app-level-mf-ssr-contract.test.ts', + 'integration/routes-tanstack-create-routes/tests/create-routes-contract.test.ts', + 'integration/bff-runtime-parity/tests/effect-only-data-platform.test.ts', + ], + testTimeout: 1000 * 60 * 5, + hookTimeout: 1000 * 60 * 5, +}); diff --git a/tests/utils/fixtureLock.ts b/tests/utils/fixtureLock.ts new file mode 100644 index 000000000000..e089067bb3dd --- /dev/null +++ b/tests/utils/fixtureLock.ts @@ -0,0 +1,78 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +export type ReleaseFixtureLock = () => Promise; + +const pollInterval = 200; +const staleLockAge = 10 * 60 * 1000; + +function resolveLockDir(fixtureDir: string) { + const realPath = path.resolve(fixtureDir); + const digest = crypto.createHash('sha1').update(realPath).digest('hex'); + + return path.join(os.tmpdir(), `modernjs-fixture-${digest}.lock`); +} + +export async function acquireFixtureLock( + fixtureDir: string, +): Promise { + const lockDir = resolveLockDir(fixtureDir); + + while (true) { + try { + await fs.mkdir(lockDir); + await fs.writeFile( + path.join(lockDir, 'owner.json'), + JSON.stringify({ + pid: process.pid, + fixtureDir: path.resolve(fixtureDir), + acquiredAt: new Date().toISOString(), + }), + ); + + return async () => { + await fs.rm(lockDir, { recursive: true, force: true }); + }; + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error; + } + + try { + const stat = await fs.stat(lockDir); + if (Date.now() - stat.mtimeMs > staleLockAge) { + await fs.rm(lockDir, { recursive: true, force: true }); + continue; + } + } catch { + continue; + } + + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + } +} + +export async function acquireFixtureLocks( + fixtureDirs: string[], +): Promise { + const releaseLocks: ReleaseFixtureLock[] = []; + const sortedFixtureDirs = [ + ...new Set(fixtureDirs.map(dir => path.resolve(dir))), + ].sort(); + + try { + for (const fixtureDir of sortedFixtureDirs) { + releaseLocks.push(await acquireFixtureLock(fixtureDir)); + } + } catch (error) { + await Promise.allSettled(releaseLocks.reverse().map(release => release())); + throw error; + } + + return async () => { + await Promise.allSettled(releaseLocks.reverse().map(release => release())); + }; +} From 3d6109f88daabd7906366543bce8d1bbebb86f8d Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Fri, 15 May 2026 01:47:36 +0200 Subject: [PATCH 3/6] docs(router): align TanStack plugin review scope --- .../document/docs/en/apis/app/runtime/router/router.mdx | 2 +- packages/document/docs/en/components/init-app.mdx | 7 ------- .../docs/en/guides/basic-features/routes/routes.mdx | 2 +- .../document/docs/en/guides/get-started/tech-stack.mdx | 6 +----- .../document/docs/zh/apis/app/runtime/router/router.mdx | 2 +- packages/document/docs/zh/components/init-app.mdx | 7 ------- .../docs/zh/guides/basic-features/routes/routes.mdx | 2 +- .../document/docs/zh/guides/get-started/tech-stack.mdx | 6 +----- .../runtime/plugin-runtime/src/router/runtime/internal.ts | 2 +- 9 files changed, 7 insertions(+), 29 deletions(-) diff --git a/packages/document/docs/en/apis/app/runtime/router/router.mdx b/packages/document/docs/en/apis/app/runtime/router/router.mdx index c1c2b692b6e2..9a8a666d2d61 100644 --- a/packages/document/docs/en/apis/app/runtime/router/router.mdx +++ b/packages/document/docs/en/apis/app/runtime/router/router.mdx @@ -9,7 +9,7 @@ sidebar_position: 1 The router solution based on [react-router v7](https://reactrouter.com/). This page documents the React Router runtime export (`@modern-js/runtime/router`). -If your app uses TanStack Router (`--router tanstack`), use `@modern-js/plugin-tanstack/runtime` and refer to TanStack Router API docs. +If your app uses TanStack Router through `@modern-js/plugin-tanstack`, use `@modern-js/plugin-tanstack/runtime` and refer to TanStack Router API docs. ::: diff --git a/packages/document/docs/en/components/init-app.mdx b/packages/document/docs/en/components/init-app.mdx index e12d4fcdb4e3..2431697de37e 100644 --- a/packages/document/docs/en/components/init-app.mdx +++ b/packages/document/docs/en/components/init-app.mdx @@ -58,10 +58,3 @@ Now, the project structure is as follows: │ └── page.tsx └── tsconfig.json ``` - -When `--tailwind` is enabled, `postcss.config.mjs` and `tailwind.config.ts` are generated in the project root. -When `--router tanstack` is enabled, the scaffold adds `@modern-js/plugin-tanstack`, registers `tanstackRouterPlugin()` in `modern.config.ts`, and uses `src/views` as the route convention directory. - -When `--bff` (default Effect runtime) or `--bff-runtime effect` is enabled, `modern.config.ts` enables `@modern-js/plugin-bff`, generates `api/effect/index.ts` + `shared/effect/api.ts`, and sets `bff.runtimeFramework` to `effect`. -When `--bff-runtime hono` is enabled, `modern.config.ts` enables `@modern-js/plugin-bff`, generates `api/lambda/hello.ts`, and sets `bff.runtimeFramework` to `hono`. -When `--workspace` is enabled, `@modern-js/*` dependencies use `workspace:*` versions for local monorepo linkage. diff --git a/packages/document/docs/en/guides/basic-features/routes/routes.mdx b/packages/document/docs/en/guides/basic-features/routes/routes.mdx index 685f8498060e..d15b1d539cb7 100644 --- a/packages/document/docs/en/guides/basic-features/routes/routes.mdx +++ b/packages/document/docs/en/guides/basic-features/routes/routes.mdx @@ -14,7 +14,7 @@ The routing mentioned in this section all refers to conventional routing. :::tip This page uses React Router import paths in examples (`@modern-js/runtime/router`). -If your project is created with `--router tanstack`, use `@modern-js/plugin-tanstack/runtime` instead. +If your project uses `@modern-js/plugin-tanstack`, use `@modern-js/plugin-tanstack/runtime` instead. ::: ## What is Nested Routing diff --git a/packages/document/docs/en/guides/get-started/tech-stack.mdx b/packages/document/docs/en/guides/get-started/tech-stack.mdx index 695d7b7e8b99..abd4bbec2500 100644 --- a/packages/document/docs/en/guides/get-started/tech-stack.mdx +++ b/packages/document/docs/en/guides/get-started/tech-stack.mdx @@ -21,11 +21,7 @@ Modern.js provides two first-party routing frameworks: - [React Router v7](https://reactrouter.com/en/main) (default), via `@modern-js/runtime/router`. - [TanStack Router](https://tanstack.com/router), via `@modern-js/plugin-tanstack/runtime`. -When creating a project, you can choose TanStack Router by using: - -```bash -npx @modern-js/create@latest myapp --router tanstack -``` +TanStack Router support is provided by `@modern-js/plugin-tanstack`, so the TanStack packages do not need to be bundled into `@modern-js/runtime`. Modern.js supports conventional routing, self-controlled routing, or other routing schemes. Please refer to ["Routing"](/guides/basic-features/routes/routes) to make your choice. diff --git a/packages/document/docs/zh/apis/app/runtime/router/router.mdx b/packages/document/docs/zh/apis/app/runtime/router/router.mdx index 022c66c083b7..bd2c84a79c20 100644 --- a/packages/document/docs/zh/apis/app/runtime/router/router.mdx +++ b/packages/document/docs/zh/apis/app/runtime/router/router.mdx @@ -8,7 +8,7 @@ sidebar_position: 1 基于 [react-router](https://reactrouter.com/) 的路由解决方案。 本页文档对应 React Router 运行时导出(`@modern-js/runtime/router`)。 -如果应用使用 TanStack Router(`--router tanstack`),请使用 `@modern-js/plugin-tanstack/runtime`,并参考 TanStack Router 官方 API 文档。 +如果应用通过 `@modern-js/plugin-tanstack` 使用 TanStack Router,请使用 `@modern-js/plugin-tanstack/runtime`,并参考 TanStack Router 官方 API 文档。 ::: diff --git a/packages/document/docs/zh/components/init-app.mdx b/packages/document/docs/zh/components/init-app.mdx index 4bb7c542577c..6f0df4b46dca 100644 --- a/packages/document/docs/zh/components/init-app.mdx +++ b/packages/document/docs/zh/components/init-app.mdx @@ -58,10 +58,3 @@ npx @modern-js/create@latest myapp │ └── page.tsx └── tsconfig.json ``` - -当启用 `--tailwind` 时,项目根目录会额外生成 `postcss.config.mjs` 和 `tailwind.config.ts`。 -当启用 `--router tanstack` 时,脚手架会添加 `@modern-js/plugin-tanstack`、在 `modern.config.ts` 中注册 `tanstackRouterPlugin()`,并使用 `src/views` 作为路由约定目录。 - -当启用 `--bff`(默认 Effect 运行时)或 `--bff-runtime effect` 时,会在 `modern.config.ts` 中启用 `@modern-js/plugin-bff`,生成 `api/effect/index.ts` 与 `shared/effect/api.ts`,并将 `bff.runtimeFramework` 设置为 `effect`。 -当启用 `--bff-runtime hono` 时,会在 `modern.config.ts` 中启用 `@modern-js/plugin-bff`,生成 `api/lambda/hello.ts`,并将 `bff.runtimeFramework` 设置为 `hono`。 -当启用 `--workspace` 时,`@modern-js/*` 依赖会使用 `workspace:*` 版本,便于本地 monorepo 联调。 diff --git a/packages/document/docs/zh/guides/basic-features/routes/routes.mdx b/packages/document/docs/zh/guides/basic-features/routes/routes.mdx index d8cb3744f729..b27e2d3809cf 100644 --- a/packages/document/docs/zh/guides/basic-features/routes/routes.mdx +++ b/packages/document/docs/zh/guides/basic-features/routes/routes.mdx @@ -14,7 +14,7 @@ Modern.js 的路由基于 [React Router 7](https://reactrouter.com/en/main), :::tip 本页示例默认使用 React Router 的导出路径(`@modern-js/runtime/router`)。 -如果你的项目通过 `--router tanstack` 创建,请改用 `@modern-js/plugin-tanstack/runtime`。 +如果你的项目使用 `@modern-js/plugin-tanstack`,请改用 `@modern-js/plugin-tanstack/runtime`。 ::: ## 什么是嵌套路由 diff --git a/packages/document/docs/zh/guides/get-started/tech-stack.mdx b/packages/document/docs/zh/guides/get-started/tech-stack.mdx index 283110c5cc50..9284c1bd3f4e 100644 --- a/packages/document/docs/zh/guides/get-started/tech-stack.mdx +++ b/packages/document/docs/zh/guides/get-started/tech-stack.mdx @@ -21,11 +21,7 @@ Modern.js 提供两套一方路由方案: - 默认使用 [React Router 7](https://reactrouter.com/en/main),通过 `@modern-js/runtime/router` 导出 API。 - 支持 [TanStack Router](https://tanstack.com/router),通过 `@modern-js/plugin-tanstack/runtime` 导出 API。 -创建项目时,可通过以下命令选择 TanStack Router: - -```bash -npx @modern-js/create@latest myapp --router tanstack -``` +TanStack Router 支持由 `@modern-js/plugin-tanstack` 提供,因此不需要把 TanStack 相关包直接内置到 `@modern-js/runtime` 中。 Modern.js 支持约定式路由、自控式路由或其他路由方案,请参考 [页面入口](/guides/concept/entries) 进行选择。 diff --git a/packages/runtime/plugin-runtime/src/router/runtime/internal.ts b/packages/runtime/plugin-runtime/src/router/runtime/internal.ts index 22e8658e2827..34f2263a2c5b 100644 --- a/packages/runtime/plugin-runtime/src/router/runtime/internal.ts +++ b/packages/runtime/plugin-runtime/src/router/runtime/internal.ts @@ -38,4 +38,4 @@ export { renderRoutes, urlJoin, } from './utils'; -export { modifyRoutes } from './plugin'; \ No newline at end of file +export { modifyRoutes } from './plugin'; From f351380014119d3e3433dbf53357a30e6b4ff4f6 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Fri, 15 May 2026 11:41:00 +0200 Subject: [PATCH 4/6] chore: polish tanstack router pr --- .../src/core/context/runtime.ts | 12 --- .../tests/router/lifecycle.test.tsx | 15 ++++ .../buildTemplate.before.test.ts | 1 - packages/runtime/plugin-tanstack/package.json | 2 +- .../plugin-tanstack/src/cli/tanstackTypes.ts | 76 +++++++++++++------ .../tests/router/tanstackTypes.test.ts | 7 +- pnpm-lock.yaml | 27 ++++--- .../package.json | 2 +- .../integration/routes-tanstack/package.json | 2 +- .../src/modern-tanstack/stream/router.gen.ts | 49 +++++++----- .../src/modern-tanstack/string/router.gen.ts | 65 ++++++++++------ .../tests/tanstack-data-flow-contract.test.ts | 3 +- tests/rstest.superapp-contracts.config.mts | 3 - 13 files changed, 168 insertions(+), 96 deletions(-) diff --git a/packages/runtime/plugin-runtime/src/core/context/runtime.ts b/packages/runtime/plugin-runtime/src/core/context/runtime.ts index 9851d30421f2..f216c270213f 100644 --- a/packages/runtime/plugin-runtime/src/core/context/runtime.ts +++ b/packages/runtime/plugin-runtime/src/core/context/runtime.ts @@ -37,18 +37,6 @@ export interface TInternalRuntimeContext extends TRuntimeContext { routerMatchedRouteIds?: string[]; routerServerSnapshot?: InternalRouterServerSnapshot; routerContext?: StaticHandlerContext; - /** - * @deprecated Use `routerInstance` or `routerRuntime.instance` instead. - */ - tanstackRouter?: unknown; - /** - * @deprecated Use `routerServerSnapshot.hydrationScript(s)` instead. - */ - tanstackSsrScript?: string; - /** - * @deprecated Use `routerServerSnapshot.matchedRouteIds` instead. - */ - tanstackMatchedModernRouteIds?: string[]; unstable_getBlockNavState?: () => boolean; ssrContext?: SSRServerContext; _internalContext?: any; diff --git a/packages/runtime/plugin-runtime/tests/router/lifecycle.test.tsx b/packages/runtime/plugin-runtime/tests/router/lifecycle.test.tsx index 9c7fe1d513bb..a14a865345c9 100644 --- a/packages/runtime/plugin-runtime/tests/router/lifecycle.test.tsx +++ b/packages/runtime/plugin-runtime/tests/router/lifecycle.test.tsx @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { getInitialContext } from '../../src/core/context'; import { modifyRoutes, @@ -104,4 +106,17 @@ describe('router lifecycle seams', () => { expect(typeof (hook as any).call).toBe('function'); } }); + + it('should not expose deprecated TanStack context fields from the runtime context source', () => { + const runtimeContextSource = fs.readFileSync( + path.join(__dirname, '../../src/core/context/runtime.ts'), + 'utf-8', + ); + + expect(runtimeContextSource).not.toContain('tanstackRouter?:'); + expect(runtimeContextSource).not.toContain('tanstackSsrScript?:'); + expect(runtimeContextSource).not.toContain( + 'tanstackMatchedModernRouteIds?:', + ); + }); }); diff --git a/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToStream/buildTemplate.before.test.ts b/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToStream/buildTemplate.before.test.ts index 10a888fe9c1e..065f70c03da3 100644 --- a/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToStream/buildTemplate.before.test.ts +++ b/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToStream/buildTemplate.before.test.ts @@ -45,7 +45,6 @@ describe('buildShellBeforeTemplate', () => { routerServerSnapshot: { matches: [{ routeId: 'router-route', assetRouteId: 'asset-route' }], }, - tanstackMatchedModernRouteIds: ['legacy'], } as any, config: {} as any, }, diff --git a/packages/runtime/plugin-tanstack/package.json b/packages/runtime/plugin-tanstack/package.json index a803a129287f..d50ce957c8b2 100644 --- a/packages/runtime/plugin-tanstack/package.json +++ b/packages/runtime/plugin-tanstack/package.json @@ -73,7 +73,7 @@ "@modern-js/types": "workspace:*", "@modern-js/utils": "workspace:*", "@swc/helpers": "^0.5.17", - "@tanstack/react-router": "1.168.26" + "@tanstack/react-router": "1.169.2" }, "peerDependencies": { "@modern-js/runtime": "workspace:^3.2.0", diff --git a/packages/runtime/plugin-tanstack/src/cli/tanstackTypes.ts b/packages/runtime/plugin-tanstack/src/cli/tanstackTypes.ts index 1a36eb630fd6..249fd088cb66 100644 --- a/packages/runtime/plugin-tanstack/src/cli/tanstackTypes.ts +++ b/packages/runtime/plugin-tanstack/src/cli/tanstackTypes.ts @@ -1,7 +1,7 @@ +import path from 'path'; import type { AppToolsContext } from '@modern-js/app-tools'; import type { NestedRouteForCli, PageRoute } from '@modern-js/types'; -import { findExists, formatImportPath, fs, slash } from '@modern-js/utils'; -import path from 'path'; +import { fs, findExists, formatImportPath, slash } from '@modern-js/utils'; const reservedWords = 'break case class catch const continue debugger default delete do else export extends finally for function if import in instanceof let new return super switch this throw try typeof var void while with yield enum await implements package protected static interface private public'; @@ -64,7 +64,11 @@ async function resolveFileNoExt(inputNoExtPath: string) { } function quote(str: string) { - return JSON.stringify(str); + return `'${str + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n')}'`; } function normalizeRelativeImport(p: string) { @@ -125,6 +129,20 @@ function createRouteStaticDataSnippet(opts: { )}\n }),`; } +function getImportSource(importStatement: string) { + return importStatement.match(/from\s+['"]([^'"]+)['"];?/)?.[1] || ''; +} + +function sortImportStatements(importStatements: string[]) { + return [...importStatements].sort((a, b) => { + const sourceComparison = getImportSource(a).localeCompare( + getImportSource(b), + ); + + return sourceComparison || a.localeCompare(b); + }); +} + export async function isTanstackRouterFrameworkEnabled( appContext: AppToolsContext, ): Promise { @@ -226,12 +244,13 @@ export async function generateTanstackRouterTypesSourceForEntry(opts: { ? importName.replace(/^loader_/, 'action_') : null; if (inline) { - const specifiers = [`loader as ${importName}`]; - if (actionName) { - specifiers.push(`action as ${actionName}`); - } + const specifiers = actionName + ? [`action as ${actionName}`, `loader as ${importName}`] + : [`loader as ${importName}`]; imports.push( - `import { ${specifiers.join(', ')} } from ${quote(relImport)};`, + specifiers.length > 1 + ? `import {\n ${specifiers.join(',\n ')},\n} from ${quote(relImport)};` + : `import { ${specifiers[0]} } from ${quote(relImport)};`, ); } else { imports.push(`import ${importName} from ${quote(relImport)};`); @@ -336,6 +355,23 @@ export async function generateTanstackRouterTypesSourceForEntry(opts: { `loader: modernLoaderToTanstack({ hasSplat: false }, ${rootLoaderName}),`, ); } + const rootStaticDataSnippet = createRouteStaticDataSnippet({ + modernRouteId: (rootModern as any)?.id as string | undefined, + loaderName: rootLoaderName, + actionName: rootActionName, + }); + if (rootStaticDataSnippet) { + rootOpts.push(rootStaticDataSnippet); + } + const rootRouteOptionsSource = rootOpts.length + ? `{\n ${rootOpts.join('\n ')}\n}` + : '{}'; + + const routeTreeChildren = topLevelVars.length + ? `[ + ${topLevelVars.join(',\n ')}, +]` + : '[]'; const routerGenTs = `/* eslint-disable */ // This file is auto-generated by Modern.js. Do not edit manually. @@ -378,7 +414,10 @@ function throwTanstackRedirect(location: string) { } } -function mapParamsForModernLoader(params: Record, hasSplat: boolean) { +function mapParamsForModernLoader( + params: Record, + hasSplat: boolean, +) { if (!hasSplat) { return params; } @@ -425,7 +464,9 @@ function modernLoaderToTanstack any>( ctx?.signal || new AbortController().signal; const baseRequest: Request | undefined = - ctx?.context?.request instanceof Request ? ctx.context.request : undefined; + ctx?.context?.request instanceof Request + ? ctx.context.request + : undefined; const href = typeof ctx?.location === 'string' @@ -473,22 +514,13 @@ function modernLoaderToTanstack any>( }; } -${imports.join('\n')} +${sortImportStatements(imports).join('\n')} -export const rootRoute = createRootRouteWithContext()({ - ${rootOpts.join('\n ')} - ${ - createRouteStaticDataSnippet({ - modernRouteId: (rootModern as any)?.id as string | undefined, - loaderName: rootLoaderName, - actionName: rootActionName, - }) || '' - } -}); +export const rootRoute = createRootRouteWithContext()(${rootRouteOptionsSource}); ${statements.join('\n\n')} -export const routeTree = rootRoute.addChildren([${topLevelVars.join(', ')}]); +export const routeTree = rootRoute.addChildren(${routeTreeChildren}); export const router = createRouter({ routeTree, diff --git a/packages/runtime/plugin-tanstack/tests/router/tanstackTypes.test.ts b/packages/runtime/plugin-tanstack/tests/router/tanstackTypes.test.ts index a3d2cb757ed7..14b9257dc34a 100644 --- a/packages/runtime/plugin-tanstack/tests/router/tanstackTypes.test.ts +++ b/packages/runtime/plugin-tanstack/tests/router/tanstackTypes.test.ts @@ -51,7 +51,12 @@ describe('tanstack router type generation', () => { }); expect(routerGenTs).toContain( - 'import { loader as loader_0, action as action_0 } from "../../routes/mf/page.data";', + [ + 'import {', + ' action as action_0,', + ' loader as loader_0,', + "} from '../../routes/mf/page.data';", + ].join('\n'), ); expect(routerGenTs).toContain('modernRouteLoader: loader_0'); expect(routerGenTs).toContain('modernRouteAction: action_0'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57df2b915134..77242835b168 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -786,8 +786,8 @@ importers: specifier: ^0.5.17 version: 0.5.21 '@tanstack/react-router': - specifier: 1.168.26 - version: 1.168.26(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 1.169.2 + version: 1.169.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) devDependencies: '@modern-js/app-tools': specifier: workspace:* @@ -3467,8 +3467,8 @@ importers: specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) typescript: - specifier: 6.0.3 - version: 6.0.3 + specifier: ^5 + version: 5.9.3 tests/integration/routes-tanstack-create-routes: dependencies: @@ -3501,8 +3501,8 @@ importers: specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) typescript: - specifier: 6.0.3 - version: 6.0.3 + specifier: ^5 + version: 5.9.3 tests/integration/rsbuild-hook: dependencies: @@ -7770,8 +7770,8 @@ packages: resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} engines: {node: '>=20.19'} - '@tanstack/react-router@1.168.26': - resolution: {integrity: sha512-+MV+U5KfMUQGZIU/x8MU3FMRSujxLs678v2jhu1Y8P9ndQBKLVOBYKFY+vv/ypxBUYiyDiOsZkDxPJC8UPo/Ig==} + '@tanstack/react-router@1.169.2': + resolution: {integrity: sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -7783,10 +7783,9 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-core@1.168.18': - resolution: {integrity: sha512-rheeg/+hIHSVw9IDzcc5NJlKamKtKJN/c8rPG9XEmLwHvA4C1WRN/yjMTGgoGNU0xKKjL2AzvUhYMSaBdelbEA==} + '@tanstack/router-core@1.169.2': + resolution: {integrity: sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw==} engines: {node: '>=20.19'} - hasBin: true '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} @@ -18962,11 +18961,11 @@ snapshots: '@tanstack/history@1.161.6': {} - '@tanstack/react-router@1.168.26(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@tanstack/react-router@1.169.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/history': 1.161.6 '@tanstack/react-store': 0.9.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tanstack/router-core': 1.168.18 + '@tanstack/router-core': 1.169.2 isbot: 5.1.40 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -18978,7 +18977,7 @@ snapshots: react-dom: 19.2.6(react@19.2.6) use-sync-external-store: 1.6.0(react@19.2.6) - '@tanstack/router-core@1.168.18': + '@tanstack/router-core@1.169.2': dependencies: '@tanstack/history': 1.161.6 cookie-es: 3.1.1 diff --git a/tests/integration/routes-tanstack-create-routes/package.json b/tests/integration/routes-tanstack-create-routes/package.json index b519a84352b4..ea220c3f02de 100644 --- a/tests/integration/routes-tanstack-create-routes/package.json +++ b/tests/integration/routes-tanstack-create-routes/package.json @@ -19,6 +19,6 @@ "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "typescript": "6.0.3" + "typescript": "^5" } } diff --git a/tests/integration/routes-tanstack/package.json b/tests/integration/routes-tanstack/package.json index d6494b2f467c..ba998317f754 100644 --- a/tests/integration/routes-tanstack/package.json +++ b/tests/integration/routes-tanstack/package.json @@ -19,6 +19,6 @@ "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "typescript": "6.0.3" + "typescript": "^5" } } diff --git a/tests/integration/routes-tanstack/src/modern-tanstack/stream/router.gen.ts b/tests/integration/routes-tanstack/src/modern-tanstack/stream/router.gen.ts index 2d9f31dcf334..0f1e2e98c319 100644 --- a/tests/integration/routes-tanstack/src/modern-tanstack/stream/router.gen.ts +++ b/tests/integration/routes-tanstack/src/modern-tanstack/stream/router.gen.ts @@ -39,7 +39,10 @@ function throwTanstackRedirect(location: string) { } } -function mapParamsForModernLoader(params: Record, hasSplat: boolean) { +function mapParamsForModernLoader( + params: Record, + hasSplat: boolean, +) { if (!hasSplat) { return params; } @@ -53,6 +56,7 @@ function mapParamsForModernLoader(params: Record, hasSplat: bool function createRouteStaticData(opts: { modernRouteId?: string; + modernRouteAction?: unknown; modernRouteLoader?: unknown; }) { const staticData: Record = {}; @@ -65,6 +69,10 @@ function createRouteStaticData(opts: { staticData.modernRouteLoader = opts.modernRouteLoader; } + if (opts.modernRouteAction) { + staticData.modernRouteAction = opts.modernRouteAction; + } + return Object.keys(staticData).length > 0 ? staticData : undefined; } @@ -81,7 +89,9 @@ function modernLoaderToTanstack any>( ctx?.signal || new AbortController().signal; const baseRequest: Request | undefined = - ctx?.context?.request instanceof Request ? ctx.context.request : undefined; + ctx?.context?.request instanceof Request + ? ctx.context.request + : undefined; const href = typeof ctx?.location === 'string' @@ -129,61 +139,66 @@ function modernLoaderToTanstack any>( }; } -import loader_0 from "../../stream/routes/layout.loader"; -import { loader as loader_1 } from "../../stream/routes/page.data"; -import { loader as loader_2 } from "../../stream/routes/optional/[id$]/page.data"; -import { loader as loader_3 } from "../../stream/routes/redirect/page.data"; -import { loader as loader_4 } from "../../stream/routes/user/[id]/page.data"; +import loader_0 from '../../stream/routes/layout.loader'; +import { loader as loader_2 } from '../../stream/routes/optional/[id$]/page.data'; +import { loader as loader_1 } from '../../stream/routes/page.data'; +import { loader as loader_3 } from '../../stream/routes/redirect/page.data'; +import { loader as loader_4 } from '../../stream/routes/user/[id]/page.data'; export const rootRoute = createRootRouteWithContext()({ loader: modernLoaderToTanstack({ hasSplat: false }, loader_0), staticData: createRouteStaticData({ - modernRouteId: "stream_layout", + modernRouteId: 'stream_layout', modernRouteLoader: loader_0, }), }); const route_stream_page = createRoute({ getParentRoute: () => rootRoute, - path: "/", + path: '/', loader: modernLoaderToTanstack({ hasSplat: false }, loader_1), staticData: createRouteStaticData({ - modernRouteId: "stream_page", + modernRouteId: 'stream_page', modernRouteLoader: loader_1, }), }); const route_stream_optional__id$__page = createRoute({ getParentRoute: () => rootRoute, - path: "optional/{-$id}", + path: 'optional/{-$id}', loader: modernLoaderToTanstack({ hasSplat: false }, loader_2), staticData: createRouteStaticData({ - modernRouteId: "stream_optional/(id$)/page", + modernRouteId: 'stream_optional/(id$)/page', modernRouteLoader: loader_2, }), }); const route_stream_redirect_page = createRoute({ getParentRoute: () => rootRoute, - path: "redirect", + path: 'redirect', loader: modernLoaderToTanstack({ hasSplat: false }, loader_3), staticData: createRouteStaticData({ - modernRouteId: "stream_redirect/page", + modernRouteId: 'stream_redirect/page', modernRouteLoader: loader_3, }), }); const route_stream_user__id__page = createRoute({ getParentRoute: () => rootRoute, - path: "user/$id", + path: 'user/$id', loader: modernLoaderToTanstack({ hasSplat: false }, loader_4), staticData: createRouteStaticData({ - modernRouteId: "stream_user/(id)/page", + modernRouteId: 'stream_user/(id)/page', modernRouteLoader: loader_4, }), }); -export const routeTree = rootRoute.addChildren([route_stream_page, route_stream_optional__id$__page, route_stream_redirect_page, route_stream_user__id__page]); +export const routeTree = rootRoute.addChildren([ + route_stream_page, + route_stream_optional__id$__page, + route_stream_redirect_page, + route_stream_user__id__page, +]); export const router = createRouter({ routeTree, diff --git a/tests/integration/routes-tanstack/src/modern-tanstack/string/router.gen.ts b/tests/integration/routes-tanstack/src/modern-tanstack/string/router.gen.ts index a86a20f1bffe..767ce8eb8c65 100644 --- a/tests/integration/routes-tanstack/src/modern-tanstack/string/router.gen.ts +++ b/tests/integration/routes-tanstack/src/modern-tanstack/string/router.gen.ts @@ -39,7 +39,10 @@ function throwTanstackRedirect(location: string) { } } -function mapParamsForModernLoader(params: Record, hasSplat: boolean) { +function mapParamsForModernLoader( + params: Record, + hasSplat: boolean, +) { if (!hasSplat) { return params; } @@ -53,6 +56,7 @@ function mapParamsForModernLoader(params: Record, hasSplat: bool function createRouteStaticData(opts: { modernRouteId?: string; + modernRouteAction?: unknown; modernRouteLoader?: unknown; }) { const staticData: Record = {}; @@ -65,6 +69,10 @@ function createRouteStaticData(opts: { staticData.modernRouteLoader = opts.modernRouteLoader; } + if (opts.modernRouteAction) { + staticData.modernRouteAction = opts.modernRouteAction; + } + return Object.keys(staticData).length > 0 ? staticData : undefined; } @@ -81,7 +89,9 @@ function modernLoaderToTanstack any>( ctx?.signal || new AbortController().signal; const baseRequest: Request | undefined = - ctx?.context?.request instanceof Request ? ctx.context.request : undefined; + ctx?.context?.request instanceof Request + ? ctx.context.request + : undefined; const href = typeof ctx?.location === 'string' @@ -129,80 +139,91 @@ function modernLoaderToTanstack any>( }; } -import loader_0 from "../../string/routes/layout.loader"; -import { loader as loader_1 } from "../../string/routes/page.data"; -import { loader as loader_2 } from "../../string/routes/mutation/page.data"; -import { loader as loader_3 } from "../../string/routes/optional/[id$]/page.data"; -import { loader as loader_4 } from "../../string/routes/redirect/page.data"; -import { loader as loader_5 } from "../../string/routes/user/[id]/page.data"; +import loader_0 from '../../string/routes/layout.loader'; +import { + action as action_2, + loader as loader_2, +} from '../../string/routes/mutation/page.data'; +import { loader as loader_3 } from '../../string/routes/optional/[id$]/page.data'; +import { loader as loader_1 } from '../../string/routes/page.data'; +import { loader as loader_4 } from '../../string/routes/redirect/page.data'; +import { loader as loader_5 } from '../../string/routes/user/[id]/page.data'; export const rootRoute = createRootRouteWithContext()({ loader: modernLoaderToTanstack({ hasSplat: false }, loader_0), staticData: createRouteStaticData({ - modernRouteId: "string_layout", + modernRouteId: 'string_layout', modernRouteLoader: loader_0, }), }); const route_string_blocker_page = createRoute({ getParentRoute: () => rootRoute, - path: "blocker", + path: 'blocker', staticData: createRouteStaticData({ - modernRouteId: "string_blocker/page", + modernRouteId: 'string_blocker/page', }), }); const route_string_page = createRoute({ getParentRoute: () => rootRoute, - path: "/", + path: '/', loader: modernLoaderToTanstack({ hasSplat: false }, loader_1), staticData: createRouteStaticData({ - modernRouteId: "string_page", + modernRouteId: 'string_page', modernRouteLoader: loader_1, }), }); const route_string_mutation_page = createRoute({ getParentRoute: () => rootRoute, - path: "mutation", + path: 'mutation', loader: modernLoaderToTanstack({ hasSplat: false }, loader_2), staticData: createRouteStaticData({ - modernRouteId: "string_mutation/page", + modernRouteId: 'string_mutation/page', modernRouteLoader: loader_2, + modernRouteAction: action_2, }), }); const route_string_optional__id$__page = createRoute({ getParentRoute: () => rootRoute, - path: "optional/{-$id}", + path: 'optional/{-$id}', loader: modernLoaderToTanstack({ hasSplat: false }, loader_3), staticData: createRouteStaticData({ - modernRouteId: "string_optional/(id$)/page", + modernRouteId: 'string_optional/(id$)/page', modernRouteLoader: loader_3, }), }); const route_string_redirect_page = createRoute({ getParentRoute: () => rootRoute, - path: "redirect", + path: 'redirect', loader: modernLoaderToTanstack({ hasSplat: false }, loader_4), staticData: createRouteStaticData({ - modernRouteId: "string_redirect/page", + modernRouteId: 'string_redirect/page', modernRouteLoader: loader_4, }), }); const route_string_user__id__page = createRoute({ getParentRoute: () => rootRoute, - path: "user/$id", + path: 'user/$id', loader: modernLoaderToTanstack({ hasSplat: false }, loader_5), staticData: createRouteStaticData({ - modernRouteId: "string_user/(id)/page", + modernRouteId: 'string_user/(id)/page', modernRouteLoader: loader_5, }), }); -export const routeTree = rootRoute.addChildren([route_string_page, route_string_blocker_page, route_string_mutation_page, route_string_optional__id$__page, route_string_redirect_page, route_string_user__id__page]); +export const routeTree = rootRoute.addChildren([ + route_string_page, + route_string_blocker_page, + route_string_mutation_page, + route_string_optional__id$__page, + route_string_redirect_page, + route_string_user__id__page, +]); export const router = createRouter({ routeTree, diff --git a/tests/integration/routes-tanstack/tests/tanstack-data-flow-contract.test.ts b/tests/integration/routes-tanstack/tests/tanstack-data-flow-contract.test.ts index d86f189bed65..fb4bbe8f7589 100644 --- a/tests/integration/routes-tanstack/tests/tanstack-data-flow-contract.test.ts +++ b/tests/integration/routes-tanstack/tests/tanstack-data-flow-contract.test.ts @@ -33,8 +33,9 @@ describe('tanstack generated data-flow contracts', () => { ); assertTanstackLoaderContract(code); - expect(code).toContain('path: "mutation"'); + expect(code).toContain("path: 'mutation'"); expect(code).toContain('route_string_mutation_page'); + expect(code).toContain('modernRouteAction: action_'); expect(code).toContain('createRouter({'); }); diff --git a/tests/rstest.superapp-contracts.config.mts b/tests/rstest.superapp-contracts.config.mts index 1b932a5aacbb..917da5d8913b 100644 --- a/tests/rstest.superapp-contracts.config.mts +++ b/tests/rstest.superapp-contracts.config.mts @@ -6,10 +6,7 @@ export default withTestPreset({ globals: true, include: [ 'integration/routes-tanstack/tests/tanstack-data-flow-contract.test.ts', - 'integration/routes-tanstack-mf/tests/tanstack-mf-contract.test.ts', - 'integration/i18n/mf/test/app-level-mf-ssr-contract.test.ts', 'integration/routes-tanstack-create-routes/tests/create-routes-contract.test.ts', - 'integration/bff-runtime-parity/tests/effect-only-data-platform.test.ts', ], testTimeout: 1000 * 60 * 5, hookTimeout: 1000 * 60 * 5, From 6c0fea66e4c7922e901ac656eeb61f122522f8f0 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Fri, 15 May 2026 18:47:00 +0200 Subject: [PATCH 5/6] refactor: simplify tanstack loader adapters --- .../plugin-tanstack/src/runtime/routeTree.ts | 171 +++++++----------- 1 file changed, 70 insertions(+), 101 deletions(-) diff --git a/packages/runtime/plugin-tanstack/src/runtime/routeTree.ts b/packages/runtime/plugin-tanstack/src/runtime/routeTree.ts index 42060b137355..1f402ef0752c 100644 --- a/packages/runtime/plugin-tanstack/src/runtime/routeTree.ts +++ b/packages/runtime/plugin-tanstack/src/runtime/routeTree.ts @@ -94,6 +94,70 @@ function createModernRequest(input: string, signal: AbortSignal) { return new Request(input, { signal }); } +function createModernLoaderRequest(ctx: any) { + const signal: AbortSignal = + ctx?.abortController?.signal || ctx?.signal || new AbortController().signal; + const baseRequest: Request | undefined = + ctx?.context?.request instanceof Request ? ctx.context.request : undefined; + + const href = + typeof ctx?.location === 'string' + ? ctx.location + : ctx?.location?.publicHref || + ctx?.location?.href || + ctx?.location?.url?.href || + ''; + + return baseRequest + ? new Request(baseRequest, { signal }) + : createModernRequest(href, signal); +} + +function normalizeModernLoaderResult(result: unknown) { + if (isResponse(result)) { + if (isRedirectResponse(result)) { + const location = result.headers.get('Location') || '/'; + throwTanstackRedirect(location); + } + if (result.status === 404) { + throw notFound(); + } + } + + return result; +} + +function normalizeModernLoaderError(err: unknown): never { + if (isResponse(err)) { + if (isTanstackRedirect(err)) { + throw err; + } + if (isRedirectResponse(err)) { + const location = err.headers.get('Location') || '/'; + throwTanstackRedirect(location); + } + if (err.status === 404) { + throw notFound(); + } + } + throw err; +} + +async function callModernRouteHandler( + handler: (args: any) => any, + ctx: any, + params: Record, +) { + const request = createModernLoaderRequest(ctx); + const result = await handler({ + request, + params, + context: ctx?.context?.requestContext, + }); + + return normalizeModernLoaderResult(result); +} + function wrapModernLoader( modernRoute: NestedRoute | PageRoute, modernLoader: ((args: any) => any) | undefined, @@ -106,64 +170,18 @@ function wrapModernLoader( } catch {} } - const signal: AbortSignal = - ctx?.abortController?.signal || - ctx?.signal || - new AbortController().signal; - const baseRequest: Request | undefined = - ctx?.context?.request instanceof Request - ? ctx.context.request - : undefined; - - const href = - typeof ctx?.location === 'string' - ? ctx.location - : ctx?.location?.publicHref || - ctx?.location?.href || - ctx?.location?.url?.href || - ''; - - const request = baseRequest - ? new Request(baseRequest, { signal }) - : createModernRequest(href, signal); const params = mapParamsForModernLoader({ modernRoute, params: ctx.params || {}, }); - const result = modernLoader - ? await modernLoader({ - request, - params, - context: ctx?.context?.requestContext, - }) - : null; - - if (isResponse(result)) { - if (isRedirectResponse(result)) { - const location = result.headers.get('Location') || '/'; - throwTanstackRedirect(location); - } - if (result.status === 404) { - throw notFound(); - } + if (!modernLoader) { + return null; } - return result; + return callModernRouteHandler(modernLoader, ctx, params); } catch (err) { - if (isResponse(err)) { - if (isTanstackRedirect(err)) { - throw err; - } - if (isRedirectResponse(err)) { - const location = err.headers.get('Location') || '/'; - throwTanstackRedirect(location); - } - if (err.status === 404) { - throw notFound(); - } - } - throw err; + normalizeModernLoaderError(err); } }; } @@ -201,63 +219,14 @@ function wrapRouteObjectLoader(route: RouteObject) { return async (ctx: any) => { try { - const signal: AbortSignal = - ctx?.abortController?.signal || - ctx?.signal || - new AbortController().signal; - const baseRequest: Request | undefined = - ctx?.context?.request instanceof Request - ? ctx.context.request - : undefined; - - const href = - typeof ctx?.location === 'string' - ? ctx.location - : ctx?.location?.publicHref || - ctx?.location?.href || - ctx?.location?.url?.href || - ''; - - const request = baseRequest - ? new Request(baseRequest, { signal }) - : createModernRequest(href, signal); - const params = mapParamsForRouteObjectLoader({ route, params: ctx.params || {}, }); - const result = await routeLoader({ - request, - params, - context: ctx?.context?.requestContext, - } as any); - - if (isResponse(result)) { - if (isRedirectResponse(result)) { - const location = result.headers.get('Location') || '/'; - throwTanstackRedirect(location); - } - if (result.status === 404) { - throw notFound(); - } - } - - return result; + return callModernRouteHandler(routeLoader, ctx, params); } catch (err) { - if (isResponse(err)) { - if (isTanstackRedirect(err)) { - throw err; - } - if (isRedirectResponse(err)) { - const location = err.headers.get('Location') || '/'; - throwTanstackRedirect(location); - } - if (err.status === 404) { - throw notFound(); - } - } - throw err; + normalizeModernLoaderError(err); } }; } From 2a3ac76134399f295b3bf766662cd35d8666c887 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Fri, 15 May 2026 19:48:00 +0200 Subject: [PATCH 6/6] refactor: share tanstack route helpers --- .../plugin-tanstack/src/cli/tanstackTypes.ts | 23 +--------- .../src/runtime/plugin.node.tsx | 43 +++++++------------ .../plugin-tanstack/src/runtime/plugin.tsx | 32 +++++--------- .../plugin-tanstack/src/runtime/routeTree.ts | 30 +------------ .../plugin-tanstack/src/runtime/utils.tsx | 16 +++++++ .../plugin-tanstack/src/tanstackPath.ts | 28 ++++++++++++ 6 files changed, 73 insertions(+), 99 deletions(-) create mode 100644 packages/runtime/plugin-tanstack/src/tanstackPath.ts diff --git a/packages/runtime/plugin-tanstack/src/cli/tanstackTypes.ts b/packages/runtime/plugin-tanstack/src/cli/tanstackTypes.ts index 249fd088cb66..e2fee2489947 100644 --- a/packages/runtime/plugin-tanstack/src/cli/tanstackTypes.ts +++ b/packages/runtime/plugin-tanstack/src/cli/tanstackTypes.ts @@ -2,6 +2,7 @@ import path from 'path'; import type { AppToolsContext } from '@modern-js/app-tools'; import type { NestedRouteForCli, PageRoute } from '@modern-js/types'; import { fs, findExists, formatImportPath, slash } from '@modern-js/utils'; +import { toTanstackPath } from '../tanstackPath'; const reservedWords = 'break case class catch const continue debugger default delete do else export extends finally for function if import in instanceof let new return super switch this throw try typeof var void while with yield enum await implements package protected static interface private public'; @@ -36,28 +37,6 @@ const JS_OR_TS_EXTS = [ '.cts', ] as const; -function toTanstackPath(pathname: string): string { - return pathname - .split('/') - .map(segment => { - if (!segment) { - return segment; - } - if (segment === '*') { - return '$'; - } - if (segment.startsWith(':')) { - const name = segment.slice(1); - if (name.endsWith('?')) { - return `{-$${name.slice(0, -1)}}`; - } - return `$${name}`; - } - return segment; - }) - .join('/'); -} - async function resolveFileNoExt(inputNoExtPath: string) { const file = findExists(JS_OR_TS_EXTS.map(ext => `${inputNoExtPath}${ext}`)); return file ? getPathWithoutExt(file) : null; diff --git a/packages/runtime/plugin-tanstack/src/runtime/plugin.node.tsx b/packages/runtime/plugin-tanstack/src/runtime/plugin.node.tsx index b00f11948fc2..3e08b08090c6 100644 --- a/packages/runtime/plugin-tanstack/src/runtime/plugin.node.tsx +++ b/packages/runtime/plugin-tanstack/src/runtime/plugin.node.tsx @@ -1,50 +1,53 @@ /// -import { - getGlobalLayoutApp, - getGlobalRoutes, - InternalRuntimeContext, - type TInternalRuntimeContext, -} from '@modern-js/runtime/context'; -import type { RuntimePlugin } from '@modern-js/runtime/plugin'; import { merge } from '@modern-js/runtime-utils/merge'; import { - createRequestContext, type RequestContext, + createRequestContext, } from '@modern-js/runtime-utils/node'; -import type { RouteObject } from '@modern-js/runtime-utils/router'; import { time } from '@modern-js/runtime-utils/time'; +import { + InternalRuntimeContext, + type TInternalRuntimeContext, + getGlobalLayoutApp, + getGlobalRoutes, +} from '@modern-js/runtime/context'; +import type { RuntimePlugin } from '@modern-js/runtime/plugin'; import { LOADER_REPORTER_NAME } from '@modern-js/utils/universal/constants'; import { type AnyRouter, + RouterProvider, createMemoryHistory, createRouter, - RouterProvider, } from '@tanstack/react-router'; import { attachRouterServerSsrUtils } from '@tanstack/react-router/ssr/server'; import type React from 'react'; import { Suspense, useContext } from 'react'; import { createModernBasepathRewrite } from './basepathRewrite'; import { + type RouterExtendsHooks, modifyRoutes as modifyRoutesHook, onAfterCreateRouter as onAfterCreateRouterHook, onAfterHydrateRouter as onAfterHydrateRouterHook, onBeforeCreateRouter as onBeforeCreateRouterHook, onBeforeCreateRoutes as onBeforeCreateRoutesHook, onBeforeHydrateRouter as onBeforeHydrateRouterHook, - type RouterExtendsHooks, } from './hooks'; import { + type RouterLifecycleContext, applyRouterServerPrepareResult, createRouterServerSnapshot, - type RouterLifecycleContext, } from './lifecycle'; import { createRouteTreeFromRouteObjects, getModernRouteIdsFromMatches, } from './routeTree'; import type { InternalRouterServerSnapshot, RouterConfig } from './types'; -import { createRouteObjectsFromConfig, urlJoin } from './utils'; +import { + createRouteObjectsFromConfig, + stripSyntheticNotFoundRoute, + urlJoin, +} from './utils'; type ModernTanstackRouterContext = { request: Request; @@ -87,20 +90,6 @@ function createGetSsrHref(request: Request): string { return `${url.pathname}${url.search}${url.hash}`; } -function stripSyntheticNotFoundRoute(routes: RouteObject[]): RouteObject[] { - return routes - .filter(route => !(route.path === '*' && !route.id && !route.loader)) - .map(route => { - if (!route.children?.length) { - return route; - } - return { - ...route, - children: stripSyntheticNotFoundRoute(route.children), - }; - }); -} - function collectRouterErrors( tanstackRouter: AnyRouter, ): Record | undefined { diff --git a/packages/runtime/plugin-tanstack/src/runtime/plugin.tsx b/packages/runtime/plugin-tanstack/src/runtime/plugin.tsx index 7ed17c874ad1..eb45862f3c25 100644 --- a/packages/runtime/plugin-tanstack/src/runtime/plugin.tsx +++ b/packages/runtime/plugin-tanstack/src/runtime/plugin.tsx @@ -1,18 +1,18 @@ /// +import { merge } from '@modern-js/runtime-utils/merge'; +import type { RouteObject } from '@modern-js/runtime-utils/router'; import { + InternalRuntimeContext, getGlobalLayoutApp, getGlobalRoutes, - InternalRuntimeContext, } from '@modern-js/runtime/context'; import type { RuntimePlugin } from '@modern-js/runtime/plugin'; -import { merge } from '@modern-js/runtime-utils/merge'; -import type { RouteObject } from '@modern-js/runtime-utils/router'; import { + RouterProvider, createBrowserHistory, createHashHistory, createRouter, - RouterProvider, useLocation, useMatches, useNavigate, @@ -23,21 +23,25 @@ import * as React from 'react'; import { useContext, useMemo } from 'react'; import { createModernBasepathRewrite } from './basepathRewrite'; import { + type RouterExtendsHooks, modifyRoutes as modifyRoutesHook, onAfterCreateRouter as onAfterCreateRouterHook, onAfterHydrateRouter as onAfterHydrateRouterHook, onBeforeCreateRouter as onBeforeCreateRouterHook, onBeforeCreateRoutes as onBeforeCreateRoutesHook, onBeforeHydrateRouter as onBeforeHydrateRouterHook, - type RouterExtendsHooks, } from './hooks'; import { - applyRouterRuntimeState, type RouterLifecycleContext, + applyRouterRuntimeState, } from './lifecycle'; import { createRouteTreeFromRouteObjects } from './routeTree'; import type { RouterConfig } from './types'; -import { createRouteObjectsFromConfig, urlJoin } from './utils'; +import { + createRouteObjectsFromConfig, + stripSyntheticNotFoundRoute, + urlJoin, +} from './utils'; function normalizeBase(b: string) { if (b.length > 1 && b.endsWith('/')) { @@ -52,20 +56,6 @@ function isSegmentPrefix(pathname: string, base: string) { return p === b || p.startsWith(`${b}/`); } -function stripSyntheticNotFoundRoute(routes: RouteObject[]): RouteObject[] { - return routes - .filter(route => !(route.path === '*' && !route.id && !route.loader)) - .map(route => { - if (!route.children?.length) { - return route; - } - return { - ...route, - children: stripSyntheticNotFoundRoute(route.children), - }; - }); -} - export const tanstackRouterPlugin = ( userConfig: Partial = {}, ): RuntimePlugin<{ diff --git a/packages/runtime/plugin-tanstack/src/runtime/routeTree.ts b/packages/runtime/plugin-tanstack/src/runtime/routeTree.ts index 1f402ef0752c..634f89621d69 100644 --- a/packages/runtime/plugin-tanstack/src/runtime/routeTree.ts +++ b/packages/runtime/plugin-tanstack/src/runtime/routeTree.ts @@ -11,37 +11,9 @@ import { notFound, redirect, } from '@tanstack/react-router'; +import { toTanstackPath } from '../tanstackPath'; import { DefaultNotFound } from './DefaultNotFound'; -function toTanstackPath(pathname: string): string { - // TanStack Router uses `$param` and `$` (splat) style params. - // Modern's conventional routing currently generates React Router style params (e.g. `:id`, `*`). - // - // We only convert the subset Modern generates today: - // - `:id` -> `$id` - // - `:id?` -> `{-$id}` (optional param) - // - `*` -> `$` - return pathname - .split('/') - .map(segment => { - if (!segment) { - return segment; - } - if (segment === '*') { - return '$'; - } - if (segment.startsWith(':')) { - const name = segment.slice(1); - if (name.endsWith('?')) { - return `{-$${name.slice(0, -1)}}`; - } - return `$${name}`; - } - return segment; - }) - .join('/'); -} - function isResponse(value: unknown): value is Response { return ( value != null && diff --git a/packages/runtime/plugin-tanstack/src/runtime/utils.tsx b/packages/runtime/plugin-tanstack/src/runtime/utils.tsx index 510e6a2b5847..bfa772d61344 100644 --- a/packages/runtime/plugin-tanstack/src/runtime/utils.tsx +++ b/packages/runtime/plugin-tanstack/src/runtime/utils.tsx @@ -133,6 +133,22 @@ export function createRouteObjectsFromConfig({ }); } +export function stripSyntheticNotFoundRoute( + routes: RouteObject[], +): RouteObject[] { + return routes + .filter(route => !(route.path === '*' && !route.id && !route.loader)) + .map(route => { + if (!route.children?.length) { + return route; + } + return { + ...route, + children: stripSyntheticNotFoundRoute(route.children), + }; + }); +} + export const urlJoin = (...parts: string[]) => { const separator = '/'; const replace = new RegExp(`${separator}{1,}`, 'g'); diff --git a/packages/runtime/plugin-tanstack/src/tanstackPath.ts b/packages/runtime/plugin-tanstack/src/tanstackPath.ts new file mode 100644 index 000000000000..f2be5f031a19 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/tanstackPath.ts @@ -0,0 +1,28 @@ +export function toTanstackPath(pathname: string): string { + // TanStack Router uses `$param` and `$` (splat) style params. + // Modern's conventional routing currently generates React Router style params (e.g. `:id`, `*`). + // + // We only convert the subset Modern generates today: + // - `:id` -> `$id` + // - `:id?` -> `{-$id}` (optional param) + // - `*` -> `$` + return pathname + .split('/') + .map(segment => { + if (!segment) { + return segment; + } + if (segment === '*') { + return '$'; + } + if (segment.startsWith(':')) { + const name = segment.slice(1); + if (name.endsWith('?')) { + return `{-$${name.slice(0, -1)}}`; + } + return `$${name}`; + } + return segment; + }) + .join('/'); +}