fix(dashboard): strip browser-extension attrs before hydration (#7073)

* fix(dashboard): strip browser-extension attrs before hydration

Browser extensions (Bitdefender's bis_skin_checked, Grammarly's
data-gr-ext-installed, LanguageTool's data-lt-installed) inject
attributes into the DOM after SSR but before React hydrates, causing
"attributes didn't match" hydration errors in the dev console.

The <html> and <body> tags already have suppressHydrationWarning, but
React only applies it one level deep — it doesn't propagate to Next.js
internal elements like the <div hidden> metadata boundary where the
mismatch actually surfaces.

Add a synchronous pre-hydration cleanup script in <head> that:
1. Strips known extension attributes from document.documentElement
2. Observes for late injections via MutationObserver
3. Auto-disconnects after 5s (well past typical hydration)

Verified: curl /login confirms the script is present in the served HTML
with all target attributes (bis_skin_checked, data-google-query-id,
data-gr-ext-installed, data-lt-installed) and the MutationObserver.
Typecheck and lint clean.

* test(dashboard): guard the pre-hydration extension-attr strip script

Add a regression test mirroring the existing
tests/unit/dashboard/crypto-randomuuid-polyfill.test.ts pattern:
readFileSync src/app/layout.tsx and assert the full known
browser-extension attribute list (bis_skin_checked, data-google-query-id,
data-new-gr-c-s-check-loaded, data-gr-ext-installed, data-lt-installed,
data-lt-tmp-id), the MutationObserver wiring (attributeFilter + 5s
auto-disconnect), and the synchronous initial strip against
document.documentElement all stay present in layout.tsx.

Verified fail-then-pass: the assertions fail against the pre-fix tree
(no such script present) and pass once the pre-hydration script is
present, so a future layout.tsx refactor can no longer silently drop
this script and reintroduce the hydration-mismatch warnings extension
users were seeing.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Fdy
2026-07-19 02:18:01 +02:00
committed by GitHub
parent 0c9ca5f3b4
commit 02d4a9a8fe
2 changed files with 104 additions and 0 deletions

View File

@@ -60,6 +60,38 @@ export default async function RootLayout({ children }) {
return (
<html lang={locale} dir={isRtl ? "rtl" : "ltr"} suppressHydrationWarning>
<head>
{/* Pre-hydration cleanup: browser extensions (Bitdefender's
bis_skin_checked, Grammarly's data-gr-ext-installed, LanguageTool's
data-lt-installed, etc.) inject attributes into the DOM after SSR
but before React hydrates, causing hydration mismatch warnings in
dev. Strip them synchronously and observe for late injections; the
observer auto-disconnects after 5s (well past typical hydration). */}
<script
dangerouslySetInnerHTML={{
__html: `
(function () {
var ATTRS = ["bis_skin_checked", "data-google-query-id", "data-new-gr-c-s-check-loaded", "data-gr-ext-installed", "data-lt-installed", "data-lt-tmp-id"];
function strip(el) {
for (var i = 0; i < ATTRS.length; i++) {
if (el.hasAttribute(ATTRS[i])) el.removeAttribute(ATTRS[i]);
}
}
strip(document.documentElement);
if (typeof MutationObserver === "undefined") return;
var obs = new MutationObserver(function (muts) {
for (var i = 0; i < muts.length; i++) {
var m = muts[i];
if (m.type === "attributes" && ATTRS.indexOf(m.attributeName) !== -1) {
m.target.removeAttribute(m.attributeName);
}
}
});
obs.observe(document.documentElement, { attributes: true, subtree: true, attributeFilter: ATTRS });
setTimeout(function () { obs.disconnect(); }, 5000);
})();
`,
}}
/>
{/* Material Symbols icon font is self-hosted via globals.css
(@import "material-symbols/outlined.css") so icons render even when
the Google Fonts CDN is unreachable (#3695). */}

View File

@@ -0,0 +1,72 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve, join } from "node:path";
// Regression guard for the browser-extension hydration-mismatch fix: extensions
// like Bitdefender (bis_skin_checked), Google Search (data-google-query-id),
// Grammarly (data-new-gr-c-s-check-loaded / data-gr-ext-installed) and
// LanguageTool (data-lt-installed / data-lt-tmp-id) inject attributes into the
// DOM after SSR but before React hydrates. React's suppressHydrationWarning on
// <html>/<body> only applies one level deep, so the mismatch still surfaces on
// Next.js's internal elements. The fix adds a synchronous pre-hydration <script>
// in src/app/layout.tsx that strips the known attributes from
// document.documentElement and keeps stripping late injections via a
// MutationObserver that auto-disconnects after 5s. These assertions fail on the
// pre-fix tree (no such script present) and stay green afterwards, preventing a
// future layout.tsx refactor from silently dropping this script.
const cwd = process.cwd();
const layoutPath = resolve(join(cwd, "src/app/layout.tsx"));
const EXPECTED_ATTRS = [
"bis_skin_checked",
"data-google-query-id",
"data-new-gr-c-s-check-loaded",
"data-gr-ext-installed",
"data-lt-installed",
"data-lt-tmp-id",
];
test("layout.tsx strips the full known browser-extension attribute list before hydration", () => {
const layout = readFileSync(layoutPath, "utf8");
for (const attr of EXPECTED_ATTRS) {
assert.ok(
layout.includes(`"${attr}"`),
`layout.tsx must list "${attr}" among the pre-hydration extension attributes to strip`
);
}
assert.match(
layout,
/removeAttribute\s*\(\s*ATTRS\s*\[\s*i\s*\]\s*\)/,
"layout.tsx must call removeAttribute for each known extension attribute"
);
});
test("layout.tsx observes late attribute injections via MutationObserver and auto-disconnects", () => {
const layout = readFileSync(layoutPath, "utf8");
assert.match(
layout,
/new MutationObserver\s*\(/,
"layout.tsx must set up a MutationObserver to catch extension attributes injected after the initial strip"
);
assert.match(
layout,
/attributeFilter\s*:\s*ATTRS/,
"the MutationObserver must filter on the same known extension ATTRS list"
);
assert.match(
layout,
/setTimeout\s*\(\s*function\s*\(\s*\)\s*\{\s*obs\.disconnect\(\)\s*;?\s*\}\s*,\s*5000\s*\)/,
"the MutationObserver must auto-disconnect after 5s (well past typical hydration) to avoid a long-lived observer"
);
});
test("layout.tsx strips extension attributes from document.documentElement synchronously (before React hydrates)", () => {
const layout = readFileSync(layoutPath, "utf8");
assert.match(
layout,
/strip\s*\(\s*document\.documentElement\s*\)/,
"layout.tsx must run the strip synchronously against document.documentElement on initial script execution"
);
});