mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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). */}
|
||||
|
||||
72
tests/unit/dashboard/hydration-extension-attrs.test.ts
Normal file
72
tests/unit/dashboard/hydration-extension-attrs.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user