diff --git a/.changeset/tall-buses-explode.md b/.changeset/tall-buses-explode.md new file mode 100644 index 00000000000..f13b7801b21 --- /dev/null +++ b/.changeset/tall-buses-explode.md @@ -0,0 +1,13 @@ +--- +'@module-federation/nextjs-mf': patch +'@module-federation/node': patch +'@module-federation/enhanced': patch +'@module-federation/manifest': patch +'@module-federation/modern-js': patch +'@module-federation/modern-js-v3': patch +'@module-federation/sdk': patch +--- + +Stabilize the Next.js v9 integration path with improved Next.js 16/RSC behavior, hydration handling, and E2E coverage. + +This also aligns supporting runtime/build plumbing across Node runtime helpers, enhanced runtime/container hooks, manifest generation, Modern.js integrations, and SDK webpack-path normalization used by these flows. diff --git a/apps/3000-home/components/SharedNav.tsx b/apps/3000-home/components/SharedNav.tsx index 9dae70cbccd..e88a60c503d 100644 --- a/apps/3000-home/components/SharedNav.tsx +++ b/apps/3000-home/components/SharedNav.tsx @@ -1,20 +1,36 @@ import React from 'react'; import { Menu, Layout } from 'antd'; -import { useRouter } from 'next/router'; +import { useRouter } from 'next/compat/router'; import './menu'; -const SharedNav = () => { - const { asPath, push } = useRouter(); - let activeMenu; +type SharedNavProps = { + currentPath?: string; +}; + +function getActiveMenu(path: string | undefined): string | undefined { + if (!path) { + return undefined; + } + + if (path === '/' || path.startsWith('/home')) { + return '/'; + } + + if (path.startsWith('/shop')) { + return '/shop'; + } - if (asPath === '/' || asPath.startsWith('/home')) { - activeMenu = '/'; - } else if (asPath.startsWith('/shop')) { - activeMenu = '/shop'; - } else if (asPath.startsWith('/checkout')) { - activeMenu = '/checkout'; + if (path.startsWith('/checkout')) { + return '/checkout'; } + return undefined; +} + +const SharedNav = ({ currentPath }: SharedNavProps) => { + const router = useRouter(); + const activeMenu = getActiveMenu(currentPath); + const menuItems = [ { className: 'home-menu-link', @@ -53,7 +69,14 @@ const SharedNav = () => { mode="horizontal" selectedKeys={activeMenu ? [activeMenu] : undefined} onClick={({ key }) => { - push(key); + if (router?.push) { + router.push(key); + return; + } + + if (typeof window !== 'undefined') { + window.location.assign(key); + } }} items={menuItems} /> diff --git a/apps/3000-home/components/menu.tsx b/apps/3000-home/components/menu.tsx index 9f6e43caaf5..40d325a89ef 100644 --- a/apps/3000-home/components/menu.tsx +++ b/apps/3000-home/components/menu.tsx @@ -1,7 +1,8 @@ +import * as React from 'react'; import type { ItemType } from 'antd/es/menu/interface'; -import { useRouter } from 'next/router'; import { Menu } from 'antd'; +import { useRouter } from 'next/compat/router'; const menuItems: ItemType[] = [ { label: 'Main home', key: '/' }, @@ -15,11 +16,16 @@ const menuItems: ItemType[] = [ }, ]; -export default function AppMenu() { +type AppMenuProps = { + currentPath?: string; +}; + +export default function AppMenu({ currentPath }: AppMenuProps) { const router = useRouter(); + const resolvedPath = currentPath || '/'; return ( - <> +
@@ -27,11 +33,21 @@ export default function AppMenu() {
router.push(key)} + onClick={({ key }) => { + const href = String(key); + if (router?.push) { + router.push(href); + return; + } + + if (typeof window !== 'undefined') { + window.location.assign(href); + } + }} items={menuItems} /> - +
); } diff --git a/apps/3000-home/next-env.d.ts b/apps/3000-home/next-env.d.ts index a4a7b3f5cfa..d3956e1409f 100644 --- a/apps/3000-home/next-env.d.ts +++ b/apps/3000-home/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +import './.next/dev/types/routes.d.ts'; // NOTE: This file should not be edited -// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/apps/3000-home/next.config.js b/apps/3000-home/next.config.js index b233abb14b8..c69ac4f7ee1 100644 --- a/apps/3000-home/next.config.js +++ b/apps/3000-home/next.config.js @@ -1,77 +1,66 @@ -const NextFederationPlugin = require('@module-federation/nextjs-mf'); -const path = require('path'); -const reactPath = path.dirname(require.resolve('react/package.json')); -const reactDomPath = path.dirname(require.resolve('react-dom/package.json')); +const { withNextFederation } = require('@module-federation/nextjs-mf'); /** @type {import('next').NextConfig} */ -const nextConfig = { +const baseConfig = { typescript: { ignoreBuildErrors: true, }, - eslint: { - ignoreDuringBuilds: true, - }, webpack(config, options) { - const { isServer } = options; config.watchOptions = { ignored: ['**/node_modules/**', '**/@mf-types/**'], }; - // used for testing build output snapshots - const remotes = { - checkout: `checkout@http://localhost:3002/_next/static/${ - isServer ? 'ssr' : 'chunks' - }/remoteEntry.js`, - home_app: `home_app@http://localhost:3000/_next/static/${ - isServer ? 'ssr' : 'chunks' - }/remoteEntry.js`, - shop: `shop@http://localhost:3001/_next/static/${ - isServer ? 'ssr' : 'chunks' - }/remoteEntry.js`, - }; - config.plugins.push( - new NextFederationPlugin({ - name: 'home_app', - filename: 'static/chunks/remoteEntry.js', - remotes: { - shop: remotes.shop, - checkout: remotes.checkout, - }, - exposes: { - './SharedNav': './components/SharedNav', - './menu': './components/menu', - }, - shared: { - 'lodash/': {}, - antd: { - requiredVersion: '5.19.1', - version: '5.19.1', - }, - '@ant-design/': { - singleton: true, - }, - }, - extraOptions: { - debug: false, - exposePages: true, - enableImageLoaderFix: true, - enableUrlLoaderFix: true, - }, - }), - ); config.plugins.push({ name: 'nx-dev-webpack-plugin', apply(compiler) { compiler.options.devtool = false; - compiler.options.resolve.alias = { - ...compiler.options.resolve.alias, - react: reactPath, - 'react-dom': reactDomPath, - }; }, }); + return config; }, }; -module.exports = nextConfig; +module.exports = withNextFederation(baseConfig, { + name: 'home_app', + mode: 'pages', + filename: 'static/chunks/remoteEntry.js', + remotes: ({ isServer }) => ({ + shop: `shop@http://localhost:3001/_next/static/${ + isServer ? 'ssr' : 'chunks' + }/remoteEntry.js`, + checkout: `checkout@http://localhost:3002/_next/static/${ + isServer ? 'ssr' : 'chunks' + }/remoteEntry.js`, + }), + exposes: { + './SharedNav': './components/SharedNav', + './menu': './components/menu', + }, + shared: { + 'lodash/': {}, + '@ant-design/cssinjs': { + singleton: true, + requiredVersion: false, + eager: true, + }, + antd: { + singleton: true, + requiredVersion: '5.19.1', + version: '5.19.1', + }, + '@ant-design/': { + singleton: true, + }, + }, + pages: { + exposePages: true, + pageMapFormat: 'routes-v2', + }, + runtime: { + onRemoteFailure: 'null-fallback', + }, + diagnostics: { + level: 'warn', + }, +}); diff --git a/apps/3000-home/package.json b/apps/3000-home/package.json index 129a1d8c720..2835b3b461b 100644 --- a/apps/3000-home/package.json +++ b/apps/3000-home/package.json @@ -6,7 +6,7 @@ "@ant-design/cssinjs": "^1.21.0", "antd": "5.19.1", "lodash": "4.17.23", - "next": "14.2.35", + "next": "16.1.5", "react": "18.3.1", "react-dom": "18.3.1" }, @@ -19,6 +19,7 @@ }, "scripts": { "start": "next start", - "build": "pnpm exec next telemetry disable && NEXT_PRIVATE_LOCAL_WEBPACK=true next build" + "build": "pnpm exec next telemetry disable && NEXT_PRIVATE_LOCAL_WEBPACK=true next build --webpack", + "dev": "NEXT_PRIVATE_LOCAL_WEBPACK=true next dev --webpack -p 3000" } } diff --git a/apps/3000-home/pages/_app.tsx b/apps/3000-home/pages/_app.tsx index 533db175aba..55d07edc228 100644 --- a/apps/3000-home/pages/_app.tsx +++ b/apps/3000-home/pages/_app.tsx @@ -1,54 +1,67 @@ import * as React from 'react'; -import { useState } from 'react'; import { init } from '@module-federation/runtime'; -console.log('logging init', typeof init); import App from 'next/app'; import { Layout, version, ConfigProvider } from 'antd'; import { StyleProvider } from '@ant-design/cssinjs'; - -import Router, { useRouter } from 'next/router'; -const SharedNav = React.lazy(() => import('../components/SharedNav')); +import { useRouter } from 'next/compat/router'; +import SharedNav from '../components/SharedNav'; import HostAppMenu from '../components/menu'; + function MyApp(props) { const { Component, pageProps } = props; - const { asPath } = useRouter(); - const [MenuComponent, setMenuComponent] = useState(() => HostAppMenu); - const handleRouteChange = async (url) => { + const router = useRouter(); + const resolvedPath = + router?.asPath || + router?.pathname || + (typeof window !== 'undefined' + ? `${window.location.pathname}${window.location.search}${window.location.hash}` + : '/'); + const [MenuComponent, setMenuComponent] = React.useState(() => HostAppMenu); + + const handleRouteChange = React.useCallback(async (url: string) => { if (url.startsWith('/shop')) { - // @ts-ignore const RemoteAppMenu = (await import('shop/menu')).default; setMenuComponent(() => RemoteAppMenu); - } else if (url.startsWith('/checkout')) { - // @ts-ignore + return; + } + + if (url.startsWith('/checkout')) { const RemoteAppMenu = (await import('checkout/menu')).default; setMenuComponent(() => RemoteAppMenu); - } else { - setMenuComponent(() => HostAppMenu); + return; } - }; - // handle first route hit. + + setMenuComponent(() => HostAppMenu); + }, []); + React.useEffect(() => { - handleRouteChange(asPath); - }, [asPath]); + const initialPath = + router?.asPath || + (typeof window !== 'undefined' + ? `${window.location.pathname}${window.location.search}${window.location.hash}` + : '/'); + void handleRouteChange(initialPath); + }, [handleRouteChange, router?.asPath]); - //handle route change React.useEffect(() => { - // Step 3: Subscribe on events - Router.events.on('routeChangeStart', handleRouteChange); + if (!router?.events) { + return; + } + + router.events.on('routeChangeStart', handleRouteChange); return () => { - Router.events.off('routeChangeStart', handleRouteChange); + router.events.off('routeChangeStart', handleRouteChange); }; - }, []); + }, [handleRouteChange, router?.events]); + return ( - - - + - + diff --git a/apps/3000-home/pages/_document.js b/apps/3000-home/pages/_document.js index f0e5fd7b8b5..575817cfb73 100644 --- a/apps/3000-home/pages/_document.js +++ b/apps/3000-home/pages/_document.js @@ -1,22 +1,50 @@ import React from 'react'; import Document, { Html, Head, Main, NextScript } from 'next/document'; import { - revalidate, - FlushedChunks, + ensureRemoteHotReload, flushChunks, -} from '@module-federation/nextjs-mf/utils'; +} from '@module-federation/node/utils'; + +const REMOTE_ENTRY_URLS = [ + 'http://localhost:3001/_next/static/chunks/remoteEntry.js', + 'http://localhost:3002/_next/static/chunks/remoteEntry.js', +]; + +const shouldEnableRemoteHotReload = + process.env.MF_REMOTE_HOT_RELOAD === 'true' || + (process.env.NODE_ENV === 'production' && + process.env.MF_REMOTE_HOT_RELOAD !== 'false'); + +const remoteHotReload = ensureRemoteHotReload({ + enabled: shouldEnableRemoteHotReload, + intervalMs: Number(process.env.MF_REMOTE_REVALIDATE_INTERVAL_MS || 10_000), + immediate: false, +}); + +const FlushedChunks = ({ chunks = [] }) => { + const combinedChunks = Array.from(new Set([...REMOTE_ENTRY_URLS, ...chunks])); + const scripts = combinedChunks + .filter((chunk) => chunk.endsWith('.js')) + .map((chunk) => { + const isRemoteEntry = chunk.includes('remoteEntry'); + return