Skip to content

Lazy remote loading, asset caching, per-route SEO, accessibility and a working test suite - #1

Open
devi-r wants to merge 7 commits into
mainfrom
perf/caching-seo-a11y
Open

Lazy remote loading, asset caching, per-route SEO, accessibility and a working test suite#1
devi-r wants to merge 7 commits into
mainfrom
perf/caching-seo-a11y

Conversation

@devi-r

@devi-r devi-r commented Aug 26, 2026

Copy link
Copy Markdown
Owner

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, immutable
  • index.html stays must-revalidate (stable URL, changing contents)
  • Added a CSP allowlisting the four Render remotes, jsDelivr (three.js), GA, the mock catalogue API, and the two Vercel iframe origins — the trust boundary this architecture already relies on, written down and enforced
  • Added 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 and og:url, so shared links and history entries were indistinguishable.

  • src/constants/pageMeta.js — single source of truth for per-route title/description/indexability
  • src/hooks/useDocumentMeta.js — syncs title, description, canonical, OG/Twitter and robots on navigation
  • scripts/generate-seo.js (wired into npm run build) — emits sitemap.xml, adds the Sitemap: line to robots.txt, and generates _redirects
  • _redirects now serves known routes 200 and everything else a genuine 404 instead of 200 OK with the app shell. A blanket /* /index.html 200 lets crawlers index unlimited junk URLs as real pages.
  • Fixed og:url, twitter:url and the JSON-LD url, which pointed at www.devi-r.com — that 301s to the apex, so every declared canonical aimed at a redirect
  • Added a static <link rel="canonical"> for crawlers that don't run JS

_redirects is 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-dashboard is 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/_redirects is retained as a fallback so a bare craco build still yields a working SPA.

3. Accessibility

  • Heading order was H2, H2, H3, H3, H3, H1, … — the page <h1> came after three sidebar headings. Now H1, H2, H2, H2. The two stray H2s were the "Devi R" brand mark in both Header and SidebarHeader; both are always mounted (one md:hidden, the other hidden md:flex), so both counted on every route. They're brand marks, not section headings.
  • Sidebar section labels are no longer <h3> — they're navigation group labels, and the <ul> is named via aria-label instead
  • Sidebar is now a <nav aria-label="Primary"> landmark
  • Added a skip link (keyboard users previously tabbed the entire sidebar on every route)
  • Collapsed sidebar links had no accessible name at all once the text label unmounted — added aria-label to every icon-only control
  • Unlabelled SVGs: 15 → 0
  • Named the three identical "View"/"GitHub" card link pairs by project, which were indistinguishable in a screen reader's link list
  • Added aria-current="page" to the active nav item

4. Module Federation config

  • shared: { ...deps } was sharing every entry of dependencies, including build-only packages (react-scripts, @craco/craco, @testing-library/*). Now an explicit list.
  • Dropped eager: true from the shared singletons. src/index.js already provides the async boundary (import("./bootstrap")) that eager consumption exists to work around. Result at this step: main.js 111.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.
  • Removed a dead duplicate React.lazy map from mf-remotes.config.js. Nothing imported it, and because craco.config.js require()s that file in Node, it was pulling React into the webpack config context.

5. Bug fixes

  • Sidebar mapped sections without a key
  • SidebarSection keyed list items on item.path, but project items carry viewUrl — all three project rows had key={undefined}

6. Lazy remote containers

All four remoteEntry.js files were fetched on every route, including the landing page, which renders none of them.

The cause was not eager: true. webpack 5 emits an initExternal() call for every remote declared in ModuleFederationPlugin's remotes option, 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 remotes option entirely and added src/remotes/loadRemote.js, which injects the container script, calls __webpack_init_sharing__("default") and container.init(...) once per container, then resolves the exposed module. React.lazy defers all of it to first render of the route.

Measured on the built app:

before after
remoteEntry.js on / 4 0
remoteEntry.js on /syntax-highlighter 4 1
post-login-dashboard fetched when never visited yes no
main.js (gzip) 111.47 kB 3.72 kB

initExternal calls 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 test failed to resolve the router before any test executed. Three distinct causes:

  1. react-router-dom@7.9.3 declares main: "./dist/main.js", a file it does not ship (only dist/index.js exists). CRA 5 pins jest 27, whose resolver has no exports support and falls back to main.
  2. react-router/dom is exports-only with no main at all, and Node's own resolution picks a .mjs build jest 27 cannot parse.
  3. react-router 7 runs new TextEncoder() at module scope; jsdom 16 does not provide it.

Fixed with two moduleNameMapper entries pointing at the CJS builds (tests only — webpack resolves these correctly on its own) and a TextEncoder/TextDecoder polyfill in setupTests.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 served
  • useDocumentMeta — per-route title/canonical/og, noindex on demo and unknown routes, tags reused rather than duplicated
  • loadRemote — container fetched and initialised exactly once, nothing fetched until the loader is called, retry allowed after failure
  • IFrameRemote — height applied only from an embedded origin and the rendered frame, clamped, reset when the remote changes

8. Iframe height sync

/portfolio-architecture pinned 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. IFrameRemote now listens for a mfe:resize message 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.toml plus a report-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-src entry for the catalogue API can only be checked on production. express-mock-server-rose.vercel.app allowlists exactly one origin:

Origin: https://devi-r.com                            -> 200
Origin: http://localhost:4178                         -> 403
Origin: https://deploy-preview-1--example.netlify.app -> 403

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-Server by allowing http://localhost:* and https://deploy-preview-*--*.netlify.app; right now the catalogue cannot run in local dev at all.

Not included

  • Remote-side height reporting. The host half of the iframe protocol is here and tested, but no remote sends mfe:resize yet, so /portfolio-architecture behaves exactly as before until Nextjs-Portfolio-Blogs ships the ~6-line companion snippet (documented in IFrameRemote.js). Nothing regresses in the meantime.
  • Commit 6fb724a is red in isolation because the CRA boilerplate test it inherits is deleted in the following commit. That test was already failing on main. The branch tip is green.

@netlify

netlify Bot commented Aug 26, 2026

Copy link
Copy Markdown

Deploy Preview for devir ready!

Name Link
🔨 Latest commit 4678969
🔍 Latest deploy log https://app.netlify.com/projects/devir/deploys/6a8f2d9dc6a2f60008672b43
😎 Deploy Preview https://deploy-preview-1--devir.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@devi-r devi-r changed the title Fix asset caching, per-route SEO, accessibility and Module Federation sharing Lazy remote loading, asset caching, per-route SEO, accessibility and a working test suite Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant