diff --git a/.changeset/tidy-router-shares.md b/.changeset/tidy-router-shares.md new file mode 100644 index 0000000..71eba9b --- /dev/null +++ b/.changeset/tidy-router-shares.md @@ -0,0 +1,5 @@ +--- +"@module-federation/tanstack": patch +--- + +Share TanStack Router runtimes by default so router-aware remote components can preserve the host Router context across SSR bundlers. diff --git a/apps/rsbuild-host/package.json b/apps/rsbuild-host/package.json index fad3a00..8b16668 100644 --- a/apps/rsbuild-host/package.json +++ b/apps/rsbuild-host/package.json @@ -13,6 +13,7 @@ "dependencies": { "@module-federation/runtime": "2.9.2", "@tanstack/react-router": "1.170.41", + "@tanstack/router-core": "1.171.34", "@tanstack/react-start": "1.168.60", "example-host-context": "workspace:*", "react": "19.3.0", diff --git a/apps/rsbuild-host/rsbuild.config.ts b/apps/rsbuild-host/rsbuild.config.ts index caec5e4..1b99537 100644 --- a/apps/rsbuild-host/rsbuild.config.ts +++ b/apps/rsbuild-host/rsbuild.config.ts @@ -14,7 +14,9 @@ export default defineConfig({ dts: false, name: "tanstack_rsbuild_host", // Workspace dependencies declare "workspace:*", which is not a semver range. - shared: { "example-host-context": { requiredVersion: "*", singleton: true } }, + shared: { + "example-host-context": { requiredVersion: "*", singleton: true }, + }, // Resolve remotes on demand so one offline remote cannot block host startup. shareStrategy: "loaded-first", remotes: { diff --git a/apps/rsbuild-host/src/routes/index.tsx b/apps/rsbuild-host/src/routes/index.tsx index 41b643c..24330d0 100644 --- a/apps/rsbuild-host/src/routes/index.tsx +++ b/apps/rsbuild-host/src/routes/index.tsx @@ -18,6 +18,10 @@ function Home() { fallback="Loading the Vite remote…" remote="tanstack_vite_remote/StatusCard" /> + ); diff --git a/apps/rsbuild-ssr-host/package.json b/apps/rsbuild-ssr-host/package.json index c8e67d3..fd71271 100644 --- a/apps/rsbuild-ssr-host/package.json +++ b/apps/rsbuild-ssr-host/package.json @@ -14,6 +14,7 @@ "@module-federation/runtime": "2.9.2", "@module-federation/vite": "1.23.3", "@tanstack/react-router": "1.170.41", + "@tanstack/router-core": "1.171.34", "@tanstack/react-start": "1.168.60", "example-host-context": "workspace:*", "react": "19.3.0", diff --git a/apps/rsbuild-ssr-host/rsbuild.config.ts b/apps/rsbuild-ssr-host/rsbuild.config.ts index f02e28e..632322c 100644 --- a/apps/rsbuild-ssr-host/rsbuild.config.ts +++ b/apps/rsbuild-ssr-host/rsbuild.config.ts @@ -14,7 +14,9 @@ export default defineConfig({ dts: false, name: "tanstack_rsbuild_ssr_host", // Workspace dependencies declare "workspace:*", which is not a semver range. - shared: { "example-host-context": { requiredVersion: "*", singleton: true } }, + shared: { + "example-host-context": { requiredVersion: "*", singleton: true }, + }, shareStrategy: "loaded-first", remotes: { tanstack_rsbuild_ssr_remote: diff --git a/apps/rsbuild-ssr-host/src/remote.d.ts b/apps/rsbuild-ssr-host/src/remote.d.ts index c46c4d0..7df5d82 100644 --- a/apps/rsbuild-ssr-host/src/remote.d.ts +++ b/apps/rsbuild-ssr-host/src/remote.d.ts @@ -11,3 +11,10 @@ declare module "tanstack_vite_remote/StatusCard" { const StatusCard: ComponentType; export default StatusCard; } + +declare module "tanstack_vite_remote/RouterCard" { + import type { ComponentType } from "react"; + + const RouterCard: ComponentType; + export default RouterCard; +} diff --git a/apps/rsbuild-ssr-host/src/routes/index.tsx b/apps/rsbuild-ssr-host/src/routes/index.tsx index fa5dc6e..9906bf6 100644 --- a/apps/rsbuild-ssr-host/src/routes/index.tsx +++ b/apps/rsbuild-ssr-host/src/routes/index.tsx @@ -6,6 +6,7 @@ import { RemoteBoundary } from "../components/RemoteBoundary"; // outage renders the remote once it is back. const RsbuildStatusCard = lazyRemote(() => import("tanstack_rsbuild_ssr_remote/StatusCard")); const ViteStatusCard = lazyRemote(() => import("tanstack_vite_remote/StatusCard")); +const ViteRouterCard = lazyRemote(() => import("tanstack_vite_remote/RouterCard")); // Manifests of the remotes this route renders on the server. const serverRenderedManifests = [ @@ -25,6 +26,7 @@ export const Route = createFileRoute("/")({ ).then((lists) => lists.flat()), import("tanstack_rsbuild_ssr_remote/StatusCard").catch(() => null), import("tanstack_vite_remote/StatusCard").catch(() => null), + import("tanstack_vite_remote/RouterCard").catch(() => null), ]); return { stylesheets }; }, @@ -51,6 +53,9 @@ function Home() { + + + ); diff --git a/apps/vite-host/package.json b/apps/vite-host/package.json index c822690..0c2d0fe 100644 --- a/apps/vite-host/package.json +++ b/apps/vite-host/package.json @@ -13,6 +13,7 @@ "dependencies": { "@module-federation/runtime": "2.9.2", "@tanstack/react-router": "1.170.41", + "@tanstack/router-core": "1.171.34", "@tanstack/react-start": "1.168.60", "example-host-context": "workspace:*", "react": "19.3.0", diff --git a/apps/vite-host/src/remote.d.ts b/apps/vite-host/src/remote.d.ts index 9342b12..c1da129 100644 --- a/apps/vite-host/src/remote.d.ts +++ b/apps/vite-host/src/remote.d.ts @@ -5,6 +5,13 @@ declare module "tanstack_vite_remote/StatusCard" { export default StatusCard; } +declare module "tanstack_vite_remote/RouterCard" { + import type { ComponentType } from "react"; + + const RouterCard: ComponentType; + export default RouterCard; +} + declare module "tanstack_rsbuild_remote/StatusCard" { import type { ComponentType } from "react"; diff --git a/apps/vite-host/src/routes/index.tsx b/apps/vite-host/src/routes/index.tsx index d889520..8ea7217 100644 --- a/apps/vite-host/src/routes/index.tsx +++ b/apps/vite-host/src/routes/index.tsx @@ -6,6 +6,7 @@ import { RemoteBoundary } from "../components/RemoteBoundary"; // Unlike React.lazy, lazyRemote loads again after a failure, so a server that hit a remote // outage renders the remote once it is back. const RemoteStatusCard = lazyRemote(() => import("tanstack_vite_remote/StatusCard")); +const RemoteRouterCard = lazyRemote(() => import("tanstack_vite_remote/RouterCard")); const RsbuildStatusCard = lazyRemote(() => import("tanstack_rsbuild_remote/StatusCard")); const RsbuildSsrStatusCard = lazyRemote(() => import("tanstack_rsbuild_ssr_remote/StatusCard")); @@ -26,6 +27,7 @@ export const Route = createFileRoute("/")({ ), ).then((lists) => lists.flat()), import("tanstack_vite_remote/StatusCard").catch(() => null), + import("tanstack_vite_remote/RouterCard").catch(() => null), import("tanstack_rsbuild_ssr_remote/StatusCard").catch(() => null), ]); return { stylesheets }; @@ -62,6 +64,10 @@ function Home() { + + + + diff --git a/apps/vite-remote/package.json b/apps/vite-remote/package.json index 87f7d6a..45fe34a 100644 --- a/apps/vite-remote/package.json +++ b/apps/vite-remote/package.json @@ -13,6 +13,7 @@ "dependencies": { "@module-federation/runtime": "2.9.2", "@tanstack/react-router": "1.170.41", + "@tanstack/router-core": "1.171.34", "@tanstack/react-start": "1.168.60", "example-host-context": "workspace:*", "react": "19.3.0", diff --git a/apps/vite-remote/src/components/RouterCard.tsx b/apps/vite-remote/src/components/RouterCard.tsx new file mode 100644 index 0000000..25029d1 --- /dev/null +++ b/apps/vite-remote/src/components/RouterCard.tsx @@ -0,0 +1,25 @@ +import { Link, useNavigate } from "@tanstack/react-router"; +import "./StatusCard.css"; + +export default function RouterCard() { + const navigate = useNavigate(); + + return ( +
+

Router-aware remote

+

Remote uses the host router

+

+ This exposed component reads the host's TanStack Router context through a shared router + runtime. +

+

+ + Remote link + +

+ +
+ ); +} diff --git a/apps/vite-remote/vite.config.ts b/apps/vite-remote/vite.config.ts index ca36c2a..8d72433 100644 --- a/apps/vite-remote/vite.config.ts +++ b/apps/vite-remote/vite.config.ts @@ -9,8 +9,11 @@ export default defineConfig({ name: "tanstack_vite_remote", dts: false, // Workspace dependencies declare "workspace:*", which is not a semver range. - shared: { "example-host-context": { requiredVersion: "*", singleton: true } }, + shared: { + "example-host-context": { requiredVersion: "*", singleton: true }, + }, exposes: { + "./RouterCard": "./src/components/RouterCard.tsx", "./StatusCard": "./src/components/StatusCard.tsx", }, }), diff --git a/packages/tanstack/README.md b/packages/tanstack/README.md index d419ffb..0a08f5e 100644 --- a/packages/tanstack/README.md +++ b/packages/tanstack/README.md @@ -53,10 +53,11 @@ Defaults: - `filename: "remoteEntry.js"`, `manifest: true`, and `hostInitInjectLocation: "entry"`. -- `react` and `react-dom` are shared singletons. When the config declares - `remotes`, they are also `eager`, so the host's own React stays in the share - scope and a remote built by another bundler cannot replace it. Remote-only - builds stay lazy. +- `react`, `react-dom`, `@tanstack/react-router`, and `@tanstack/router-core` + are shared singletons. When the config declares `remotes`, they are also + `eager`, so the host's own React and Router runtimes stay in the share scope + and a remote built by another bundler cannot replace them. Remote-only builds + stay lazy. - Any entry you pass in `shared` replaces the default for that package. A Vite host renders before lazy shared modules resolve, so mark any other shared @@ -129,8 +130,9 @@ can be consumed by Vite and Rsbuild hosts. Defaults: -- `react` and `react-dom` are shared singletons, `eager` for hosts (configs with - `remotes`) and lazy for remote-only builds. +- `react`, `react-dom`, `@tanstack/react-router`, and `@tanstack/router-core` + are shared singletons, `eager` for hosts (configs with `remotes`) and lazy + for remote-only builds. - `experiments.asyncStartup: true`, so entries wait for shared modules before running. Pass `experiments: { asyncStartup: false }` to opt out. - A remote's browser build uses `publicPath: "auto"`, so hosts on other origins diff --git a/packages/tanstack/src/node-entry-loader.ts b/packages/tanstack/src/node-entry-loader.ts index 2bec321..c7d19f2 100644 --- a/packages/tanstack/src/node-entry-loader.ts +++ b/packages/tanstack/src/node-entry-loader.ts @@ -1,10 +1,23 @@ import type { ModuleFederation, ModuleFederationRuntimePlugin } from "@module-federation/runtime"; +import { tanstackStartSharedPackages } from "./shared"; type LoadEntryArgs = Parameters>[0]; type RemoteEntryExports = NonNullable; type RemoteInfo = LoadEntryArgs["remoteInfo"]; type FederationHost = LoadEntryArgs["origin"]; type NodeModule = typeof import("node:module"); +type RuntimeShareConfig = { + requiredVersion: false | string; + eager?: boolean; + singleton?: boolean; + strictVersion?: boolean; + layer?: string | null; +}; + +const tanstackStartResolutionPackages = [ + "@tanstack/react-start", + ...tanstackStartSharedPackages, +] as const; export interface NodeEntryLoaderOptions { /** Re-check manifest/server entries after this many milliseconds. Omit to cache until exit. */ @@ -41,9 +54,12 @@ export class RemoteEntryError extends Error { * - CommonJS containers (Rsbuild remotes) are fetched and compiled here as CommonJS * modules, so their own `import()` calls work. Module Federation's default Node loader * evaluates them with `vm`, which Rsbuild's development runner cannot support. - * - ES module entries (Vite remotes) go to `@module-federation/vite`'s SSR loader, which - * handles both production builds and Vite dev servers. It is loaded with Node's - * `require`, not the bundle's, so its `import()` calls work in Rsbuild's runner too. + * - ES module entries (Vite remotes) use `@module-federation/vite`'s SSR loader in production. + * Vite 8 dev entries use the same ModuleRunner protocol with a host-aware transport, because + * TanStack Start's dev server exposes its SSR entry through the client environment and would + * otherwise evaluate optimized React and Router dependencies locally. + * The loader is loaded with Node's `require`, not the bundle's, so its `import()` calls work + * in Rsbuild's runner too. */ export default function nodeEntryLoaderPlugin( options: NodeEntryLoaderOptions = {}, @@ -55,6 +71,10 @@ export default function nodeEntryLoaderPlugin( return { name: "tanstack-start-node-entry-loader", ...revalidationHooks, + apply(origin) { + if (!isNodeServer()) return; + registerNodeSharedModules(origin); + }, async loadEntry(args) { const { remoteInfo } = args; if (!isNodeServer() || !/^https?:\/\//.test(remoteInfo.entry)) return undefined; @@ -95,6 +115,49 @@ function appRequires() { .map((base) => createRequire(base)); } +type ResolvedPackage = { path: string; require: NodeJS.Require }; + +function resolvePackage( + specifier: string, + requires: NodeJS.Require[], +): ResolvedPackage | undefined { + for (const require of requires) { + const resolved = resolveFromRequire(specifier, require); + if (resolved) return { path: resolved, require }; + } + + // pnpm can place a transitive shared dependency below the package that imports it. In that + // case resolving from the app root fails even though the package is part of the app's graph. + // Resolve through TanStack Start's package roots before giving up. + for (const parentPackage of tanstackStartResolutionPackages) { + for (const appRequire of requires) { + const parent = resolvePackageRoot(parentPackage, appRequire); + if (!parent) continue; + + const parentRequire = nodeModule().createRequire(parent); + const resolved = resolveFromRequire(specifier, parentRequire); + if (resolved) return { path: resolved, require: parentRequire }; + } + } + + return undefined; +} + +function resolveFromRequire(specifier: string, require: NodeJS.Require) { + try { + return require.resolve(specifier); + } catch { + return undefined; + } +} + +function resolvePackageRoot(specifier: string, require: NodeJS.Require) { + return ( + resolveFromRequire(specifier, require) ?? + resolveFromRequire(`${specifier}/package.json`, require) + ); +} + async function loadCommonJsEntry( { entry, entryGlobalName, name }: RemoteInfo, fetchTimeoutMs = FETCH_TIMEOUT_MS, @@ -192,6 +255,66 @@ type ViteLoader = { const viteLoaders = new WeakMap(); +function registerNodeSharedModules(origin: FederationHost) { + const requires = appRequires(); + const shared: Record< + string, + { + version: string; + lib: () => any; + loaded: true; + scope?: string | string[]; + shareConfig: RuntimeShareConfig; + } + > = {}; + + for (const packageName of tanstackStartSharedPackages) { + const resolved = resolvePackage(packageName, requires); + if (!resolved) continue; + + const version = readPackageVersion(resolved.path, packageName); + if (!version) continue; + + const configured = origin.options.shared[packageName]?.[0] as + { scope?: string | string[]; shareConfig?: Record } | undefined; + shared[packageName] = { + version, + lib: () => resolved.require(resolved.path), + loaded: true, + scope: configured?.scope, + shareConfig: { + eager: true, + singleton: true, + requiredVersion: version, + ...configured?.shareConfig, + } as RuntimeShareConfig, + }; + } + + if (Object.keys(shared).length > 0) origin.registerShared(shared); +} + +function readPackageVersion(filename: string, packageName: string) { + const path = process.getBuiltinModule("node:path") as typeof import("node:path"); + const fs = process.getBuiltinModule("node:fs") as typeof import("node:fs"); + let directory = path.dirname(filename); + + while (true) { + try { + const packageJson = JSON.parse( + fs.readFileSync(path.join(directory, "package.json"), "utf8"), + ) as { name?: string; version?: string }; + if (packageJson.name === packageName) return packageJson.version; + } catch { + // Continue walking toward the application root. + } + + const parent = path.dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } +} + async function loadModuleEntry(args: LoadEntryArgs, options: NodeEntryLoaderOptions) { const { entry, name } = args.remoteInfo; let viteLoader = viteLoaders.get(args.origin); @@ -204,7 +327,10 @@ async function loadModuleEntry(args: LoadEntryArgs, options: NodeEntryLoaderOpti let remoteEntry: RemoteEntryExports | void; try { - remoteEntry = await viteLoader.plugin.loadEntry?.(args); + const devEntry = resolveViteDevEntry(entry); + remoteEntry = devEntry + ? await loadViteDevEntry(devEntry, viteLoader) + : await viteLoader.plugin.loadEntry?.(args); } catch (cause) { const { httpError } = viteLoader; throw new RemoteEntryError( @@ -239,23 +365,108 @@ async function loadModuleEntry(args: LoadEntryArgs, options: NodeEntryLoaderOpti return remoteEntry; } +type ViteModuleRunner = { import(url: string): Promise }; + +const viteDevRunners = new Map>(); + +/** Uses the Vite 8 runner protocol for TanStack Start's development SSR entries. */ +function resolveViteDevEntry(entry: string) { + return entry.includes("/__mf_ssr__/") ? entry : undefined; +} + +async function loadViteDevEntry( + entry: string, + loader: ViteLoader, +): Promise { + const remoteOrigin = new URL(entry).origin; + const runnerKey = `${remoteOrigin}:${JSON.stringify(loader.resolvedShared)}`; + let runner = viteDevRunners.get(runnerKey); + if (!runner) { + runner = createViteDevRunner(remoteOrigin, loader); + viteDevRunners.set(runnerKey, runner); + } + return runner + .then((module) => module.import(new URL(entry).pathname)) + .catch((error: unknown) => { + if (viteDevRunners.get(runnerKey) === runner) viteDevRunners.delete(runnerKey); + throw error; + }); +} + +async function createViteDevRunner( + remoteOrigin: string, + loader: ViteLoader, +): Promise { + const { ModuleRunner, ESModulesEvaluator } = loader.require("vite/module-runner") as { + ModuleRunner: new ( + options: { + hmr: false; + transport: { invoke(payload: unknown): Promise }; + }, + evaluator: unknown, + ) => ViteModuleRunner; + ESModulesEvaluator: new () => unknown; + }; + const runnerEndpoint = `${remoteOrigin}/__mf_runner__`; + const pathToFileURL = (process.getBuiltinModule("node:url") as typeof import("node:url")) + .pathToFileURL; + + return new ModuleRunner( + { + hmr: false, + transport: { + async invoke(payload: unknown) { + const response = await fetch(runnerEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + const result = await response.json(); + const data = payload as { + data?: { data?: [string]; name?: string }; + }; + const id = data.data?.data?.[0]; + const sharedPath = + data.data?.name === "fetchModule" && id + ? resolveViteSharedPath(id, loader.resolvedShared) + : undefined; + if (sharedPath) { + return { + result: { externalize: pathToFileURL(sharedPath).href, type: "module" }, + }; + } + return result; + }, + }, + }, + new ESModulesEvaluator(), + ); +} + +function resolveViteSharedPath(id: string, resolvedShared: Record) { + const cleanId = id.split("?", 1)[0] ?? id; + for (const [packageName, filename] of Object.entries(resolvedShared).sort( + ([left], [right]) => right.length - left.length, + )) { + if ( + cleanId === packageName || + cleanId.startsWith(`${packageName}/`) || + cleanId.includes(`/node_modules/.vite/deps/${packageName.replaceAll("/", "_")}`) + ) { + return filename; + } + } + return undefined; +} + function createViteLoader( { origin, remoteInfo }: LoadEntryArgs, options: NodeEntryLoaderOptions, ): ViteLoader { const requires = appRequires(); - const resolve = (specifier: string) => { - for (const require of requires) { - try { - return { path: require.resolve(specifier), require }; - } catch { - // Try the next base. - } - } - return undefined; - }; - const loader = resolve("@module-federation/vite/ssrEntryLoader"); + const loader = resolvePackage("@module-federation/vite/ssrEntryLoader", requires); if (!loader) { throw new RemoteEntryError( `Remote "${remoteInfo.name}" is a Vite remote. Add @module-federation/vite to the ` + @@ -266,10 +477,9 @@ function createViteLoader( const resolvedShared: Record = {}; for (const specifier of [...VITE_SHARED_PACKAGES, ...Object.keys(origin.options.shared)]) { - const resolved = resolve(specifier); + const resolved = resolvePackage(specifier, requires); if (resolved) resolvedShared[specifier] = resolved.path; } - // The loader is an ES module; Node 22.12+ requires it synchronously. const loaderModule = loader.require(loader.path) as ViteSsrEntryLoaderModule; return { @@ -393,9 +603,9 @@ function invalidateRemote( /** * Vite remotes can import shared packages from node_modules instead of the share scope: - * a Vite dev server's module runner externalizes them, and resolved imports in production - * builds point there. The host bundles its own copies, so this registers them in Node's - * module cache under their node_modules paths. The process then keeps one React. + * the Vite dev transport and the production loader both resolve them to the host's files. + * Registering those files in Node's module cache and Vite's shared-module cache keeps one + * instance of every configured package, including the React and Router runtimes. */ function provideHostModules( origin: FederationHost, @@ -405,21 +615,59 @@ function provideHostModules( const Module = nodeModule() as unknown as new (id: string) => CompilableModule & { loaded: boolean; }; - for (const scope of Object.values(origin.shareScopeMap)) { + for (const [scopeName, scope] of Object.entries(origin.shareScopeMap)) { for (const [packageName, versions] of Object.entries(scope)) { - const filename = resolvedShared[packageName]; - // Something already loaded this package from node_modules; nothing to align. - if (!filename || require.cache[filename]) continue; const provided = Object.values(versions).find( (shared) => shared.from === origin.name && shared.loaded && shared.lib, ); if (!provided?.lib) continue; + const sharedModule = provided.lib(); + provideViteSharedModule(scopeName, packageName, sharedModule); + const filename = + resolvedShared[packageName] ?? findCachedModuleFilename(sharedModule, require); + if (filename) resolvedShared[packageName] ??= filename; + + // Something already loaded this package from node_modules; nothing else to align. + if (!filename || require.cache[filename]) continue; + const cached = new Module(filename); cached.filename = filename; - cached.exports = provided.lib(); + cached.exports = sharedModule; cached.loaded = true; require.cache[filename] = cached as unknown as NodeJS.Module; } } } + +function findCachedModuleFilename(exports: unknown, require: NodeJS.Require) { + const cache = (require as NodeJS.Require & { cache: Record }).cache; + for (const [filename, cached] of Object.entries(cache)) { + if (cached?.exports === exports) return filename; + } + return undefined; +} + +type ViteModuleCache = { + share?: Record; +}; + +type ViteModuleCacheGlobals = typeof globalThis & { + __mf_module_cache__?: ViteModuleCache; + __mf_module_cache_react_server__?: ViteModuleCache; +}; + +/** + * Vite's dev ModuleRunner consults this cache before falling back to its own optimized + * dependency graph. Seed it with the host's already-loaded singleton so a Vite SSR remote + * cannot create a second React or Router context in the same server process. + */ +function provideViteSharedModule(scopeName: string, packageName: string, module: unknown) { + const globalState = globalThis as ViteModuleCacheGlobals; + for (const cacheKey of ["__mf_module_cache__", "__mf_module_cache_react_server__"] as const) { + const cache = (globalState[cacheKey] ??= {}); + const share = (cache.share ??= {}); + share[`${scopeName}:${packageName}`] ??= module; + if (scopeName === "default") share[packageName] ??= module; + } +} diff --git a/packages/tanstack/src/rsbuild.ts b/packages/tanstack/src/rsbuild.ts index 0db1c07..fa3be5c 100644 --- a/packages/tanstack/src/rsbuild.ts +++ b/packages/tanstack/src/rsbuild.ts @@ -4,8 +4,8 @@ import { type ModuleFederationOptions, } from "@module-federation/rsbuild-plugin"; import type { RsbuildPlugin } from "@rsbuild/core"; -import { defaultShared, eagerShared, resolveShared } from "./shared"; import type { NodeEntryLoaderOptions } from "./node-entry-loader"; +import { defaultShared, eagerShared, resolveShared, tanstackStartSharedPackages } from "./shared"; type ModuleFederationOverride = Partial; @@ -127,10 +127,12 @@ export function tanstackStartModuleFederation({ // The host's own server bundle consumes remotes. Its container, if any, is built by the // dedicated environment above, so TanStack's server output never contains one. const { exposes: _exposes, ...hostOptions } = { ...federation, ...serverOverrides }; + const serverHostOptions = withDefaults(hostOptions, eagerShared); + serverHostOptions.shared = externalizeServerShared(serverHostOptions.shared); plugins.push( pluginModuleFederation( { - ...withDefaults(hostOptions, eagerShared), + ...serverHostOptions, manifest: false, runtimePlugins: [ ...(hostOptions.runtimePlugins ?? []), @@ -256,6 +258,28 @@ function serverContainerEnvironmentPlugin({ }; } +function externalizeServerShared( + shared: ModuleFederationOptions["shared"], +): ModuleFederationOptions["shared"] { + if (!shared || Array.isArray(shared)) return shared; + + const configuredShared = shared as Record; + const externalizedShared = { ...configuredShared }; + for (const packageName of tanstackStartSharedPackages) { + externalizedShared[packageName] = externalizeSharedConfig(configuredShared[packageName]); + } + return externalizedShared as ModuleFederationOptions["shared"]; +} + +function externalizeSharedConfig(value: unknown): unknown { + if (typeof value === "string") return { import: value }; + if (Array.isArray(value)) return value.map(externalizeSharedConfig); + if (!value || typeof value !== "object") return { import: false }; + + const config = value as Record; + return { ...config, import: config.import ?? false }; +} + const REMOTE_FAILURE_FACTORY = /(webpackRequire\.m\[id\]\s*=\s*\(\)\s*=>\s*\{)(\s*throw error;)/; /** diff --git a/packages/tanstack/src/shared.ts b/packages/tanstack/src/shared.ts index 20c04ec..ed05d10 100644 --- a/packages/tanstack/src/shared.ts +++ b/packages/tanstack/src/shared.ts @@ -1,11 +1,22 @@ +export const tanstackStartSharedPackages = [ + "react", + "react-dom", + "@tanstack/react-router", + "@tanstack/router-core", +] as const; + export const defaultShared = { react: { singleton: true }, "react-dom": { singleton: true }, + "@tanstack/react-router": { singleton: true }, + "@tanstack/router-core": { singleton: true }, } as const; export const eagerShared = { react: { eager: true, singleton: true }, "react-dom": { eager: true, singleton: true }, + "@tanstack/react-router": { eager: true, singleton: true }, + "@tanstack/router-core": { eager: true, singleton: true }, } as const; export function resolveShared( diff --git a/packages/tanstack/src/vite.ts b/packages/tanstack/src/vite.ts index ed974e1..1b812c1 100644 --- a/packages/tanstack/src/vite.ts +++ b/packages/tanstack/src/vite.ts @@ -19,6 +19,7 @@ export function tanstackStartModuleFederation( // shares keep that instance in the share scope, so remotes from other bundlers cannot // replace it. Remote-only builds stay lazy and defer to the host. const isHost = Object.keys(configured.remotes ?? {}).length > 0; + const resolvedShared = resolveViteShared(shared, isHost); return [ viteVersionPlugin(), @@ -27,10 +28,11 @@ export function tanstackStartModuleFederation( filename: configured.filename ?? "remoteEntry.js", manifest: configured.manifest ?? true, hostInitInjectLocation: configured.hostInitInjectLocation ?? "entry", - shared: resolveShared( - shared, - isHost ? eagerShared : defaultShared, - ) as ModuleFederationOptions["shared"], + shared: resolvedShared as ModuleFederationOptions["shared"], }), ]; } + +function resolveViteShared(shared: ModuleFederationOptions["shared"], isHost: boolean) { + return resolveShared(shared, isHost ? eagerShared : defaultShared); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0c1c85..fa6eb49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,6 +227,9 @@ importers: '@tanstack/react-start': specifier: 1.168.60 version: 1.168.60(@rsbuild/core@2.2.11(@module-federation/runtime-tools@2.9.2))(@rspack/core@2.2.8(@module-federation/runtime-tools@2.9.2)(@swc/helpers@0.5.23))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + '@tanstack/router-core': + specifier: 1.171.34 + version: 1.171.34 example-host-context: specifier: workspace:* version: link:../host-context @@ -322,6 +325,9 @@ importers: '@tanstack/react-start': specifier: 1.168.60 version: 1.168.60(@rsbuild/core@2.2.11(@module-federation/runtime-tools@2.9.2))(@rspack/core@2.2.8(@module-federation/runtime-tools@2.9.2)(@swc/helpers@0.5.23))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + '@tanstack/router-core': + specifier: 1.171.34 + version: 1.171.34 example-host-context: specifier: workspace:* version: link:../host-context @@ -417,6 +423,9 @@ importers: '@tanstack/react-start': specifier: 1.168.60 version: 1.168.60(@rsbuild/core@2.2.11(@module-federation/runtime-tools@2.9.2))(@rspack/core@2.2.8(@module-federation/runtime-tools@2.9.2)(@swc/helpers@0.5.23))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + '@tanstack/router-core': + specifier: 1.171.34 + version: 1.171.34 example-host-context: specifier: workspace:* version: link:../host-context @@ -460,6 +469,9 @@ importers: '@tanstack/react-start': specifier: 1.168.60 version: 1.168.60(@rsbuild/core@2.2.11(@module-federation/runtime-tools@2.9.2))(@rspack/core@2.2.8(@module-federation/runtime-tools@2.9.2)(@swc/helpers@0.5.23))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.12)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + '@tanstack/router-core': + specifier: 1.171.34 + version: 1.171.34 example-host-context: specifier: workspace:* version: link:../host-context diff --git a/test/example-build.test.mjs b/test/example-build.test.mjs index d362d90..5684a5e 100644 --- a/test/example-build.test.mjs +++ b/test/example-build.test.mjs @@ -9,6 +9,7 @@ const viteHostDist = join(root, "apps", "vite-host", "dist"); const viteRemoteDist = join(root, "apps", "vite-remote", "dist"); const rsbuildHostDist = join(root, "apps", "rsbuild-host", "dist"); const rsbuildRemoteDist = join(root, "apps", "rsbuild-remote", "dist"); +const rsbuildSsrHostDist = join(root, "apps", "rsbuild-ssr-host", "dist"); function readJson(path) { return JSON.parse(readFileSync(path, "utf8")); @@ -20,6 +21,7 @@ test("all TanStack Start apps produce client and server builds", () => { ["Vite remote", viteRemoteDist], ["rsbuild host", rsbuildHostDist], ["rsbuild remote", rsbuildRemoteDist], + ["rsbuild SSR host", rsbuildSsrHostDist], ]) { assert.ok(existsSync(join(dist, "client", "mf-manifest.json")), `${name} client`); const serverEntry = name.startsWith("rsbuild") ? "index.js" : "server.js"; @@ -40,16 +42,30 @@ test("remote publishes browser and server federation entries", () => { const statusCard = manifest.exposes.find(({ path }) => path === "./StatusCard"); assert.ok(statusCard, "StatusCard expose is listed"); assert.ok(statusCard.assets.css.sync.length > 0, "StatusCard CSS is published"); + assert.ok( + manifest.exposes.find(({ path }) => path === "./RouterCard"), + "RouterCard expose is listed", + ); for (const dependency of ["react", "react-dom"]) { const shared = manifest.shared.find(({ name }) => name === dependency); assert.equal(shared?.singleton, true, `${dependency} is a singleton`); } + for (const [dependency, version] of [ + ["@tanstack/react-router", "1.170.41"], + ["@tanstack/router-core", "1.171.34"], + ]) { + const shared = manifest.shared.find(({ name }) => name === dependency); + assert.equal(shared?.singleton, true, `${dependency} is a singleton`); + assert.equal(shared?.requiredVersion, version, `${dependency} is pinned`); + } }); test("Vite host records the TanStack remote and server-side loader", () => { const manifest = readJson(join(viteHostDist, "client", "mf-manifest.json")); - const remote = manifest.remotes.find(({ alias }) => alias === "tanstack_vite_remote"); + const remote = manifest.remotes.find( + ({ alias, moduleName }) => alias === "tanstack_vite_remote" && moduleName === "StatusCard", + ); assert.equal(remote?.moduleName, "StatusCard"); const serverFiles = readFileSync(join(viteHostDist, "server", ".vite", "manifest.json"), "utf8"); @@ -61,6 +77,7 @@ test("Vite and Rsbuild publish reciprocal client interoperability contracts", () const viteHostManifest = readJson(join(viteHostDist, "client", "mf-manifest.json")); const rsbuildHostManifest = readJson(join(rsbuildHostDist, "client", "mf-manifest.json")); const rsbuildRemoteClient = readJson(join(rsbuildRemoteDist, "client", "mf-manifest.json")); + const rsbuildSsrHostManifest = readJson(join(rsbuildSsrHostDist, "client", "mf-manifest.json")); assert.equal( viteHostManifest.remotes.find(({ alias }) => alias === "tanstack_rsbuild_remote")?.moduleName, @@ -98,4 +115,16 @@ test("Vite and Rsbuild publish reciprocal client interoperability contracts", () ); } } + + for (const [manifest, host] of [ + [viteHostManifest, "Vite host"], + [rsbuildHostManifest, "Rsbuild host"], + [rsbuildSsrHostManifest, "Rsbuild SSR host"], + ]) { + for (const dependency of ["@tanstack/react-router", "@tanstack/router-core"]) { + const shared = manifest.shared.find(({ name }) => name === dependency); + assert.equal(shared?.singleton, true, `${host} shares ${dependency} as a singleton`); + assert.ok(shared?.requiredVersion, `${host} pins ${dependency}`); + } + } }); diff --git a/test/release-contracts.test.mjs b/test/release-contracts.test.mjs index 75d9cc4..e53acfe 100644 --- a/test/release-contracts.test.mjs +++ b/test/release-contracts.test.mjs @@ -56,6 +56,10 @@ test("applies TanStack-safe defaults", () => { assert.equal(options.shared.react.shareConfig.singleton, true); assert.equal(options.shared.react.shareConfig.eager, false, "remote-only builds stay lazy"); assert.equal(options.shared["react-dom"].shareConfig.singleton, true); + for (const dependency of ["@tanstack/react-router", "@tanstack/router-core"]) { + assert.equal(options.shared[dependency].shareConfig.singleton, true); + assert.equal(options.shared[dependency].shareConfig.eager, false); + } }); test("Vite hosts provide their own React eagerly so remotes cannot replace it", () => { @@ -63,9 +67,15 @@ test("Vite hosts provide their own React eagerly so remotes cannot replace it", name: "host", remotes: { remote: { type: "module", name: "remote", entry: "remoteEntry.js" } }, }); - for (const dependency of ["react", "react-dom"]) { + for (const dependency of [ + "react", + "react-dom", + "@tanstack/react-router", + "@tanstack/router-core", + ]) { assert.equal(options.shared[dependency].shareConfig.singleton, true); assert.equal(options.shared[dependency].shareConfig.eager, true, `${dependency} is eager`); + assert.notEqual(options.shared[dependency].shareConfig.import, false); } const overridden = upstreamOptions({ @@ -94,6 +104,17 @@ test("Vite hosts preserve SSR entry-loader revalidation options", () => { }); }); +test("Vite hybrids keep host shares eager while preserving remote exposes", () => { + const options = upstreamOptions({ + name: "hybrid", + exposes: { "./Card": "./src/Card.tsx" }, + remotes: { remote: { type: "module", name: "remote", entry: "remoteEntry.js" } }, + }); + assert.ok(options.exposes["./Card"]); + assert.equal(options.shared.react.shareConfig.eager, true); + assert.equal(options.shared["@tanstack/react-router"].shareConfig.eager, true); +}); + test("preserves explicit federation settings and shared entries", () => { const options = upstreamOptions({ name: "remote", @@ -127,7 +148,13 @@ test("does not mutate the caller's options", () => { test("accepts the upstream array form", () => { const options = upstreamOptions({ name: "remote", shared: ["lodash", "react"] }); - assert.deepEqual(Object.keys(options.shared).sort(), ["lodash", "react", "react-dom"]); + assert.deepEqual(Object.keys(options.shared).sort(), [ + "@tanstack/react-router", + "@tanstack/router-core", + "lodash", + "react", + "react-dom", + ]); assert.equal(options.shared.react.shareConfig.singleton, true); assert.equal(options.shared["react-dom"].shareConfig.singleton, true); }); @@ -162,6 +189,8 @@ test("Rsbuild shared defaults never mutate caller overrides", () => { assert.deepEqual(callerShared, before); assert.deepEqual(shared.react, { eager: false, singleton: false }); assert.deepEqual(shared["react-dom"], { eager: true, singleton: true }); + assert.deepEqual(shared["@tanstack/react-router"], { eager: true, singleton: true }); + assert.deepEqual(shared["@tanstack/router-core"], { eager: true, singleton: true }); }); const rsbuildRemote = { name: "test_remote", exposes: { "./Card": "./src/Card.tsx" } }; @@ -177,16 +206,59 @@ test("Rsbuild hosts share eager React; remotes and server containers stay lazy", server: true, }); - for (const options of [hostClient, hostServer]) { - assert.deepEqual(options.shared.react, { eager: true, singleton: true }); - assert.deepEqual(options.shared["react-dom"], { eager: true, singleton: true }); + for (const [options, server] of [ + [hostClient, false], + [hostServer, true], + ]) { + for (const dependency of [ + "react", + "react-dom", + "@tanstack/react-router", + "@tanstack/router-core", + ]) { + assert.deepEqual(options.shared[dependency], { + ...(server ? { import: false } : {}), + eager: true, + singleton: true, + }); + } } for (const options of [remoteClient, remoteContainer]) { - assert.deepEqual(options.shared.react, { singleton: true }); - assert.deepEqual(options.shared["react-dom"], { singleton: true }); + for (const dependency of [ + "react", + "react-dom", + "@tanstack/react-router", + "@tanstack/router-core", + ]) { + assert.deepEqual(options.shared[dependency], { singleton: true }); + } } }); +test("Rsbuild SSR preserves explicit shared import and scope settings", () => { + const [, server] = federationOptions({ + federation: { + ...rsbuildHost, + shared: { + react: "preact/compat", + "@tanstack/react-router": { + singleton: true, + shareKey: "router", + shareScope: "custom", + }, + lodash: { import: "lodash-es", singleton: true }, + }, + }, + server: true, + }); + + assert.equal(server.shared.react.import, "preact/compat"); + assert.equal(server.shared["@tanstack/react-router"].import, false); + assert.equal(server.shared["@tanstack/react-router"].shareKey, "router"); + assert.equal(server.shared["@tanstack/react-router"].shareScope, "custom"); + assert.deepEqual(server.shared.lodash, { import: "lodash-es", singleton: true }); +}); + test("Rsbuild enables async startup unless the caller disables it", () => { const [enabled] = federationOptions({ federation: rsbuildRemote }); assert.equal(enabled.experiments.asyncStartup, true); diff --git a/test/runtime.test.mjs b/test/runtime.test.mjs index a89d303..d0840b7 100644 --- a/test/runtime.test.mjs +++ b/test/runtime.test.mjs @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import { execFile, spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { after, before, test } from "node:test"; @@ -332,6 +332,43 @@ test("Vite remotes ask for @module-federation/vite when the host lacks it", () = } }); +test("resolves a transitive Router core package from the Router package root", () => { + const app = mkdtempSync(join(tmpdir(), "mf-tanstack-transitive-")); + try { + const routerRequire = createRequire(join(root, "apps", "rsbuild-ssr-host", "package.json")); + const routerEntry = routerRequire.resolve("@tanstack/react-router"); + const routerPackage = resolve(dirname(routerEntry), "../.."); + const routerLink = join(app, "node_modules", "@tanstack", "react-router"); + mkdirSync(dirname(routerLink), { recursive: true }); + symlinkSync(routerPackage, routerLink, "dir"); + writeFileSync(join(app, "package.json"), JSON.stringify({ private: true })); + + const script = join(app, "server.mjs"); + writeFileSync( + script, + `const { default: loader } = await import(${JSON.stringify(loaderUrl)}); + let registered; + loader().apply({ + name: "host", + options: { shared: {} }, + registerShared(shared) { registered = shared; }, + }); + console.log(JSON.stringify({ + router: registered?.["@tanstack/react-router"]?.version, + routerCore: registered?.["@tanstack/router-core"]?.version, + }));`, + ); + const result = spawnSync(process.execPath, [script], { cwd: app, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { + router: "1.170.41", + routerCore: "1.171.34", + }); + } finally { + rmSync(app, { force: true, recursive: true }); + } +}); + /** * Runs `body` as an ES module in the Rsbuild SSR example host, which depends on * @module-federation/vite and React, and returns the JSON it logs last. It runs in a child diff --git a/test/support/examples.mjs b/test/support/examples.mjs index 94057a3..ea64295 100644 --- a/test/support/examples.mjs +++ b/test/support/examples.mjs @@ -27,12 +27,16 @@ const cards = { rsbuildSsr: "Federated SSR from Rsbuild", }; +const routerCard = "Router-aware remote"; + // The remotes each SSR host renders on the server, across both bundlers. const serverRendered = { viteHost: ["viteRemote", "rsbuildSsrRemote"], rsbuildSsrHost: ["rsbuildSsrRemote", "viteRemote"], }; +const routerCardServerRendered = new Set(["viteHost", "rsbuildSsrHost"]); + const remoteCards = { viteRemote: cards.vite, rsbuildSsrRemote: cards.rsbuildSsr }; /** @@ -77,6 +81,12 @@ export function describeExamples(script) { for (const remote of remoteKeys) { assert.ok(body.includes(remoteCards[remote]), `${host} initial HTML contains ${remote}`); } + if (routerCardServerRendered.has(host)) { + assert.ok( + body.includes("Remote uses the host router"), + `${host} initial HTML contains ${routerCard}`, + ); + } assert.equal( body.match(/Rendered on the server/g)?.length, remoteKeys.length, @@ -159,6 +169,7 @@ export function describeExamples(script) { for (const card of [cards.vite, cards.rsbuildSsr, cards.rsbuild]) { await assertInteractive(page, card); } + await assertRouterIntegration(page, routerCard); await assertHostContext(page, [cards.vite, cards.rsbuildSsr, cards.rsbuild], "Vite host"); }); }); @@ -167,6 +178,7 @@ export function describeExamples(script) { await withPage(apps.rsbuildHost.url, async (page) => { await assertInteractive(page, cards.vite); await assertInteractive(page, cards.rsbuild); + await assertRouterIntegration(page, routerCard); await assertHostContext(page, [cards.vite, cards.rsbuild], "Rsbuild host"); }); }); @@ -176,6 +188,7 @@ export function describeExamples(script) { await page.getByText("Hydrated on the host").first().waitFor({ timeout: 20_000 }); await assertInteractive(page, cards.rsbuildSsr); await assertInteractive(page, cards.vite); + await assertRouterIntegration(page, routerCard); await assertHostContext(page, [cards.rsbuildSsr, cards.vite], "Rsbuild SSR host"); }); }); @@ -276,14 +289,21 @@ export function describeExamples(script) { /** Waits until every SSR host renders all of its remotes on the server. */ async function assertServerRendersRemotes(when) { for (const [host, remoteKeys] of Object.entries(serverRendered)) { - const { body } = await waitForResponse(apps[host].url, processes[host], ({ body }) => - remoteKeys.every((remote) => body.includes(remoteCards[remote])), + const { body } = await waitForResponse( + apps[host].url, + processes[host], + ({ body }) => + remoteKeys.every((remote) => body.includes(remoteCards[remote])) && + (!routerCardServerRendered.has(host) || body.includes("Remote uses the host router")), ); assert.equal( body.match(/Rendered on the server/g)?.length, remoteKeys.length, `${host} renders its remotes on the server ${when}`, ); + if (routerCardServerRendered.has(host)) { + assert.ok(body.includes("Remote uses the host router"), `${host} renders ${routerCard}`); + } } } @@ -361,6 +381,19 @@ async function assertInteractive(page, cardText) { ); } +/** Proves a remote component can render a Link and navigate through the host Router. */ +async function assertRouterIntegration(page, cardText) { + const card = page.locator("article", { hasText: cardText }); + const link = card.getByRole("link", { name: "Remote link" }); + assert.match((await link.getAttribute("href")) ?? "", /#router-link$/); + + await link.click(); + await page.waitForFunction(() => window.location.hash === "#router-link"); + + await card.getByRole("button", { name: "Remote navigate" }).click(); + await page.waitForFunction(() => window.location.hash === "#router-navigate"); +} + function startApp(name, script) { const child = spawn("pnpm", ["--filter", name, script], { cwd: root,