Lazy remote loading, asset caching, per-route SEO, accessibility and a working test suite - #1
Open
devi-r wants to merge 7 commits into
Open
Lazy remote loading, asset caching, per-route SEO, accessibility and a working test suite#1devi-r wants to merge 7 commits into
devi-r wants to merge 7 commits into
Conversation
…its own cacheable chunk
…overing every remote origin
… sitemap plus real 404 status
✅ Deploy Preview for devir ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
… the route that needs it
…ate test with real coverage
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Caching, SEO, accessibility and Module Federation config fixes for the shell. All of it verified against a real production build in a browser.
1. Caching and security headers (
netlify.toml, new)Netlify was serving webpack's content-hashed assets with
public, max-age=0, must-revalidate. Those filenames change whenever their contents change, so revalidating them costs a round-trip per asset per visit and can only ever return 304./static/*→max-age=31536000, immutableindex.htmlstaysmust-revalidate(stable URL, changing contents)X-Content-Type-Options,Referrer-Policy,Permissions-Policy,frame-ancestors 'none'Previously the only security header was HSTS.
2. Per-route SEO
Every route served the landing page's
<title>, description andog:url, so shared links and history entries were indistinguishable.src/constants/pageMeta.js— single source of truth for per-route title/description/indexabilitysrc/hooks/useDocumentMeta.js— syncs title, description, canonical, OG/Twitter and robots on navigationscripts/generate-seo.js(wired intonpm run build) — emitssitemap.xml, adds theSitemap:line torobots.txt, and generates_redirects_redirectsnow serves known routes200and everything else a genuine 404 instead of200 OKwith the app shell. A blanket/* /index.html 200lets crawlers index unlimited junk URLs as real pages.og:url,twitter:urland the JSON-LDurl, which pointed atwww.devi-r.com— that 301s to the apex, so every declared canonical aimed at a redirect<link rel="canonical">for crawlers that don't run JS_redirectsis generated from every servable route, while the sitemap uses only indexable ones — a route can legitimately be unlisted while still needing to answer 200./post-login-dashboardis one such: it works, but its project card is commented out, so it's kept out of the sitemap rather than published as an orphan page.public/_redirectsis retained as a fallback so a barecraco buildstill yields a working SPA.3. Accessibility
H2, H2, H3, H3, H3, H1, …— the page<h1>came after three sidebar headings. NowH1, H2, H2, H2. The two strayH2s were the "Devi R" brand mark in bothHeaderandSidebarHeader; both are always mounted (onemd:hidden, the otherhidden md:flex), so both counted on every route. They're brand marks, not section headings.<h3>— they're navigation group labels, and the<ul>is named viaaria-labelinstead<nav aria-label="Primary">landmarkaria-labelto every icon-only controlaria-current="page"to the active nav item4. Module Federation config
shared: { ...deps }was sharing every entry ofdependencies, including build-only packages (react-scripts,@craco/craco,@testing-library/*). Now an explicit list.eager: truefrom the shared singletons.src/index.jsalready provides the async boundary (import("./bootstrap")) that eager consumption exists to work around. Result at this step:main.js111.47 kB → 4.48 kB gzip (3.72 kB after §6), with React and the router emitted as their own chunks that now survive deploys under the immutable cache header.React.lazymap frommf-remotes.config.js. Nothing imported it, and becausecraco.config.jsrequire()s that file in Node, it was pulling React into the webpack config context.5. Bug fixes
Sidebarmapped sections without akeySidebarSectionkeyed list items onitem.path, but project items carryviewUrl— all three project rows hadkey={undefined}6. Lazy remote containers
All four
remoteEntry.jsfiles were fetched on every route, including the landing page, which renders none of them.The cause was not
eager: true. webpack 5 emits aninitExternal()call for every remote declared inModuleFederationPlugin'sremotesoption, inside the share-scope initialiser — which runs as soon as the first shared module is consumed, i.e. at startup. Verified in the built bundle:f(236), f(935), f(889), f(691)sat inline with the shared-module registrations.Dropped the
remotesoption entirely and addedsrc/remotes/loadRemote.js, which injects the container script, calls__webpack_init_sharing__("default")andcontainer.init(...)once per container, then resolves the exposed module.React.lazydefers all of it to first render of the route.Measured on the built app:
remoteEntry.json/remoteEntry.json/syntax-highlighterpost-login-dashboardfetched when never visitedmain.js(gzip)initExternalcalls in the share-scope initialiser: none remaining.Also verified by SPA navigation across three remotes in one session: each container is fetched and initialised exactly once, with no share-scope or React-singleton errors.
7. Test suite
Was unrunnable —
npm testfailed to resolve the router before any test executed. Three distinct causes:react-router-dom@7.9.3declaresmain: "./dist/main.js", a file it does not ship (onlydist/index.jsexists). CRA 5 pins jest 27, whose resolver has noexportssupport and falls back tomain.react-router/domis exports-only with nomainat all, and Node's own resolution picks a.mjsbuild jest 27 cannot parse.new TextEncoder()at module scope; jsdom 16 does not provide it.Fixed with two
moduleNameMapperentries pointing at the CJS builds (tests only — webpack resolves these correctly on its own) and aTextEncoder/TextDecoderpolyfill insetupTests.js.Deleted the untouched CRA boilerplate test (
renders learn react link) and added 26 tests across 4 suites, covering the parts of this PR most likely to break silently:pageMeta— every servable route has metadata, sitemap stays a subset of servable routes, noindex routes are still serveduseDocumentMeta— per-route title/canonical/og, noindex on demo and unknown routes, tags reused rather than duplicatedloadRemote— container fetched and initialised exactly once, nothing fetched until the loader is called, retry allowed after failureIFrameRemote— height applied only from an embedded origin and the rendered frame, clamped, reset when the remote changes8. Iframe height sync
/portfolio-architecturepinned the frame at viewport height while the framed document is ~3258 px, so the article scrolled inside the iframe: the host scrollbar never reflected the real length and in-page anchors could not be linked to.A cross-origin frame cannot be measured from the host, so the remote has to report.
IFrameRemotenow listens for amfe:resizemessage and grows the frame to fit, handing scrolling back to the host page. The listener validates all of: sending origin is one we deliberately embed, the message came from our frame rather than any frame on the page, the type matches, and the height is a finite positive number clamped to 50 000 px. Height resets when the remote URL changes.Until a remote actually sends the message the component behaves exactly as it does today, so this cannot regress the two existing iframes. The companion snippet the remote needs is documented at the top of
IFrameRemote.js.Verification
Served the production build with the exact CSP from
netlify.tomlplus areport-uri, then walked all 8 routes: zero violations — both Module Federation remotes, the syntax highlighter's web worker, three.js from jsDelivr, both iframes, and the 404.The
connect-srcentry for the catalogue API can only be checked on production.express-mock-server-rose.vercel.appallowlists exactly one origin:So the catalogue fails on localhost and on Netlify deploy previews, in both cases because the API refuses the origin — not because of anything in this PR (production renders all 167 products today). Worth fixing separately in
Express-Mock-Serverby allowinghttp://localhost:*andhttps://deploy-preview-*--*.netlify.app; right now the catalogue cannot run in local dev at all.Not included
mfe:resizeyet, so/portfolio-architecturebehaves exactly as before untilNextjs-Portfolio-Blogsships the ~6-line companion snippet (documented inIFrameRemote.js). Nothing regresses in the meantime.6fb724ais red in isolation because the CRA boilerplate test it inherits is deleted in the following commit. That test was already failing onmain. The branch tip is green.