diff --git a/craco.config.js b/craco.config.js index 27f0bf3..b0e9bf9 100644 --- a/craco.config.js +++ b/craco.config.js @@ -1,42 +1,51 @@ +const path = require("path"); const { ModuleFederationPlugin } = require("webpack").container; const deps = require("./package.json").dependencies; -// Import the remote configurations -// Note: Since this is a craco.config.js (Node.js environment), we use require. -const remotes = require("./src/remotes/mf-remotes.config.js").default; +// The remote list is no longer needed at build time: containers are resolved and +// fetched in the browser by src/remotes/loadRemote.js, so nothing here has to +// know their URLs. module.exports = { webpack: { configure: (webpackConfig) => { webpackConfig.output.publicPath = "auto"; - // Dynamically generate the remotes object based on the environment - const isProduction = process.env.NODE_ENV === "production"; - const remoteEntries = remotes.reduce((acc, remote) => { - const url = isProduction ? remote.prodUrl : remote.devUrl; - acc[remote.name] = `${remote.name}@${url}`; - return acc; - }, {}); - webpackConfig.plugins.push( new ModuleFederationPlugin({ name: "host", - remotes: remoteEntries, // Use the dynamically generated object + // Deliberately no `remotes` option. + // + // A statically-declared remote gets an `initExternal()` call emitted + // into webpack's share-scope initialiser, which runs at app startup — + // so all four remoteEntry.js files were fetched on every route, + // including the landing page, which renders none of them. + // + // Containers are loaded on demand instead, by src/remotes/loadRemote.js. + // The `shared` scope below is still required: it is what the runtime + // hands to each container's init() so React stays a singleton. + // Only share what actually runs in the browser and is worth + // deduplicating with the remotes. Spreading every entry of + // `dependencies` here also shares build-only packages + // (react-scripts, @craco/craco, @testing-library/*), which webpack + // then has to resolve into the share scope for no benefit. + // + // These are NOT `eager: true`: src/index.js already provides the + // async boundary (`import("./bootstrap")`) that eager consumption + // exists to work around. Keeping them lazy lets webpack emit React + // and the router as their own long-lived chunks instead of inlining + // them into main.js on every build. shared: { - ...deps, react: { singleton: true, - eager: true, requiredVersion: deps.react, }, "react-dom": { singleton: true, - eager: true, requiredVersion: deps["react-dom"], }, "react-router-dom": { singleton: true, - eager: true, requiredVersion: deps["react-router-dom"], }, }, @@ -54,4 +63,30 @@ module.exports = { devServer: { historyApiFallback: true, }, + jest: { + configure: (jestConfig) => { + // CRA 5 pins jest 27, whose resolver predates `exports` maps entirely. + // react-router 7 relies on them: + // + // - react-router-dom declares `main: "./dist/main.js"`, a file it does + // not ship (only dist/index.js exists), so the `main` fallback fails + // outright — this is what made every router-importing test die with + // "Cannot find module 'react-router-dom'". + // - react-router/dom is exports-only, with no `main` to fall back to, + // and Node's own resolution picks the .mjs build that jest 27 cannot + // parse. + // + // Map both to the CJS builds. webpack resolves these correctly on its + // own, so this affects tests only. + const cjs = (p) => path.resolve(__dirname, "node_modules", p); + jestConfig.moduleNameMapper = { + ...jestConfig.moduleNameMapper, + "^react-router-dom$": cjs("react-router-dom/dist/index.js"), + "^react-router/dom$": cjs( + "react-router/dist/development/dom-export.js" + ), + }; + return jestConfig; + }, + }, }; diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..4289170 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,98 @@ +[build] + command = "npm run build" + publish = "build" + +# --------------------------------------------------------------------------- +# Caching +# --------------------------------------------------------------------------- +# Netlify's default for this site was `public, max-age=0, must-revalidate` on +# everything, including webpack's content-hashed assets. Those filenames change +# whenever their contents change, so revalidating them costs a round-trip per +# asset per visit and can never return anything but 304. +[[headers]] + for = "/static/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +# index.html is the only file whose URL is stable while its contents change — +# it must never be cached, or visitors keep booting a stale asset manifest. +[[headers]] + for = "/index.html" + [headers.values] + Cache-Control = "public, max-age=0, must-revalidate" + +[[headers]] + for = "/" + [headers.values] + Cache-Control = "public, max-age=0, must-revalidate" + +# Unhashed public/ assets: cache briefly, but let them be replaced same-day. +[[headers]] + for = "/*.webp" + [headers.values] + Cache-Control = "public, max-age=86400" + +[[headers]] + for = "/*.png" + [headers.values] + Cache-Control = "public, max-age=86400" + +[[headers]] + for = "/favicon.svg" + [headers.values] + Cache-Control = "public, max-age=86400" + +[[headers]] + for = "/Devi_R_Senior_Frontend_Engineer_Resume.pdf" + [headers.values] + Cache-Control = "public, max-age=86400" + +# --------------------------------------------------------------------------- +# Security headers +# --------------------------------------------------------------------------- +# This shell executes JavaScript fetched from four Render origins and frames two +# Vercel origins at runtime. A CSP is the one place that trust boundary is +# written down and enforced; without it any injected script can pull code from +# anywhere. The allowlist below is the architecture, stated explicitly. +[[headers]] + for = "/*" + [headers.values] + X-Content-Type-Options = "nosniff" + Referrer-Policy = "strict-origin-when-cross-origin" + Permissions-Policy = "camera=(), microphone=(), geolocation=(), interest-cohort=()" + Content-Security-Policy = """ + default-src 'self'; \ + script-src 'self' 'unsafe-inline' \ + https://react-post-login-dashboard.onrender.com \ + https://react-ecommerce-catalogue-page.onrender.com \ + https://react-syntax-highlighter.onrender.com \ + https://react-webgl-paint-splatter.onrender.com \ + https://cdn.jsdelivr.net \ + https://www.googletagmanager.com \ + https://www.google-analytics.com; \ + style-src 'self' 'unsafe-inline' \ + https://react-post-login-dashboard.onrender.com \ + https://react-ecommerce-catalogue-page.onrender.com \ + https://react-syntax-highlighter.onrender.com \ + https://react-webgl-paint-splatter.onrender.com; \ + img-src 'self' data: blob: https:; \ + font-src 'self' data:; \ + worker-src 'self' blob: \ + https://react-syntax-highlighter.onrender.com; \ + connect-src 'self' \ + https://react-post-login-dashboard.onrender.com \ + https://react-ecommerce-catalogue-page.onrender.com \ + https://react-syntax-highlighter.onrender.com \ + https://react-webgl-paint-splatter.onrender.com \ + https://express-mock-server-rose.vercel.app \ + https://cdn.jsdelivr.net \ + https://www.google-analytics.com \ + https://region1.google-analytics.com; \ + frame-src 'self' \ + https://nextjs-portfolio-blogs.vercel.app \ + https://nextjs-fullstack-ai-fe-system-desig.vercel.app; \ + frame-ancestors 'none'; \ + base-uri 'self'; \ + form-action 'self'; \ + object-src 'none' + """ diff --git a/package.json b/package.json index fcbb225..45fe957 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ }, "scripts": { "start": "PORT=3000 craco start", - "build": "craco build", + "build": "craco build && node scripts/generate-seo.js", "test": "craco test", "eject": "react-scripts eject" }, diff --git a/public/_redirects b/public/_redirects index f08da56..c9bbf30 100644 --- a/public/_redirects +++ b/public/_redirects @@ -1,3 +1,10 @@ +# Fallback only. +# +# `npm run build` overwrites this file in build/ via scripts/generate-seo.js, +# which emits an explicit 200 rule per known route plus a catch-all that returns +# a real 404 status for everything else. This blanket rule stays here so that a +# bare `craco build` (without the generate step) still produces a working SPA +# rather than 404ing every deep link. + # SPA fallback rule (must be last) /* /index.html 200 - diff --git a/public/index.html b/public/index.html index e7aac2e..c89cd16 100644 --- a/public/index.html +++ b/public/index.html @@ -12,7 +12,7 @@ - + - + + Devi R diff --git a/scripts/generate-seo.js b/scripts/generate-seo.js new file mode 100644 index 0000000..807e218 --- /dev/null +++ b/scripts/generate-seo.js @@ -0,0 +1,96 @@ +/* eslint-disable */ +// Generates build/sitemap.xml and build/_redirects from the route metadata in +// src/constants/pageMeta.js, so the deployed site's SEO surface can never drift +// from the routes the router actually serves. +// +// Run as part of `npm run build` (see package.json). Requires Node >= 22, which +// can require() an ES module graph — the same assumption craco.config.js +// already makes when it requires src/remotes/mf-remotes.config.js. + +const fs = require("fs"); +const path = require("path"); + +let SITE_ORIGIN, getIndexableRoutes, getServableRoutes; +try { + ({ + SITE_ORIGIN, + getIndexableRoutes, + getServableRoutes, + } = require("../src/constants/pageMeta.js")); +} catch (err) { + console.error( + "[generate-seo] Could not load src/constants/pageMeta.js.\n" + + "This script require()s an ES module, which needs Node >= 22.12.\n" + + `Current Node: ${process.version}\n\n` + + err.message + ); + process.exit(1); +} + +const BUILD_DIR = path.join(__dirname, "..", "build"); + +if (!fs.existsSync(BUILD_DIR)) { + console.error( + "[generate-seo] build/ not found — run this after `craco build`." + ); + process.exit(1); +} + +const routes = getIndexableRoutes(); +const lastmod = new Date().toISOString().slice(0, 10); + +// ---------------------------------------------------------------- sitemap.xml +const sitemap = ` + +${routes + .map( + ({ path: routePath, priority }) => + ` + ${SITE_ORIGIN}${routePath} + ${lastmod} + ${priority} + ` + ) + .join("\n")} + +`; + +fs.writeFileSync(path.join(BUILD_DIR, "sitemap.xml"), sitemap); + +// ------------------------------------------------------------------ robots.txt +// Point crawlers at the sitemap we just generated. +const robotsPath = path.join(BUILD_DIR, "robots.txt"); +let robots = fs.existsSync(robotsPath) + ? fs.readFileSync(robotsPath, "utf8").replace(/^Sitemap:.*$/gm, "").trimEnd() + : "User-agent: *\nAllow: /"; +robots += `\n\nSitemap: ${SITE_ORIGIN}/sitemap.xml\n`; +fs.writeFileSync(robotsPath, robots); + +// ------------------------------------------------------------------ _redirects +// A blanket `/* /index.html 200` makes every unknown URL answer 200 OK with the +// app shell — a soft 404. Search engines then index unlimited junk URLs as real +// pages. Instead: serve the shell with 200 for routes the router knows about, +// and serve the same shell with a genuine 404 status for everything else. The +// custom NotFoundPage still renders either way. +// +// Note this uses every servable route, not just the indexable ones: a route can +// legitimately be excluded from the sitemap (unlisted, or noindex) while still +// having to answer 200 when someone visits it directly. +const knownRoutes = getServableRoutes(); + +const redirects = `# Generated by scripts/generate-seo.js — do not edit by hand. +# Known application routes: serve the SPA shell with 200 OK. +${knownRoutes + .map((routePath) => `${routePath.padEnd(34)}/index.html 200`) + .join("\n")} + +# Anything else is genuinely not found. The shell still boots and renders the +# custom 404 page, but the response carries a 404 status for crawlers. +/*${" ".repeat(32)}/index.html 404 +`; + +fs.writeFileSync(path.join(BUILD_DIR, "_redirects"), redirects); + +console.log( + `[generate-seo] wrote sitemap.xml (${routes.length} urls), robots.txt and _redirects` +); diff --git a/src/App.js b/src/App.js index af9a280..9475be9 100644 --- a/src/App.js +++ b/src/App.js @@ -11,8 +11,13 @@ import { remoteRoutesMetadata } from "./remotes/mf-remote-routes"; import ModuleFederationRemote from "./components/remoteWrappers/ModuleFederationRemote"; import { iframeRoutesMetadata } from "./remotes/iframe-routes"; import IframeRemote from "./components/remoteWrappers/IFrameRemote"; +import useDocumentMeta from "./hooks/useDocumentMeta"; const RootLayout = () => { + // Keeps , description, canonical and the OG/Twitter tags in step with + // the active route. Lives here so every route is covered, remotes included. + useDocumentMeta(); + return ( <ErrorBoundary> <ConditionalLayout> diff --git a/src/App.test.js b/src/App.test.js deleted file mode 100644 index 1f03afe..0000000 --- a/src/App.test.js +++ /dev/null @@ -1,8 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('renders learn react link', () => { - render(<App />); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); diff --git a/src/components/layout/Header.js b/src/components/layout/Header.js index 7e3172b..a70946b 100644 --- a/src/components/layout/Header.js +++ b/src/components/layout/Header.js @@ -19,19 +19,28 @@ const Header = ({ onMenuClick }) => { <div className="bg-[#0F2430] flex items-center justify-between py-4 pl-4 pr-8 md:hidden"> <button onClick={handleMenuClick} + aria-label="Open navigation menu" className="text-slate-300 hover:text-white p-2 rounded-lg hover:bg-slate-800/50 transition-all duration-300 group relative" > - <HiMenu className="w-5 h-5 group-hover:scale-110 transition-transform text-[#A1F6FF]" /> + <HiMenu + aria-hidden="true" + className="w-5 h-5 group-hover:scale-110 transition-transform text-[#A1F6FF]" + /> </button> <Link to={ROUTES.LANDING} className="flex items-center" + aria-label="Devi R — home" onClick={handleLogoClick} > - <h2 className="text-white text-2xl lg:text-2xl font-semibold tracking-tight truncate"> + {/* A brand mark, not a section heading. As an <h2> it sat above each + page's <h1> in the outline — and this component and SidebarHeader + are both always mounted (one is md:hidden, the other hidden md:flex), + so it contributed two stray headings on every route. */} + <span className="text-white text-2xl lg:text-2xl font-semibold tracking-tight truncate"> Devi R - </h2> + </span> </Link> </div> ); diff --git a/src/components/layout/Layout.js b/src/components/layout/Layout.js index 6c7b572..ca67929 100644 --- a/src/components/layout/Layout.js +++ b/src/components/layout/Layout.js @@ -41,6 +41,15 @@ const Layout = ({ children }) => { return ( <div className="relative flex flex-col h-dvh bg-[#0B1C24] md:flex-row overflow-hidden"> + {/* Keyboard users would otherwise tab through the whole sidebar on every + route before reaching the page itself. Visible only on focus. */} + <a + href="#main-content" + className="sr-only focus:not-sr-only focus:absolute focus:top-3 focus:left-3 focus:z-[60] focus:rounded-lg focus:bg-[#A1F6FF] focus:px-4 focus:py-2 focus:text-[#0B1C24] focus:font-semibold" + > + Skip to main content + </a> + <Header onMenuClick={() => setIsMenuOpen((prev) => !prev)} /> {/* Mobile Overlay Background - Click to close */} @@ -56,6 +65,8 @@ const Layout = ({ children }) => { {/* Main Content Area: Takes up remaining space */} <div className="flex-1 flex flex-col overflow-hidden transition-all duration-200 ease-in-out"> <main + id="main-content" + tabIndex={-1} ref={mainContentRef} className="layout-root flex-1 overflow-auto bg-[#0B1C24]" > diff --git a/src/components/layout/sidebar/Sidebar.js b/src/components/layout/sidebar/Sidebar.js index d8a23aa..461b315 100644 --- a/src/components/layout/sidebar/Sidebar.js +++ b/src/components/layout/sidebar/Sidebar.js @@ -53,7 +53,8 @@ const Sidebar = ({ isMenuOpen }) => { }, [isTooltipHovered]); return ( - <div + <nav + aria-label="Primary" className={`fixed w-[90vw] inset-y-0 left-0 transform transition-transform duration-300 ease-in-out bg-[#0F2430] h-full shadow-xl overflow-hidden z-50 ${ isMenuOpen ? "translate-x-0" : "-translate-x-full" } @@ -81,6 +82,7 @@ const Sidebar = ({ isMenuOpen }) => { > {sidebarItems.map((section) => ( <SidebarSection + key={section.sectionTitle} isCollapsed={isCollapsed} sectionTitle={section.sectionTitle} showAction={section.showAction} @@ -98,7 +100,7 @@ const Sidebar = ({ isMenuOpen }) => { position={tooltipPosition} text={getTooltipText()} /> - </div> + </nav> ); }; diff --git a/src/components/layout/sidebar/SidebarHeader.js b/src/components/layout/sidebar/SidebarHeader.js index 1b18484..efaa89d 100644 --- a/src/components/layout/sidebar/SidebarHeader.js +++ b/src/components/layout/sidebar/SidebarHeader.js @@ -33,17 +33,22 @@ const SidebarHeader = ({ onMouseEnter={onTooltipEnter} onMouseLeave={onTooltipLeave} > - <HiMenu className="w-5 h-5 group-hover:scale-110 transition-transform text-[#A1F6FF]" /> + <HiMenu + aria-hidden="true" + className="w-5 h-5 group-hover:scale-110 transition-transform text-[#A1F6FF]" + /> </button> {!isCollapsed && ( <Link to={ROUTES.LANDING} className="flex items-center" + aria-label="Devi R — home" onClick={handleLogoClick} > - <h2 className="text-white text-lg lg:text-xl font-semibold tracking-tight truncate"> + {/* Brand mark, not a heading — see the note in Header.js. */} + <span className="text-white text-lg lg:text-xl font-semibold tracking-tight truncate"> Devi R - </h2> + </span> </Link> )} </div> diff --git a/src/components/layout/sidebar/SidebarSection.js b/src/components/layout/sidebar/SidebarSection.js index 179a666..e6f24ec 100644 --- a/src/components/layout/sidebar/SidebarSection.js +++ b/src/components/layout/sidebar/SidebarSection.js @@ -28,9 +28,14 @@ const SidebarSection = ({ <div className="flex items-center justify-between mb-4 h-6"> {!isCollapsed && ( <> - <h3 className="text-slate-400 text-sm md:text-sm font-semibold uppercase tracking-wider truncate"> + {/* Deliberately not a heading. These are navigation group labels; + marking them up as <h3> put three headings ahead of each page's + <h1> in the document outline. The <ul> below is labelled by this + text via aria-label instead, which names the group for assistive + tech without inventing document structure. */} + <div className="text-slate-400 text-sm md:text-sm font-semibold uppercase tracking-wider truncate"> {sectionTitle} - </h3> + </div> {showAction && ( <Link to={actionPath} @@ -44,18 +49,32 @@ const SidebarSection = ({ )} </div> - <ul className="space-y-1 max-h-80 overflow-y-auto hide-scrollbar"> + <ul + aria-label={sectionTitle} + className="space-y-1 max-h-80 overflow-y-auto hide-scrollbar" + > + {/* Project items carry `viewUrl` rather than `path`, so keying on + `item.path` alone gave every project row a key of `undefined`. */} {items.map((item) => ( - <li key={item.path} className="w-full"> + <li + key={item.path || item.viewUrl || item.externalUrl} + className="w-full" + > {item.external ? ( <a href={item.externalUrl} target={item.externalTarget} rel="noopener noreferrer" + // When collapsed the text label is unmounted, which would leave + // the link with no accessible name at all. + aria-label={item.label || item.title} className="w-full flex items-center gap-3 py-2 rounded-lg transition-all duration-300 group relative text-slate-300 hover:text-white" onClick={() => handleItemClick(item)} > - <span className="text-base md:text-base lg:text-xl flex-shrink-0"> + <span + aria-hidden="true" + className="text-base md:text-base lg:text-xl flex-shrink-0" + > {item.icon} </span> {!isCollapsed && ( @@ -67,6 +86,12 @@ const SidebarSection = ({ ) : ( <Link to={item.viewUrl || item.path} + aria-label={item.label || item.title} + aria-current={ + location.pathname === (item.viewUrl || item.path) + ? "page" + : undefined + } className={`w-full flex items-center gap-3 py-2 rounded-lg transition-all duration-300 group relative ${ location.pathname === (item.viewUrl || item.path) ? "text-[#A1F6FF]" @@ -74,7 +99,10 @@ const SidebarSection = ({ }`} onClick={() => handleItemClick(item)} > - <span className="text-base md:text-base lg:text-xl flex-shrink-0"> + <span + aria-hidden="true" + className="text-base md:text-base lg:text-xl flex-shrink-0" + > {item.icon} </span> {!isCollapsed && ( diff --git a/src/components/projects/Project.js b/src/components/projects/Project.js index 2361f1b..88ce0ba 100644 --- a/src/components/projects/Project.js +++ b/src/components/projects/Project.js @@ -19,7 +19,8 @@ const Project = ({ project }) => { <div className="min-w-0 bg-[#0F2430] p-5 rounded-[6px] shadow-lg overflow-hidden hover:shadow-xl transition-shadow duration-300"> {/* Project Content */} <div className="w-full h-full p-6 fancy-border-box flex flex-col"> - <h3 className="text-[#A1F6FF] text-sm md:text-base mb-3">{title}</h3> + {/* h2, not h3: these cards sit directly under the page's <h1>. */} + <h2 className="text-[#A1F6FF] text-sm md:text-base mb-3">{title}</h2> <div className="flex flex-wrap gap-2 mb-6 items-start"> {tags.map((tag) => ( @@ -39,19 +40,24 @@ const Project = ({ project }) => { href={viewUrl} target="_blank" rel="noopener noreferrer" + // Every card renders a link reading just "View" / "GitHub", so + // out of context (e.g. a screen reader's link list) all three + // cards are indistinguishable. Name them by project. + aria-label={`View ${title}`} className="flex justify-center items-center gap-2 w-1/2 bg-[#193C50] text-[#0F2430] border border-[#193C50] p-1 rounded-[6px] text-[#CDEBEE] text-sm md:text-sm lg:text-base hover:opacity-80 transition-colors duration-200" onClick={handleViewClick} > - <FaExternalLinkAlt className="text-sm" /> + <FaExternalLinkAlt aria-hidden="true" className="text-sm" /> View </a> ) : ( <Link to={viewUrl} + aria-label={`View ${title}`} className="flex justify-center items-center gap-2 w-1/2 bg-[#193C50] text-[#0F2430] border border-[#193C50] p-1 rounded-[6px] text-[#CDEBEE] text-sm md:text-sm lg:text-base hover:opacity-80 transition-colors duration-200" onClick={handleViewClick} > - <FaExternalLinkAlt className="text-sm" /> + <FaExternalLinkAlt aria-hidden="true" className="text-sm" /> View </Link> )} @@ -59,10 +65,11 @@ const Project = ({ project }) => { href={githubUrl} target="_blank" rel="noopener noreferrer" + aria-label={`${title} source on GitHub`} className="flex justify-center items-center gap-2 w-1/2 bg-[#0D212E] text-[#0F2430] border border-[#153243] p-1 rounded-[6px] text-[#CDEBEE] text-sm md:text-sm lg:text-base hover:opacity-80 transition-colors duration-200" onClick={handleGitHubClick} > - <FaGithub className="text-sm" /> + <FaGithub aria-hidden="true" className="text-sm" /> GitHub </a> </div> diff --git a/src/components/projects/Projects.js b/src/components/projects/Projects.js index 2e54909..c4d13f5 100644 --- a/src/components/projects/Projects.js +++ b/src/components/projects/Projects.js @@ -68,6 +68,7 @@ const Projects = () => { href="https://github.com/devi-r" target="_blank" rel="noopener noreferrer" + aria-label="More projects on Devi R's GitHub profile" className="text-blue-600 hover:text-blue-800 underline" onClick={handleGitHubClick} > diff --git a/src/components/remoteWrappers/IFrameRemote.js b/src/components/remoteWrappers/IFrameRemote.js index ccdc77d..6fe598f 100644 --- a/src/components/remoteWrappers/IFrameRemote.js +++ b/src/components/remoteWrappers/IFrameRemote.js @@ -1,11 +1,78 @@ -import { useState } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import RemoteErrorBoundary from "../error/RemoteErrorBoundary"; import RemoteLoader from "../loader/RemoteLoader"; import { NotFoundPage } from "../error"; +// Height-sync protocol for iframe remotes. +// +// A cross-origin iframe cannot be measured from the host, so without the remote +// telling us how tall it is the only option is to pin the frame at viewport +// height and let the document scroll inside it. That produces a nested scroll +// region: the host's scrollbar never reflects the article's real length, and +// in-page anchors cannot be linked to. +// +// The remote opts in by posting its height to the parent: +// +// const post = () => +// window.parent?.postMessage( +// { type: "mfe:resize", height: document.documentElement.scrollHeight }, +// "*" +// ); +// const ro = new ResizeObserver(post); +// ro.observe(document.documentElement); +// window.addEventListener("load", post); +// +// Until a remote sends that message this component behaves exactly as before, +// so an un-updated remote is not broken by shipping this. +export const RESIZE_MESSAGE_TYPE = "mfe:resize"; + +// Only origins we deliberately embed may drive the host's layout. +const ALLOWED_ORIGINS = [ + "https://nextjs-portfolio-blogs.vercel.app", + "https://nextjs-fullstack-ai-fe-system-desig.vercel.app", + "http://localhost:3004", + "http://localhost:3006", +]; + +const isAllowedOrigin = (origin) => ALLOWED_ORIGINS.includes(origin); + +// Guard against a remote reporting an absurd height and blowing out the layout. +const MAX_HEIGHT = 50000; + const IframeRemote = ({ remoteUrl }) => { const [isLoading, setIsLoading] = useState(true); const [isError, setIsError] = useState(false); + const [syncedHeight, setSyncedHeight] = useState(null); + const iframeRef = useRef(null); + + const handleMessage = useCallback((event) => { + if (!isAllowedOrigin(event.origin)) return; + + const data = event.data; + if (!data || data.type !== RESIZE_MESSAGE_TYPE) return; + + const height = Number(data.height); + if (!Number.isFinite(height) || height <= 0) return; + + // Ignore messages from a different frame than the one we render. + const frame = iframeRef.current; + if (frame && event.source && frame.contentWindow !== event.source) return; + + setSyncedHeight(Math.min(Math.round(height), MAX_HEIGHT)); + }, []); + + useEffect(() => { + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, [handleMessage]); + + // A new remote is a new document; drop the previous document's height so we + // fall back to the viewport-height default until the new one reports in. + useEffect(() => { + setSyncedHeight(null); + setIsLoading(true); + setIsError(false); + }, [remoteUrl]); if (!remoteUrl) { return <NotFoundPage />; @@ -15,15 +82,25 @@ const IframeRemote = ({ remoteUrl }) => { return <RemoteErrorBoundary />; } + const hasSyncedHeight = syncedHeight !== null; + return ( <> {isLoading && <RemoteLoader />} <iframe + ref={iframeRef} src={remoteUrl} title="Iframe Remote Application" - className={`w-full min-h-screen border-none transition-opacity duration-300 ease-in-out ${ - isLoading ? "opacity-0" : "opacity-100" - } ${isError ? "hidden" : "visible"}`} + // Once the remote reports its height the frame grows to fit and the + // host page owns the single scrollbar. Before that, the original + // min-h-screen behaviour applies. + className={`w-full border-none transition-opacity duration-300 ease-in-out ${ + hasSyncedHeight ? "" : "min-h-screen" + } ${isLoading ? "opacity-0" : "opacity-100"} ${ + isError ? "hidden" : "visible" + }`} + style={hasSyncedHeight ? { height: `${syncedHeight}px` } : undefined} + scrolling={hasSyncedHeight ? "no" : undefined} onLoad={() => setIsLoading(false)} onError={() => setIsError(true)} /> diff --git a/src/components/remoteWrappers/IFrameRemote.test.js b/src/components/remoteWrappers/IFrameRemote.test.js new file mode 100644 index 0000000..b60be9c --- /dev/null +++ b/src/components/remoteWrappers/IFrameRemote.test.js @@ -0,0 +1,101 @@ +import { render, screen, act } from "@testing-library/react"; +import IframeRemote, { RESIZE_MESSAGE_TYPE } from "./IFrameRemote"; + +const BLOG = "https://nextjs-portfolio-blogs.vercel.app"; +const REMOTE_URL = `${BLOG}/blogs/portfolio-architecture`; + +const getFrame = () => screen.getByTitle("Iframe Remote Application"); + +// Posts a height message as if it came from inside the rendered frame. +const postHeight = (height, { origin = BLOG, source, type } = {}) => { + const event = new MessageEvent("message", { + data: { type: type ?? RESIZE_MESSAGE_TYPE, height }, + origin, + }); + // MessageEvent.source is read-only in jsdom; define it explicitly. + Object.defineProperty(event, "source", { + value: source === undefined ? getFrame().contentWindow : source, + }); + act(() => { + window.dispatchEvent(event); + }); +}; + +describe("IframeRemote height sync", () => { + it("falls back to viewport height until the remote reports in", () => { + render(<IframeRemote remoteUrl={REMOTE_URL} />); + + const frame = getFrame(); + expect(frame.className).toContain("min-h-screen"); + expect(frame.style.height).toBe(""); + }); + + it("grows to the reported height and hands scrolling to the host", () => { + render(<IframeRemote remoteUrl={REMOTE_URL} />); + + postHeight(3258); + + const frame = getFrame(); + expect(frame.style.height).toBe("3258px"); + expect(frame.className).not.toContain("min-h-screen"); + expect(frame.getAttribute("scrolling")).toBe("no"); + }); + + it("ignores height messages from origins we do not embed", () => { + render(<IframeRemote remoteUrl={REMOTE_URL} />); + + postHeight(9999, { origin: "https://evil.example" }); + + expect(getFrame().style.height).toBe(""); + }); + + it("ignores messages from a different frame than the one rendered", () => { + render(<IframeRemote remoteUrl={REMOTE_URL} />); + + postHeight(9999, { source: {} }); + + expect(getFrame().style.height).toBe(""); + }); + + it("ignores unrelated message types and non-numeric heights", () => { + render(<IframeRemote remoteUrl={REMOTE_URL} />); + + postHeight(3258, { type: "something:else" }); + expect(getFrame().style.height).toBe(""); + + postHeight("not-a-number"); + expect(getFrame().style.height).toBe(""); + + postHeight(-10); + expect(getFrame().style.height).toBe(""); + }); + + it("clamps an absurd height rather than blowing out the layout", () => { + render(<IframeRemote remoteUrl={REMOTE_URL} />); + + postHeight(10_000_000); + + expect(getFrame().style.height).toBe("50000px"); + }); + + it("drops the previous document's height when the remote changes", () => { + const { rerender } = render(<IframeRemote remoteUrl={REMOTE_URL} />); + postHeight(3258); + expect(getFrame().style.height).toBe("3258px"); + + rerender(<IframeRemote remoteUrl={`${BLOG}/blogs/another-post`} />); + + expect(getFrame().style.height).toBe(""); + expect(getFrame().className).toContain("min-h-screen"); + }); + + it("stops listening once unmounted", () => { + const removeSpy = jest.spyOn(window, "removeEventListener"); + const { unmount } = render(<IframeRemote remoteUrl={REMOTE_URL} />); + + unmount(); + + expect(removeSpy).toHaveBeenCalledWith("message", expect.any(Function)); + removeSpy.mockRestore(); + }); +}); diff --git a/src/constants/pageMeta.js b/src/constants/pageMeta.js new file mode 100644 index 0000000..e5e8267 --- /dev/null +++ b/src/constants/pageMeta.js @@ -0,0 +1,111 @@ +import { ROUTES } from "./routes.js"; + +// The apex host is canonical: https://www.devi-r.com 301s to https://devi-r.com, +// so every canonical/og:url we emit must use the apex to avoid pointing search +// engines and social scrapers at a redirect. +export const SITE_ORIGIN = "https://devi-r.com"; + +export const DEFAULT_META = { + title: "Devi R", + description: + "Devi R, Senior Frontend Engineer, Portfolio: An intentional dive into microfrontend architecture. This 'over-engineered' site serves as a case study in modern frontend system design.", +}; + +// Per-route document metadata. This is the single source of truth for both the +// runtime <title>/<meta> updates and the generated sitemap.xml, so a new route +// only has to be described once. +// +// `indexable: false` keeps a route out of the sitemap and gives it a +// robots noindex tag — used for the demo routes, which exist to exercise the +// error boundaries rather than to be found in search results. +export const PAGE_META = { + [ROUTES.LANDING]: { + title: "Devi R — Senior Frontend Engineer", + description: DEFAULT_META.description, + indexable: true, + priority: "1.0", + }, + [ROUTES.PROJECTS]: { + title: "Projects — Devi R", + description: + "Independently deployed microfrontends composed at runtime by this portfolio shell: a syntax highlighter, an AI frontend system designer, and an ecommerce catalogue.", + indexable: true, + priority: "0.8", + }, + [ROUTES.PORTFOLIO_ARCHITECTURE_BLOG]: { + title: "How It's Built — Microfrontend Architecture — Devi R", + description: + "Why this portfolio is deliberately over-engineered: a client-side shell composing four Module Federation remotes and two iframe remotes, and what that costs.", + indexable: true, + priority: "0.9", + }, + [ROUTES.SYNTAX_HIGHLIGHTER]: { + title: "Syntax Highlighter — Devi R", + description: + "A JavaScript syntax highlighter built from scratch: its own lexer, parser and semantic analysis engine running in a web worker, with a live AST view.", + indexable: true, + priority: "0.7", + }, + [ROUTES.AI_FE_SYSTEM_DESIGNER]: { + title: "AI FE System Designer — Devi R", + description: + "A Next.js remote that generates frontend system design articles for a named system, embedded into the shell as an iframe remote.", + indexable: true, + priority: "0.7", + }, + [ROUTES.ECOMMERCE_CATALOGUE]: { + title: "Ecommerce Catalogue — Devi R", + description: + "A faceted product catalogue remote: filtering, sorting and a responsive grid over a mock catalogue of 167 products.", + indexable: true, + priority: "0.7", + }, + // This route works and its remote is live, but it is deliberately unlisted: + // its card is commented out in constants/main.js and it has no sidebar entry. + // Left out of the sitemap so we don't publish an orphan page. Flip + // `indexable` to true (and restore the project card) to surface it. + [ROUTES.POST_LOGIN_DASHBOARD]: { + title: "Post Login Dashboard — Devi R", + description: + "A dashboard remote loaded into the shell over Module Federation.", + indexable: false, + }, + [ROUTES.ABOUT_ME]: { + title: "About & Contact — Devi R", + description: + "Get in touch with Devi R, Senior Frontend Engineer — email, GitHub, LinkedIn and resume, behind a WebGL paint-splatter remote.", + indexable: true, + priority: "0.8", + }, + [ROUTES.ERROR_DEMO]: { + title: "Error Boundary Demo — Devi R", + description: DEFAULT_META.description, + indexable: false, + }, + [ROUTES.NOT_FOUND_DEMO]: { + title: "404 Demo — Devi R", + description: DEFAULT_META.description, + indexable: false, + }, +}; + +export const NOT_FOUND_META = { + title: "Page not found — Devi R", + description: DEFAULT_META.description, + indexable: false, +}; + +export const getPageMeta = (pathname) => + PAGE_META[pathname] || NOT_FOUND_META; + +// Routes that belong in sitemap.xml, in declaration order. A route being +// absent here means "don't advertise it to crawlers" — NOT "this URL is +// invalid", which is a separate question answered by getServableRoutes(). +export const getIndexableRoutes = () => + Object.entries(PAGE_META) + .filter(([, meta]) => meta.indexable) + .map(([path, meta]) => ({ path, priority: meta.priority || "0.5" })); + +// Every path the router actually serves. These must answer 200; anything else +// is a genuine 404. Includes unlisted and noindex routes. +export const getServableRoutes = () => Object.keys(PAGE_META); diff --git a/src/constants/pageMeta.test.js b/src/constants/pageMeta.test.js new file mode 100644 index 0000000..3156d6e --- /dev/null +++ b/src/constants/pageMeta.test.js @@ -0,0 +1,58 @@ +import { + PAGE_META, + NOT_FOUND_META, + SITE_ORIGIN, + getPageMeta, + getIndexableRoutes, + getServableRoutes, +} from "./pageMeta"; +import { ROUTES } from "./routes"; + +describe("pageMeta", () => { + it("gives every router-served route its own title", () => { + const titles = Object.values(PAGE_META).map((m) => m.title); + expect(new Set(titles).size).toBe(titles.length); + }); + + it("falls back to the not-found meta for an unknown path", () => { + expect(getPageMeta("/no-such-route")).toBe(NOT_FOUND_META); + expect(NOT_FOUND_META.indexable).toBe(false); + }); + + it("describes every route the router can serve", () => { + // Guards against adding a route to routes.js and forgetting its metadata, + // which would silently make it a soft-404 in _redirects. + const servable = getServableRoutes(); + Object.values(ROUTES).forEach((route) => { + expect(servable).toContain(route); + }); + }); + + it("keeps the sitemap a subset of servable routes", () => { + const servable = getServableRoutes(); + getIndexableRoutes().forEach(({ path }) => { + expect(servable).toContain(path); + }); + }); + + it("excludes noindex routes from the sitemap but still serves them", () => { + const indexed = getIndexableRoutes().map((r) => r.path); + + // Demo routes exist to exercise the error boundaries, not to be found. + expect(indexed).not.toContain(ROUTES.ERROR_DEMO); + expect(indexed).not.toContain(ROUTES.NOT_FOUND_DEMO); + // Deliberately unlisted: no nav entry, project card commented out. + expect(indexed).not.toContain(ROUTES.POST_LOGIN_DASHBOARD); + + // ...but all three must still answer 200 rather than 404. + const servable = getServableRoutes(); + expect(servable).toContain(ROUTES.ERROR_DEMO); + expect(servable).toContain(ROUTES.NOT_FOUND_DEMO); + expect(servable).toContain(ROUTES.POST_LOGIN_DASHBOARD); + }); + + it("canonicalises on the apex host, which is what www redirects to", () => { + expect(SITE_ORIGIN).toBe("https://devi-r.com"); + expect(SITE_ORIGIN).not.toMatch(/www\./); + }); +}); diff --git a/src/hooks/useDocumentMeta.js b/src/hooks/useDocumentMeta.js new file mode 100644 index 0000000..7cace47 --- /dev/null +++ b/src/hooks/useDocumentMeta.js @@ -0,0 +1,58 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; +import { SITE_ORIGIN, getPageMeta } from "../constants/pageMeta"; + +// index.html ships a single set of tags describing the site as a whole. Because +// the shell is client-rendered, every route otherwise keeps the landing page's +// title, description and og:url — so shared links and browser history entries +// are indistinguishable from each other. This syncs them to the active route. + +const setMetaTag = (attr, key, content) => { + let el = document.head.querySelector(`meta[${attr}="${key}"]`); + if (!el) { + el = document.createElement("meta"); + el.setAttribute(attr, key); + document.head.appendChild(el); + } + el.setAttribute("content", content); +}; + +const setCanonical = (href) => { + let el = document.head.querySelector('link[rel="canonical"]'); + if (!el) { + el = document.createElement("link"); + el.setAttribute("rel", "canonical"); + document.head.appendChild(el); + } + el.setAttribute("href", href); +}; + +const useDocumentMeta = () => { + const { pathname } = useLocation(); + + useEffect(() => { + const { title, description, indexable } = getPageMeta(pathname); + const canonicalUrl = `${SITE_ORIGIN}${pathname === "/" ? "/" : pathname}`; + + document.title = title; + setMetaTag("name", "description", description); + setCanonical(canonicalUrl); + + setMetaTag("property", "og:title", title); + setMetaTag("property", "og:description", description); + setMetaTag("property", "og:url", canonicalUrl); + setMetaTag("name", "twitter:title", title); + setMetaTag("name", "twitter:description", description); + setMetaTag("name", "twitter:url", canonicalUrl); + + // Demo and unknown routes should never be indexed. `max-image-preview` + // is preserved from index.html so the OG image still renders large. + setMetaTag( + "name", + "robots", + indexable ? "max-image-preview:large" : "noindex, follow" + ); + }, [pathname]); +}; + +export default useDocumentMeta; diff --git a/src/hooks/useDocumentMeta.test.js b/src/hooks/useDocumentMeta.test.js new file mode 100644 index 0000000..e64f230 --- /dev/null +++ b/src/hooks/useDocumentMeta.test.js @@ -0,0 +1,76 @@ +import { render } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import useDocumentMeta from "./useDocumentMeta"; +import { SITE_ORIGIN, getPageMeta } from "../constants/pageMeta"; +import { ROUTES } from "../constants/routes"; + +const Probe = () => { + useDocumentMeta(); + return null; +}; + +const renderAt = (path) => + render( + <MemoryRouter initialEntries={[path]}> + <Probe /> + </MemoryRouter> + ); + +const head = (selector) => document.head.querySelector(selector); + +describe("useDocumentMeta", () => { + it("sets title, description and canonical from the active route", () => { + renderAt(ROUTES.PROJECTS); + + const meta = getPageMeta(ROUTES.PROJECTS); + expect(document.title).toBe(meta.title); + expect(head('meta[name="description"]').content).toBe(meta.description); + expect(head('link[rel="canonical"]').href).toBe( + `${SITE_ORIGIN}${ROUTES.PROJECTS}` + ); + }); + + it("points og/twitter URLs at the same canonical, not the landing page", () => { + renderAt(ROUTES.SYNTAX_HIGHLIGHTER); + + const expected = `${SITE_ORIGIN}${ROUTES.SYNTAX_HIGHLIGHTER}`; + expect(head('meta[property="og:url"]').content).toBe(expected); + expect(head('meta[name="twitter:url"]').content).toBe(expected); + expect(head('meta[property="og:title"]').content).toBe( + getPageMeta(ROUTES.SYNTAX_HIGHLIGHTER).title + ); + }); + + it("keeps the landing canonical at the bare origin with a trailing slash", () => { + renderAt(ROUTES.LANDING); + expect(head('link[rel="canonical"]').href).toBe(`${SITE_ORIGIN}/`); + }); + + it("marks demo and unknown routes noindex, and real routes indexable", () => { + renderAt(ROUTES.ERROR_DEMO); + expect(head('meta[name="robots"]').content).toMatch(/noindex/); + + renderAt("/definitely-not-a-route"); + expect(head('meta[name="robots"]').content).toMatch(/noindex/); + expect(document.title).toBe(getPageMeta("/definitely-not-a-route").title); + + renderAt(ROUTES.ABOUT_ME); + expect(head('meta[name="robots"]').content).not.toMatch(/noindex/); + }); + + it("reuses the existing tags rather than appending duplicates", () => { + renderAt(ROUTES.PROJECTS); + renderAt(ROUTES.ABOUT_ME); + renderAt(ROUTES.LANDING); + + expect(document.head.querySelectorAll('link[rel="canonical"]')).toHaveLength( + 1 + ); + expect( + document.head.querySelectorAll('meta[name="description"]') + ).toHaveLength(1); + expect( + document.head.querySelectorAll('meta[property="og:url"]') + ).toHaveLength(1); + }); +}); diff --git a/src/remotes/loadRemote.js b/src/remotes/loadRemote.js new file mode 100644 index 0000000..531cf3b --- /dev/null +++ b/src/remotes/loadRemote.js @@ -0,0 +1,89 @@ +/* global __webpack_init_sharing__, __webpack_share_scopes__ */ + +// Dynamic Module Federation container loading. +// +// Why this exists rather than the `remotes` option in ModuleFederationPlugin: +// webpack 5 emits an `initExternal(id)` call for every statically-declared +// remote inside the share-scope initialiser. That initialiser runs as soon as +// the first shared module is consumed — i.e. at app startup — so declaring +// remotes statically means every remoteEntry.js is fetched on every route, +// including the landing page, which renders none of them. `eager: true` was not +// the cause; removing it did not change this. +// +// Loading containers by hand instead means a remoteEntry.js is fetched the +// first time its route is actually visited, and never otherwise. + +// name -> Promise<container>. Keyed so a remote is fetched at most once per +// session no matter how many times its route is entered. +const containers = new Map(); + +const loadContainer = (name, url) => { + if (containers.has(name)) return containers.get(name); + + const pending = new Promise((resolve, reject) => { + // A previous build of this remote may already have defined the global. + if (typeof window[name] !== "undefined") { + resolve(window[name]); + return; + } + + const script = document.createElement("script"); + script.src = url; + script.async = true; + + script.onload = () => { + const container = window[name]; + if (typeof container === "undefined") { + reject( + new Error( + `Remote "${name}" loaded from ${url} but did not define window.${name}.` + ) + ); + return; + } + resolve(container); + }; + + script.onerror = () => { + // Drop the cached rejection so a later visit (or a retry from the error + // boundary) can attempt the fetch again — Render instances can be cold. + containers.delete(name); + script.remove(); + reject(new Error(`Failed to load remote "${name}" from ${url}.`)); + }; + + document.head.appendChild(script); + }); + + containers.set(name, pending); + return pending; +}; + +// container.init() must be called exactly once per container; calling it twice +// throws "Container initialization failed as it has already been initialized". +const initialised = new WeakSet(); + +const initContainer = async (container) => { + if (initialised.has(container)) return; + await __webpack_init_sharing__("default"); + await container.init(__webpack_share_scopes__.default); + initialised.add(container); +}; + +/** + * Returns a loader suitable for React.lazy(). + * + * The returned module namespace already has the shape React.lazy expects + * ({ default: Component }), because the remotes expose their App as a default + * export. + */ +export const loadRemoteModule = + (name, url, moduleName = "./App") => + async () => { + const container = await loadContainer(name, url); + await initContainer(container); + const factory = await container.get(moduleName); + return factory(); + }; + +export default loadRemoteModule; diff --git a/src/remotes/loadRemote.test.js b/src/remotes/loadRemote.test.js new file mode 100644 index 0000000..b89ea5d --- /dev/null +++ b/src/remotes/loadRemote.test.js @@ -0,0 +1,145 @@ +// loadRemote caches containers in module scope (deliberately — a remote should +// be fetched at most once per session). Each test therefore gets a fresh copy +// of the module so the cache from one case cannot leak into the next. +const freshLoader = () => { + let mod; + jest.isolateModules(() => { + mod = require("./loadRemote"); + }); + return mod.loadRemoteModule; +}; + +const urlFor = (name) => `https://remote.example/entry.js?name=${name}`; + +const makeContainer = (Component) => ({ + init: jest.fn().mockResolvedValue(undefined), + get: jest.fn().mockResolvedValue(() => ({ default: Component })), +}); + +// Emulates the browser loading a container script: when the loader appends a +// <script>, define the matching global and fire onload (or onerror if there is +// no container registered for that name). +const stubScriptLoading = (containersByName) => { + const appended = []; + jest.spyOn(document.head, "appendChild").mockImplementation((script) => { + appended.push(script); + const name = new URL(script.src).searchParams.get("name"); + queueMicrotask(() => { + const container = containersByName[name]; + if (container) { + window[name] = container; + script.onload(); + } else { + script.onerror(new Event("error")); + } + }); + return script; + }); + return appended; +}; + +beforeEach(() => { + global.__webpack_init_sharing__ = jest.fn().mockResolvedValue(undefined); + global.__webpack_share_scopes__ = { default: { react: {} } }; +}); + +afterEach(() => { + jest.restoreAllMocks(); + delete window.RemoteA; + delete window.RemoteB; +}); + +describe("loadRemoteModule", () => { + it("resolves the remote's default export for React.lazy", async () => { + const Component = () => null; + stubScriptLoading({ RemoteA: makeContainer(Component) }); + + const mod = await freshLoader()("RemoteA", urlFor("RemoteA"))(); + + expect(mod.default).toBe(Component); + expect(global.__webpack_init_sharing__).toHaveBeenCalledWith("default"); + }); + + it("initialises each container exactly once across repeated visits", async () => { + const container = makeContainer(() => null); + stubScriptLoading({ RemoteA: container }); + + const load = freshLoader()("RemoteA", urlFor("RemoteA")); + await load(); + await load(); + await load(); + + // container.init() throws if called a second time. + expect(container.init).toHaveBeenCalledTimes(1); + expect(container.init).toHaveBeenCalledWith( + global.__webpack_share_scopes__.default + ); + }); + + it("fetches the container script only once", async () => { + const appended = stubScriptLoading({ RemoteA: makeContainer(() => null) }); + + const load = freshLoader()("RemoteA", urlFor("RemoteA")); + await load(); + await load(); + + expect(appended).toHaveLength(1); + }); + + it("keeps separate containers for separate remotes", async () => { + const a = makeContainer(() => null); + const b = makeContainer(() => null); + stubScriptLoading({ RemoteA: a, RemoteB: b }); + + const loadRemoteModule = freshLoader(); + await loadRemoteModule("RemoteA", urlFor("RemoteA"))(); + await loadRemoteModule("RemoteB", urlFor("RemoteB"))(); + + expect(a.init).toHaveBeenCalledTimes(1); + expect(b.init).toHaveBeenCalledTimes(1); + }); + + it("allows a retry after a failed load rather than caching the rejection", async () => { + // Nothing registered for RemoteA, so the script errors. + const containers = {}; + const appended = stubScriptLoading(containers); + const loadRemoteModule = freshLoader(); + + await expect( + loadRemoteModule("RemoteA", urlFor("RemoteA"))() + ).rejects.toThrow(/Failed to load remote "RemoteA"/); + expect(appended).toHaveLength(1); + + // Render instances can be cold; the next visit must be able to try again + // on the same module instance rather than replaying the cached rejection. + containers.RemoteA = makeContainer(() => null); + + await expect( + loadRemoteModule("RemoteA", urlFor("RemoteA"))() + ).resolves.toBeDefined(); + expect(appended).toHaveLength(2); + }); + + it("errors clearly when the script loads but defines no global", async () => { + jest.spyOn(document.head, "appendChild").mockImplementation((script) => { + queueMicrotask(() => script.onload()); + return script; + }); + + await expect( + freshLoader()("RemoteA", urlFor("RemoteA"))() + ).rejects.toThrow(/did not define window\.RemoteA/); + }); + + it("does not fetch anything until the returned loader is called", async () => { + const appended = stubScriptLoading({ RemoteA: makeContainer(() => null) }); + + // This is the property that makes remotes lazy: building the route table + // must not touch the network. + const load = freshLoader()("RemoteA", urlFor("RemoteA")); + expect(appended).toHaveLength(0); + + await load(); + expect(appended).toHaveLength(1); + }); +}); diff --git a/src/remotes/mf-remote-routes.js b/src/remotes/mf-remote-routes.js index 9bc3d5f..a2cd84b 100644 --- a/src/remotes/mf-remote-routes.js +++ b/src/remotes/mf-remote-routes.js @@ -1,25 +1,25 @@ import React from "react"; import { ROUTES } from "../constants/routes"; -import remotes from "./mf-remotes.config"; +import remotes, { getRemoteUrl } from "./mf-remotes.config"; import { toKebabCase } from "../utils"; +import { loadRemoteModule } from "./loadRemote"; -//react remote apps -const remoteApps = { - PostLoginDashboard: React.lazy(() => import("PostLoginDashboard/App")), - EcommerceCatalogue: React.lazy(() => import("EcommerceCatalogue/App")), - SyntaxHighlighter: React.lazy(() => import("SyntaxHighlighter/App")), - AboutMe: React.lazy(() => import("AboutMe/App")), -}; - +// Each remote's container is fetched the first time its route is rendered. +// React.lazy defers the loader until then, and because the remotes are no +// longer declared in ModuleFederationPlugin's `remotes` option, webpack no +// longer initialises them all during share-scope setup at startup. export const remoteRoutesMetadata = remotes?.map((remote) => { // Generate the expected path from the remote's name const expectedPath = `/${toKebabCase(remote.name)}`; // Find the path from ROUTES that matches the generated one const path = Object.values(ROUTES).find((route) => route === expectedPath); + return { name: remote.name, path: path, - component: remoteApps[remote.name], + component: React.lazy( + loadRemoteModule(remote.name, getRemoteUrl(remote), "./App") + ), }; }); diff --git a/src/remotes/mf-remotes.config.js b/src/remotes/mf-remotes.config.js index 388c820..d7c472d 100644 --- a/src/remotes/mf-remotes.config.js +++ b/src/remotes/mf-remotes.config.js @@ -1,7 +1,11 @@ -import React from "react"; import { ROUTES } from "../constants/routes.js"; // This is the single source of truth for all micro-frontends (remotes). +// +// craco.config.js require()s this file in Node to build the +// ModuleFederationPlugin `remotes` map, so it must stay free of anything that +// only makes sense in the browser — the React.lazy() map that used to live here +// belongs in (and already exists in) mf-remote-routes.js. export const remotes = [ { // The name used in webpack's ModuleFederationPlugin. @@ -35,17 +39,10 @@ export const remotes = [ }, ]; -export const remoteApps = { - PostLoginDashboard: React.lazy(() => import("PostLoginDashboard/App")), - EcommerceCatalogue: React.lazy(() => import("EcommerceCatalogue/App")), - SyntaxHighlighter: React.lazy(() => import("SyntaxHighlighter/App")), - AboutMe: React.lazy(() => import("AboutMe/App")), -}; - -export const remoteAppsMetadata = remotes.map((remote) => ({ - name: remote.name, - path: remote.path, - component: remoteApps[remote.name], -})); +// The URL the browser should fetch this remote's container from. Resolved here +// rather than in the webpack config because containers are now loaded at +// runtime (see loadRemote.js) instead of being declared statically. +export const getRemoteUrl = (remote) => + process.env.NODE_ENV === "development" ? remote.devUrl : remote.prodUrl; export default remotes; diff --git a/src/setupTests.js b/src/setupTests.js index 8f2609b..866f5c4 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -2,4 +2,17 @@ // allows you to do things like: // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom'; +import "@testing-library/jest-dom"; + +import { TextEncoder, TextDecoder } from "util"; + +// react-router 7 ships server-runtime code that runs `new TextEncoder()` at +// module scope, so merely importing the router throws in CRA 5's test +// environment (jest 27 / jsdom 16, which predate both globals). Node provides +// them; hand them to jsdom. +if (typeof global.TextEncoder === "undefined") { + global.TextEncoder = TextEncoder; +} +if (typeof global.TextDecoder === "undefined") { + global.TextDecoder = TextDecoder; +}