Skip to content

perf: de-duplicate identical <script src> tags in Helmet - #689

Open
iago1501 wants to merge 2 commits into
masterfrom
perf/helmet-dedupe-scripts
Open

perf: de-duplicate identical <script src> tags in Helmet#689
iago1501 wants to merge 2 commits into
masterfrom
perf/helmet-dedupe-scripts

Conversation

@iago1501

Copy link
Copy Markdown
Contributor

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-helmet has no built-in de-duplication for <script> tags (only title/base are singletons, and meta/link dedupe by specific attributes), so it just concatenates every mounted Helmet instance's tags and each duplicate becomes its own real network request.

This wraps react-helmet's Helmet and drops exact-duplicate <script src> entries (from either the script prop 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?

  1. vtex link this app on a workspace.
  2. Render two independent component instances that each declare <Helmet><script src="SAME_URL" /></Helmet> (e.g. two instances of the same reCAPTCHA-backed widget on one page).
  3. Check the Network tab: the script's URL should only be requested once, even though it's declared twice.

Unit tests are included in react/__tests__/Helmet.test.tsx covering:

  • a single script passes through unchanged
  • duplicates via the script prop are dropped
  • duplicates via JSX children are dropped
  • dedupe across the script prop and children on the same instance
  • dedupe across independently-mounted instances (the real-world scenario)
  • scripts without a src (inline scripts) are never deduped
  • other props/children (title, meta, etc.) are left untouched

I 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.

  • Deduping globally via react-helmet's combined/rewound state instead of per-mount filtering -- rejected because it would require patching into react-helmet internals rather than wrapping its public API.
  • Applying the same dedupe during SSR -- rejected because the "already requested" registry would need to be per-request, and a simpler client-only registry is both correct (SSR HTML always contains the deterministic set of scripts declared by the page) and avoids that added complexity.

Related to / Depends on

N/A

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>
@vtex-io-ci-cd

vtex-io-ci-cd Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

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:

  • Patch (backwards-compatible bug fixes)

  • Minor (backwards-compatible functionality)

  • Major (incompatible API changes)

And then you just need to merge your PR when you are ready! There is no need to create a release commit/tag.

  • No thanks, I would rather do it manually 😞

@vtex-io-docs-bot

Copy link
Copy Markdown

Beep boop 🤖

I noticed you didn't make any changes at the docs/ folder

  • There's nothing new to document 🤔
  • I'll do it later 😞

In order to keep track, I'll create an issue if you decide now is not a good time

  • I just updated 🎉🎉

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>
@iago1501

Copy link
Copy Markdown
Contributor Author

Follow-up: the dedupe alone didn't fix the reCAPTCHA duplication — root cause found

I validated the first commit live on storetheme.vtex.com against a workspace running it, and the duplicate reCAPTCHA widget was still there. Digging in, the in-render dedupe was addressing the wrong duplication.

What's actually happening

Instrumenting appendChild/removeChild from before page load (fresh browser contexts, 6 samples):

remove  t=249ms   <- Node.removeChild <- common.min.js (react-helmet updateTags)
append  t=285ms   <- Node.appendChild <- common.min.js (react-helmet updateTags)

react-helmet removes the server-rendered <script src=".../enterprise.js"> and appends a byte-identical copy ~30ms later. I confirmed the appended node is isEqualNode === true against a clone of the SSR tag, so it isn't an attribute mismatch — and the consistent ~30ms gap (212→241, 183→218, 249→285, 254→290) means these are two separate commits, not one updateTags call.

The sequence is a hydration race: on the first client commit the RecaptchaProvider hasn't mounted yet (it's behind a lazy chunk), so react-helmet sees one of its own tags that nothing in the tree claims and removes it; a commit later the provider mounts and react-helmet re-creates it. A freshly inserted <script> always executes, so the loader runs twice.

Each execution registers another reCAPTCHA widget — visible in Google's internal registry:

___grecaptcha_cfg.clients badges recaptcha__en.js
good load 100000 1 1
bad load 100000, 100001 2 2

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

  • 343 KiB of Google's recaptcha__en.js parsed and executed a second time (transfer is 0 — memory cache — but the CPU is paid in full). The two copies account for ~71% of the page's total unused JavaScript (247 KiB + 188 KiB of 614 KiB).
  • A second anchor iframe (29.4 KiB of real transfer), plus duplicate webworker.js, styles__ltr.css and logo_48.png.
  • A tail of long tasks (50–113ms, ~1–1.5s apart, out to 10.7s) from the second widget's polling, which keeps resetting Lighthouse's required 5s quiet window and inflates TTI.

Total reCAPTCHA cost on the page today is 450 KiB across 14 requests.

The fix in the second commit

The registry already lives at module scope, so it survives across commits. Seeding it at module load from the tags react-helmet emitted server-side (script[data-react-helmet][src]) makes step 3 a no-op — the src is already known to have been requested, so it never reaches react-helmet and no new tag is created.

It also drops data-react-helmet from those adopted tags. That attribute is how react-helmet recognizes what it owns, so removing it makes react-helmet ignore them entirely — step 2 doesn't happen either, and the tag stays in the DOM exactly where the server put it instead of being removed and never restored.

Module-load timing is safe: the runtime bundle's <script> tags sit at the end of <body> (verified in the SSR HTML — helmet tags at offset ~33k, bundles at ~575k of 585k), so the whole <head> is parsed by the time this runs.

This isn't reCAPTCHA-specific — it applies to any <script src> any app declares through Helmet, including third-party analytics and pixel tags, all of which are currently at risk of executing twice.

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