perf: de-duplicate identical <script src> tags in Helmet - #689
Conversation
Multiple independently-mounted component instances (e.g. several widgets on the same page) can each declare <Helmet><script src=...> for the same external script (reCAPTCHA being the common case). react-helmet has no built-in de-duplication for <script> tags, so each duplicate becomes its own real network request. This wraps react-helmet and drops exact-duplicate script src entries (from either the script prop or JSX children) before they reach it, client-side only -- SSR output is left untouched to avoid leaking per-request state across the long-lived Node process. Includes unit tests covering the dedupe behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Hi! I'm VTEX IO CI/CD Bot and I'll be helping you to publish your app! 🤖 Please select which version do you want to release:
And then you just need to merge your PR when you are ready! There is no need to create a release commit/tag.
|
|
Beep boop 🤖 I noticed you didn't make any changes at the
In order to keep track, I'll create an issue if you decide now is not a good time
|
The <script src> dedupe added in the previous commit only sees duplicates
that coexist in a single render pass. It misses a second, more common
duplication that is separated in time:
1. the server renders <script src="..."> into <head> and the browser
requests and executes it while parsing the document;
2. on the first client commit the component that declared the script
often hasn't mounted yet (it lives behind a lazily-loaded chunk), so
react-helmet sees one of its own tags that nothing in the tree
claims and removes it;
3. a commit later the component mounts, react-helmet no longer finds
the tag and appends a byte-identical one -- and a freshly inserted
<script> always executes.
Measured on storetheme.vtex.com by instrumenting appendChild/removeChild
from before page load: react-helmet removes the server-rendered
reCAPTCHA loader and re-appends a node-equal copy ~30ms later, in a
different commit, on roughly half of the loads (3 of 6 sampled, in both
production and a workspace running the dedupe). Each extra execution
registers another reCAPTCHA widget: two badges, two anchor iframes and
two parses of Google's ~340 KiB recaptcha__en.js, which together account
for ~71% of the page's unused JavaScript and a tail of long tasks that
keeps resetting Lighthouse's quiet-window requirement for TTI.
Seeding the module-scoped registry from the server-rendered tags at
module load makes step 3 a no-op. Dropping their `data-react-helmet`
attribute also makes react-helmet ignore them entirely, so step 2 never
happens either and the tag stays in the DOM where the server put it.
Co-authored-by: Cursor <cursoragent@cursor.com>
Follow-up: the dedupe alone didn't fix the reCAPTCHA duplication — root cause foundI validated the first commit live on What's actually happeningInstrumenting react-helmet removes the server-rendered The sequence is a hydration race: on the first client commit the Each execution registers another reCAPTCHA widget — visible in Google's internal registry:
It happened on 3 of 6 loads (1/3 in production, 2/3 in the workspace running this branch) — which is why the PageSpeed numbers kept moving. The first commit can't catch this because the two tags never coexist in a single render pass: the duplication is separated in time, not in the props list. Cost of the extra execution
Total reCAPTCHA cost on the page today is 450 KiB across 14 requests. The fix in the second commitThe registry already lives at module scope, so it survives across commits. Seeding it at module load from the tags react-helmet emitted server-side ( It also drops Module-load timing is safe: the runtime bundle's This isn't reCAPTCHA-specific — it applies to any |
What does this PR do?
Multiple independently-mounted component instances (e.g. several widgets on the same page) can each declare
<Helmet><script src="..." /></Helmet>for the same external script -- reCAPTCHA being the most common case.react-helmethas no built-in de-duplication for<script>tags (onlytitle/baseare singletons, andmeta/linkdedupe by specific attributes), so it just concatenates every mountedHelmetinstance's tags and each duplicate becomes its own real network request.This wraps
react-helmet'sHelmetand drops exact-duplicate<script src>entries (from either thescriptprop or JSX children) before they ever reach it, so only the first request for a given URL is rendered into the DOM.This is intentionally client-only. Registering "already requested" state at module scope would leak across unrelated requests during SSR (a long-lived Node process renders many different users' pages), so SSR output is left completely untouched -- only the client, which gets a fresh JS context on every page load, gets de-duplication.
How to test it?
vtex linkthis app on a workspace.<Helmet><script src="SAME_URL" /></Helmet>(e.g. two instances of the same reCAPTCHA-backed widget on one page).Unit tests are included in
react/__tests__/Helmet.test.tsxcovering:scriptprop are droppedchildrenare droppedscriptprop andchildrenon the same instancesrc(inline scripts) are never dedupedtitle,meta, etc.) are left untouchedI also manually verified the effect end-to-end with a small local harness (esbuild + the real
Helmet.tsx) mounting two independent widgets requesting the same script: unpatched code made 2 requests, patched code made 1.Describe alternatives you've considered, if any.
react-helmet's combined/rewound state instead of per-mount filtering -- rejected because it would require patching intoreact-helmetinternals rather than wrapping its public API.Related to / Depends on
N/A