diff --git a/.changeset/create-runtime-tanstack-tailwind.md b/.changeset/create-runtime-tanstack-tailwind.md new file mode 100644 index 000000000000..660d5186e766 --- /dev/null +++ b/.changeset/create-runtime-tanstack-tailwind.md @@ -0,0 +1,10 @@ +--- +'@modern-js/runtime': minor +'@modern-js/plugin-tanstack': minor +--- + +feat(runtime): move TanStack Router integration to `@modern-js/plugin-tanstack` + +- add `@modern-js/plugin-tanstack` runtime/cli package surface +- 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/document/docs/en/apis/app/runtime/router/router.mdx b/packages/document/docs/en/apis/app/runtime/router/router.mdx index dd58ea5d609e..9a8a666d2d61 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 through `@modern-js/plugin-tanstack`, use `@modern-js/plugin-tanstack/runtime` and refer to TanStack Router API docs. + ::: ## hooks 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..d15b1d539cb7 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 uses `@modern-js/plugin-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..abd4bbec2500 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,12 @@ 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`. + +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 9356bcb3fe83..bd2c84a79c20 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`)。 +如果应用通过 `@modern-js/plugin-tanstack` 使用 TanStack Router,请使用 `@modern-js/plugin-tanstack/runtime`,并参考 TanStack Router 官方 API 文档。 + ::: ## hooks 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..b27e2d3809cf 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`)。 +如果你的项目使用 `@modern-js/plugin-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..9284c1bd3f4e 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,12 @@ 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 支持由 `@modern-js/plugin-tanstack` 提供,因此不需要把 TanStack 相关包直接内置到 `@modern-js/runtime` 中。 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/context/runtime.ts b/packages/runtime/plugin-runtime/src/core/context/runtime.ts index 81c4738a6c5d..f216c270213f 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,6 +31,11 @@ export interface TRuntimeContext { */ export interface TInternalRuntimeContext extends TRuntimeContext { routeManifest?: RouteManifest; + routerRuntime?: InternalRouterRuntimeState; + routerInstance?: unknown; + routerHydrationScript?: string; + routerMatchedRouteIds?: string[]; + routerServerSnapshot?: InternalRouterServerSnapshot; routerContext?: StaticHandlerContext; unstable_getBlockNavState?: () => boolean; ssrContext?: SSRServerContext; 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 13fe7da2b8e8..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 = { @@ -114,6 +115,11 @@ function createReplaceSSRData(options: { ? `` : `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 6f14847ff0de..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'; @@ -73,40 +74,56 @@ export async function buildShellBeforeTemplate( async function getCssChunks() { const { routeManifest, routerContext, routes } = runtimeContext; - if (!routeManifest || !routerContext || !routes) { + if (!routeManifest) { return ''; } 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; + + 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, + 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 routeId = match.route.id; - if (routeId) { - const routeManifest = routeAssets[routeId]; - return routeManifest; - } - }) - .filter(Boolean); - const asyncEntry = routeAssets[`async-${entryName}`]; + const asyncEntry = routeAssets[`async-${entryName}`] as + | RouteManifest + | undefined; if (asyncEntry) { matchedRouteManifests?.push(asyncEntry); } 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 6599e95087b2..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 }); @@ -102,6 +120,11 @@ export class SSRDataCollector implements Collector { ? `\n` : `\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 b48d275c0c5f..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'; @@ -109,6 +107,11 @@ export const generateCode = async ( const hooks = api.getHooks(); + const generatedRoutesByEntry: Record< + string, + (NestedRouteForCli | PageRoute)[] + > = {}; + await Promise.all(entrypoints.map(generateEntryCode)); async function generateEntryCode(entrypoint: Entrypoint) { @@ -186,6 +189,10 @@ export const generateCode = async ( entrypoint, routes: markedRoutes, }); + generatedRoutesByEntry[entryName] = routes as ( + | NestedRouteForCli + | PageRoute + )[]; if (ssrMode === 'stream') { const hasPageRoute = routes.some( @@ -282,9 +289,12 @@ export const generateCode = async ( code, 'utf8', ); + } } } + + 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..8a396e64c75a 100644 --- a/packages/runtime/plugin-runtime/src/router/cli/entry.ts +++ b/packages/runtime/plugin-runtime/src/router/cli/entry.ts @@ -1,42 +1,73 @@ -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 hasNestedRoutes = (dir: string) => - fs.existsSync(path.join(dir, NESTED_ROUTES_DIR)); +export const ROUTES_DIR_META_KEY = '__modernRoutesDir'; + +export 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) => { - if (hasNestedRoutes(dir)) { - return path.join(dir, NESTED_ROUTES_DIR); +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; }); }; diff --git a/packages/runtime/plugin-runtime/src/router/cli/handler.ts b/packages/runtime/plugin-runtime/src/router/cli/handler.ts index 690781699747..9d66ebc65358 100644 --- a/packages/runtime/plugin-runtime/src/router/cli/handler.ts +++ b/packages/runtime/plugin-runtime/src/router/cli/handler.ts @@ -1,31 +1,63 @@ -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'; -let originEntrypoints: any[] = []; +type GeneratedRoutesByEntry = Record; + +type RegenerateRoutesFn = (params: { + api: CLIPluginAPI; + appContext: ReturnType['getAppContext']>; + resolvedConfig: AppNormalizedConfig; + entrypoints: Entrypoint[]; +}) => Promise; + +type HandleGeneratorEntryCodeOptions = { + entrypointsKey?: string; +}; + +type HandleFileChangeOptions = { + includeEntry?: (entrypoint: Entrypoint) => boolean; + regenerate?: RegenerateRoutesFn; + entrypointsKey?: string; +}; -export async function handleModifyEntrypoints(entrypoints: Entrypoint[]) { - return modifyEntrypoints(entrypoints); +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[], -) { + 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(); 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 +103,38 @@ 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 +154,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); } } diff --git a/packages/runtime/plugin-runtime/src/router/cli/index.ts b/packages/runtime/plugin-runtime/src/router/cli/index.ts index 4d076c2021d1..5ab9e3191333 100644 --- a/packages/runtime/plugin-runtime/src/router/cli/index.ts +++ b/packages/runtime/plugin-runtime/src/router/cli/index.ts @@ -1,28 +1,89 @@ 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 { filterRoutesForServer } from '@modern-js/utils'; -import { isRouteEntry } from './entry'; import { + filterRoutesForServer, + findExists, + fs, + NESTED_ROUTE_SPEC_FILE, +} from '@modern-js/utils'; +import { NESTED_ROUTES_DIR } from './constants'; +import { getEntrypointRoutesDir, isRouteEntry } from './entry'; +import { + handleFileChange, + handleGeneratorEntryCode, + handleModifyEntrypoints, +} from './handler'; + +export { getEntrypointRoutesDir, isRouteEntry } from './entry'; +export { handleFileChange, handleGeneratorEntryCode, handleModifyEntrypoints, } from './handler'; -export { isRouteEntry } from './entry'; -export { handleFileChange, 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)); +} + +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'], setup: api => { - const nestedRoutes: Record = {}; const nestedRoutesForServer: Record = {}; const { metaName } = api.getAppContext(); @@ -40,8 +101,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 +117,12 @@ export const routerPlugin = (): CliPlugin => ({ .map(route => route.urlPath) .sort((a, b) => (a.length - b.length > 0 ? -1 : 1)); - if (nestedRoutesEntry) { + const shouldInstallBuiltInRouter = + isBuiltInRouteEntrypoint(entrypoint) || + (!isPluginOwnedRouteEntrypoint(entrypoint) && + (hasUserRouterConfig || hasRuntimeRouterConfig)); + + if (shouldInstallBuiltInRouter) { plugins.push({ name: 'router', path: `@${metaName}/runtime/router/internal`, @@ -81,17 +154,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 +179,24 @@ export const routerPlugin = (): CliPlugin => ({ }); api.onBeforeGenerateRoutes(async ({ entrypoint, code }) => { - const { distDirectory } = api.getAppContext(); + 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< + string, + unknown + >) + : {}; - await fs.outputJSON( - path.resolve(distDirectory, NESTED_ROUTE_SPEC_FILE), - nestedRoutesForServer, - ); + await fs.outputJSON(nestedRoutesSpecPath, { + ...existingNestedRoutes, + ...nestedRoutesForServer, + }); + } return { entrypoint, 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/internal.ts b/packages/runtime/plugin-runtime/src/router/runtime/internal.ts index 6db15cd3c00f..34f2263a2c5b 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 { + createRouteObjectsFromConfig, + renderRoutes, + urlJoin, +} from './utils'; export { modifyRoutes } from './plugin'; 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/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-runtime/tests/router/lifecycle.test.tsx b/packages/runtime/plugin-runtime/tests/router/lifecycle.test.tsx new file mode 100644 index 000000000000..a14a865345c9 --- /dev/null +++ b/packages/runtime/plugin-runtime/tests/router/lifecycle.test.tsx @@ -0,0 +1,122 @@ +import fs from 'node:fs'; +import path from 'node:path'; +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'); + } + }); + + 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.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..065f70c03da3 --- /dev/null +++ b/packages/runtime/plugin-runtime/tests/ssr/serverRender/renderToStream/buildTemplate.before.test.ts @@ -0,0 +1,56 @@ +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' }], + }, + } 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 new file mode 100644 index 000000000000..d50ce957c8b2 --- /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.2.0", + "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.169.2" + }, + "peerDependencies": { + "@modern-js/runtime": "workspace:^3.2.0", + "react": ">=17.0.2", + "react-dom": ">=17.0.2" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*", + "@modern-js/rslib": "workspace:*", + "@modern-js/runtime": "workspace:*", + "@rslib/core": "0.21.4", + "@scripts/rstest-config": "workspace:*", + "@tanstack/history": "1.161.6", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", + "@types/node": "^20", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "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.mts b/packages/runtime/plugin-tanstack/rstest.config.mts new file mode 100644 index 000000000000..78962056ea77 --- /dev/null +++ b/packages/runtime/plugin-tanstack/rstest.config.mts @@ -0,0 +1,39 @@ +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/cli.test.ts', + 'tests/router/tanstackTypes.test.ts', + '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..785211d92fee --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/cli/index.ts @@ -0,0 +1,388 @@ +import path from 'node:path'; +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 { + filterRoutesForServer, + fs, + NESTED_ROUTE_SPEC_FILE, +} from '@modern-js/utils'; +import { + generateTanstackRouterTypesSourceForEntry, + isTanstackRouterFrameworkEnabled, +} from './tanstackTypes'; + +export { + generateTanstackRouterTypesSourceForEntry, + isTanstackRouterFrameworkEnabled, +} from './tanstackTypes'; + +const DEFAULT_ROUTES_DIR = 'routes'; +const DEFAULT_GENERATED_DIR_NAME = 'modern-tanstack'; +const ENTRYPOINTS_KEY = '@modern-js/plugin-tanstack'; + +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 previous = (await fs.pathExists(filePath)) + ? await fs.readFile(filePath, 'utf-8') + : null; + if (previous === content) { + return; + } + } catch { + // Fall through and rewrite the generated file. + } + + await fs.outputFile(filePath, content, 'utf-8'); +} + +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(' | '); + + 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 (entries.length === 0) { + return; + } + + const registerDtsPath = path.join( + srcDirectory, + generatedDirName, + 'register.gen.d.ts', + ); + + await writeFileIfChanged( + registerDtsPath, + createRegisterDtsContent({ entries, runtimeModule }), + ); +} + +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 function tanstackRouterPlugin( + options: TanstackRouterPluginOptions = {}, +): 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 }, + }); + + return { entrypoint, plugins }; + }); + + api.checkEntryPoint(({ path: entryPath, entry }) => { + const { isRouteEntry } = getRuntimeRouterCli(); + return { + path: entryPath, + entry: entry || isRouteEntry(entryPath, routesDir), + }; + }); + + api.config(() => { + return { + source: { + include: [ + /[\\/]node_modules[\\/]@tanstack[\\/]react-router[\\/]/, + path.resolve(__dirname, '../runtime').replace('cjs', 'esm'), + ], + }, + }; + }); + + api.modifyEntrypoints(async ({ entrypoints }) => { + const { handleModifyEntrypoints } = getRuntimeRouterCli(); + return { + entrypoints: await handleModifyEntrypoints(entrypoints, routesDir, { + routesOwner: ENTRYPOINTS_KEY, + }), + }; + }); + + api.generateEntryCode(async ({ entrypoints }) => { + const tanstackEntrypoints = entrypoints.filter(isTanstackEntrypoint); + + if (tanstackEntrypoints.length === 0) { + return; + } + + const { handleGeneratorEntryCode } = getRuntimeRouterCli(); + const routesByEntry = await handleGeneratorEntryCode( + api, + tanstackEntrypoints, + { + entrypointsKey: ENTRYPOINTS_KEY, + generateCodeOptions: { + enableTanstackTypes: false, + }, + }, + ); + + await writeTanstackRouterTypesForEntries({ + appContext: api.getAppContext(), + generatedDirName, + routesByEntry, + }); + }); + + 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 writeTanstackRouterTypesForEntries({ + appContext: api.getAppContext(), + generatedDirName, + routesByEntry, + }); + }, + }); + }); + + api.modifyFileSystemRoutes(async ({ entrypoint, routes }) => { + if (isTanstackEntrypoint(entrypoint)) { + nestedRoutesForServer[entrypoint.entryName] = filterRoutesForServer( + routes as (NestedRouteForCli | PageRoute)[], + ); + } + + return { + entrypoint, + routes, + }; + }); + + 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 new file mode 100644 index 000000000000..e2fee2489947 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/cli/tanstackTypes.ts @@ -0,0 +1,514 @@ +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'; +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', + '.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 `'${str + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n')}'`; +} + +function normalizeRelativeImport(p: string) { + const normalized = formatImportPath(slash(p)); + if (normalized.startsWith('.')) { + return normalized; + } + return `./${normalized}`; +} + +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 createRouteStaticDataSnippet(opts: { + modernRouteId?: string; + loaderName?: string | null; + actionName?: string | null; +}) { + const staticDataLines: string[] = []; + + if (opts.modernRouteId) { + staticDataLines.push(`modernRouteId: ${quote(opts.modernRouteId)},`); + } + + if (opts.loaderName) { + staticDataLines.push(`modernRouteLoader: ${opts.loaderName},`); + } + + if (opts.actionName) { + staticDataLines.push(`modernRouteAction: ${opts.actionName},`); + } + + if (!staticDataLines.length) { + return null; + } + + return `staticData: createRouteStaticData({\n ${staticDataLines.join( + '\n ', + )}\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 { + const runtimeConfigBase = path.join( + appContext.srcDirectory, + appContext.runtimeConfigFile, + ); + const runtimeConfigFile = findExists( + JS_OR_TS_EXTS.map(ext => `${runtimeConfigBase}${ext}`), + ); + if (!runtimeConfigFile) { + return false; + } + + 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; + } +} + +export async function generateTanstackRouterTypesSourceForEntry(opts: { + appContext: AppToolsContext; + entryName: string; + generatedDirName?: string; + routes: (NestedRouteForCli | PageRoute)[]; +}): Promise<{ + routerGenTs: string; +}> { + const { + appContext, + entryName, + generatedDirName = 'modern-tanstack', + routes, + } = opts; + const outDir = path.join( + appContext.srcDirectory, + generatedDirName, + entryName, + ); + + const rootModern = routes.find( + r => r && (r as any).type === 'nested' && (r 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 getImportNamesForLoader = async ( + aliasedNoExtPath: string, + inline: boolean, + hasAction: boolean, + ) => { + const key = `${ + inline ? 'inline' : 'default' + }:${hasAction ? 'action' : 'loader'}:${aliasedNoExtPath}`; + const existing = loaderImportMap.get(key); + if (existing) { + return { + loaderName: existing, + actionName: hasAction ? existing.replace(/^loader_/, 'action_') : null, + }; + } + + const prefix = `${appContext.internalSrcAlias}/`; + 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); + } + + const resolvedNoExt = await resolveFileNoExt(absNoExt); + if (!resolvedNoExt) { + return null; + } + + const relImport = normalizeRelativeImport( + path.relative(outDir, resolvedNoExt), + ); + + const importName = `loader_${loaderIndex++}`; + const actionName = hasAction + ? importName.replace(/^loader_/, 'action_') + : null; + if (inline) { + const specifiers = actionName + ? [`action as ${actionName}`, `loader as ${importName}`] + : [`loader as ${importName}`]; + imports.push( + 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)};`); + } + + loaderImportMap.set(key, importName); + return { loaderName: importName, actionName }; + }; + + 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 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 routeOpts: string[] = [`getParentRoute: () => ${parentVar},`]; + + if (isPathlessLayout(route)) { + const id = (route as any).id as string | undefined; + routeOpts.push(`id: ${quote(id || 'pathless')},`); + } else { + const p = isIndexRoute(route) ? '/' : toTanstackPath(rawPath || ''); + routeOpts.push(`path: ${quote(p)},`); + } + + if (loaderName) { + 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 ${routeOpts.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 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 rootOpts: string[] = []; + if (rootLoaderName) { + rootOpts.push( + `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. + +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; + 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, +) { + 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; + } + }; +} + +${sortImportStatements(imports).join('\n')} + +export const rootRoute = createRootRouteWithContext()(${rootRouteOptionsSource}); + +${statements.join('\n\n')} + +export const routeTree = rootRoute.addChildren(${routeTreeChildren}); + +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..ec0a94c846df --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/dataMutation.tsx @@ -0,0 +1,517 @@ +import type { AnyRouter } from '@tanstack/react-router'; +import { useRouter } 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..8e465800fe8b --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/hooks.ts @@ -0,0 +1,34 @@ +import { createSyncHook } from '@modern-js/plugin'; +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, + 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-tanstack/src/runtime/index.tsx b/packages/runtime/plugin-tanstack/src/runtime/index.tsx new file mode 100644 index 000000000000..c6ddca23a191 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/index.tsx @@ -0,0 +1,24 @@ +export * from '@tanstack/react-router'; +export { useMatch } from '@tanstack/react-router'; +export type { + Fetcher, + FetcherState, + FetcherSubmitOptions, + FormProps, + SubmitOptions, +} from './dataMutation'; +export { + Form, + RouteActionResponseError, + useFetcher, +} from './dataMutation'; +export { + tanstackRouterPlugin, + tanstackRouterPlugin as default, +} from './plugin'; +export type { + LinkProps, + NavLinkProps, + PrefetchBehavior, +} from './prefetchLink'; +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..3e08b08090c6 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/plugin.node.tsx @@ -0,0 +1,320 @@ +/// + +import { merge } from '@modern-js/runtime-utils/merge'; +import { + type RequestContext, + createRequestContext, +} from '@modern-js/runtime-utils/node'; +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, +} 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, +} from './hooks'; +import { + type RouterLifecycleContext, + applyRouterServerPrepareResult, + createRouterServerSnapshot, +} from './lifecycle'; +import { + createRouteTreeFromRouteObjects, + getModernRouteIdsFromMatches, +} from './routeTree'; +import type { InternalRouterServerSnapshot, RouterConfig } from './types'; +import { + createRouteObjectsFromConfig, + stripSyntheticNotFoundRoute, + 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 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 new file mode 100644 index 000000000000..eb45862f3c25 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/plugin.tsx @@ -0,0 +1,266 @@ +/// + +import { merge } from '@modern-js/runtime-utils/merge'; +import type { RouteObject } from '@modern-js/runtime-utils/router'; +import { + InternalRuntimeContext, + getGlobalLayoutApp, + getGlobalRoutes, +} from '@modern-js/runtime/context'; +import type { RuntimePlugin } from '@modern-js/runtime/plugin'; +import { + RouterProvider, + createBrowserHistory, + createHashHistory, + createRouter, + useLocation, + useMatches, + useNavigate, + useRouter, +} from '@tanstack/react-router'; +import { RouterClient } from '@tanstack/react-router/ssr/client'; +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, +} from './hooks'; +import { + type RouterLifecycleContext, + applyRouterRuntimeState, +} from './lifecycle'; +import { createRouteTreeFromRouteObjects } from './routeTree'; +import type { RouterConfig } from './types'; +import { + createRouteObjectsFromConfig, + stripSyntheticNotFoundRoute, + urlJoin, +} from './utils'; + +function normalizeBase(b: string) { + if (b.length > 1 && b.endsWith('/')) { + return b.slice(0, -1); + } + return b || '/'; +} + +function isSegmentPrefix(pathname: string, base: string) { + const b = normalizeBase(base); + const p = pathname || '/'; + return p === b || p.startsWith(`${b}/`); +} + +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(context => { + context.router = { + useMatches, + useLocation, + useNavigate, + useRouter, + }; + }); + + api.wrapRoot(App => { + const mergedConfig = merge( + api.getRuntimeConfig().router || {}, + 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 = () => { + if (typeof cachedRouteObjects !== 'undefined') { + return cachedRouteObjects; + } + + 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); + + const baseUrl = selectBasePath(location.pathname).replace( + /^\/*/, + '/', + ); + const _basename = + baseUrl === '/' + ? urlJoin( + baseUrl, + runtimeContext._internalRouterBaseName || basename || '', + ) + : baseUrl; + + const routeTree = useMemo(() => { + if (cachedRouteTree) { + return cachedRouteTree; + } + const routeObjects = getRouteObjects(); + if (!routeObjects.length) { + return null; + } + cachedRouteTree = createRouteTreeFromRouteObjects(routeObjects); + return cachedRouteTree; + }, []); + + if (!routeTree) { + return App ? : null; + } + + const router = useMemo(() => { + 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 = supportHtml5History + ? createBrowserHistory() + : createHashHistory(); + + const rewrite = createModernBasepathRewrite(_basename); + + cachedRouter = createRouter({ + routeTree, + basepath: '/', + rewrite, + history, + context: {}, + }); + cachedRouterBasepath = _basename; + hooks.onAfterCreateRouter.call({ + ...lifecycleContext, + router: cachedRouter, + runtimeContext, + }); + + return cachedRouter; + }, [_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 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; + }); + }, + }; +}; + +export default tanstackRouterPlugin; 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..37a9bdc9e997 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/prefetchLink.tsx @@ -0,0 +1,70 @@ +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..634f89621d69 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/routeTree.ts @@ -0,0 +1,458 @@ +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 { toTanstackPath } from '../tanstackPath'; +import { DefaultNotFound } from './DefaultNotFound'; + +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 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, +) { + return async (ctx: any) => { + try { + if (typeof (modernRoute as any).lazyImport === 'function') { + try { + (modernRoute as any).lazyImport(); + } catch {} + } + + const params = mapParamsForModernLoader({ + modernRoute, + params: ctx.params || {}, + }); + + if (!modernLoader) { + return null; + } + + return callModernRouteHandler(modernLoader, ctx, params); + } catch (err) { + normalizeModernLoaderError(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 params = mapParamsForRouteObjectLoader({ + route, + params: ctx.params || {}, + }); + + return callModernRouteHandler(routeLoader, ctx, params); + } catch (err) { + normalizeModernLoaderError(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: RouteObject) => + 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( + r => r && (r as any).type === 'nested' && (r 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((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..bfa772d61344 --- /dev/null +++ b/packages/runtime/plugin-tanstack/src/runtime/utils.tsx @@ -0,0 +1,174 @@ +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 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'); + 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/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('/'); +} 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 new file mode 100644 index 000000000000..bb32c1369c0f --- /dev/null +++ b/packages/runtime/plugin-tanstack/tests/router/dataMutation.test.tsx @@ -0,0 +1,396 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React, { 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/tests/router/tanstackTypes.test.ts b/packages/runtime/plugin-tanstack/tests/router/tanstackTypes.test.ts new file mode 100644 index 000000000000..14b9257dc34a --- /dev/null +++ b/packages/runtime/plugin-tanstack/tests/router/tanstackTypes.test.ts @@ -0,0 +1,67 @@ +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 {', + ' 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'); + expect(routerGenTs).toContain( + "} from '@modern-js/plugin-tanstack/runtime';", + ); + }); +}); 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"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f1fdff6a3cc..77242835b168 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.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:* + 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: ^5 + version: 5.9.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: ^5 + version: 5.9.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,30 @@ 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.169.2': + resolution: {integrity: sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ==} + 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.169.2': + resolution: {integrity: sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw==} + engines: {node: '>=20.19'} + + '@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 +8089,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 +8232,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 +9058,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 +9288,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 +9352,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 +9860,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 +10125,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 +10241,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 +11244,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 +11275,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 +13088,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 +13158,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 +13502,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 +14034,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 +14222,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 +14520,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 +14796,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 +14812,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 +16200,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 +16249,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 +16652,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 +16673,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 +17019,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 +17031,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 +17086,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 +17149,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 +17186,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 +17196,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 +17210,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 +17243,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 +17267,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 +17284,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 +17292,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 +17332,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 +17347,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 +17367,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 +17377,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 +17398,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 +17416,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 +17631,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 +18870,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 +18959,33 @@ snapshots: postcss: 8.5.14 tailwindcss: 4.1.18 + '@tanstack/history@1.161.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.169.2 + 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.169.2': + 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 +19046,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 +19074,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 +19090,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 +19114,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 +19267,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 +19297,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 +19323,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 +19346,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 +19361,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 +19376,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 +19416,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 +19454,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 +19474,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 +19506,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 +19515,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 +19542,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 +19552,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 +20100,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 +20477,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 +20677,8 @@ snapshots: cookie-es@1.2.3: {} + cookie-es@3.1.1: {} + cookie-signature@1.0.7: {} cookie@0.7.2: {} @@ -20411,12 +20717,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 +20741,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 +20750,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 +21337,7 @@ snapshots: dependencies: dequal: 2.0.3 - devtools-protocol@0.0.1581282: {} + devtools-protocol@0.0.1608973: {} dezalgo@1.0.4: dependencies: @@ -21185,7 +21491,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 +21507,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 +21642,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 +21796,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 +22354,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 +22700,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 +23026,8 @@ snapshots: isbot@3.8.0: {} + isbot@5.1.40: {} + isexe@2.0.0: {} isobject@3.0.1: {} @@ -22732,24 +23057,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 +24871,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 +25205,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 +25284,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 +25301,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 +25744,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 +26308,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 +26564,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 +26585,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 +26593,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 +26804,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 +26934,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 +26960,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 +27005,6 @@ snapshots: tapable@2.2.1: {} - tapable@2.3.0: {} - tapable@2.3.2: {} tapable@2.3.3: {} @@ -26832,6 +27246,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 +27286,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 +27392,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 +27402,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..ea220c3f02de --- /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": "^5" + } +} 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..ba998317f754 --- /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": "^5" + } +} 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..0f1e2e98c319 --- /dev/null +++ b/tests/integration/routes-tanstack/src/modern-tanstack/stream/router.gen.ts @@ -0,0 +1,209 @@ +/* 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; + 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, +) { + 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_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', + 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..767ce8eb8c65 --- /dev/null +++ b/tests/integration/routes-tanstack/src/modern-tanstack/string/router.gen.ts @@ -0,0 +1,234 @@ +/* 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; + 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, +) { + 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 { + 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', + 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, + modernRouteAction: action_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..fb4bbe8f7589 --- /dev/null +++ b/tests/integration/routes-tanstack/tests/tanstack-data-flow-contract.test.ts @@ -0,0 +1,52 @@ +/** + * @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('modernRouteAction: action_'); + 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..917da5d8913b --- /dev/null +++ b/tests/rstest.superapp-contracts.config.mts @@ -0,0 +1,13 @@ +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-create-routes/tests/create-routes-contract.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())); + }; +}