Files
OmniRoute/src/app/layout.tsx
Fdy 02d4a9a8fe 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>
2026-07-18 21:18:01 -03:00

152 lines
6.3 KiB
TypeScript

import { Inter } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/shared/components/ThemeProvider";
import { NextIntlClientProvider } from "next-intl";
import { getMessages, getLocale } from "next-intl/server";
import { RTL_LOCALES } from "@/i18n/config";
import { normalizeComplianceEventTypes } from "@/i18n/request";
import { getSettings } from "@/lib/db/settings";
import type { Viewport } from "next";
import { PwaRegister } from "@/shared/components/PwaRegister";
import { LocaleAutoDetect } from "@/shared/components/LocaleAutoDetect";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
export const viewport: Viewport = {
themeColor: "#0b0f1a",
viewportFit: "cover",
};
export async function generateMetadata() {
const settings = await getSettings();
const instanceName = settings?.instanceName || "OmniRoute";
const customFaviconUrl = settings?.customFaviconUrl || settings?.customFaviconBase64;
return {
title: `${instanceName} — AI Gateway for Multi-Provider LLMs`,
description:
"OmniRoute is an AI gateway for multi-provider LLMs. One endpoint for all your AI providers.",
manifest: "/manifest.webmanifest",
applicationName: instanceName,
appleWebApp: {
capable: true,
title: instanceName,
statusBarStyle: "black-translucent",
},
other: {
"mobile-web-app-capable": "yes",
},
icons: {
icon: customFaviconUrl
? "/api/settings/favicon"
: [
{ url: "/favicon.ico", sizes: "any" },
{ url: "/favicon.svg", type: "image/svg+xml" },
{ url: "/icon-512.png", type: "image/png", sizes: "512x512" },
],
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
},
};
}
export default async function RootLayout({ children }) {
const locale = await getLocale();
const messages = normalizeComplianceEventTypes((await getMessages()) as Record<string, unknown>);
const isRtl = RTL_LOCALES.includes(locale as (typeof RTL_LOCALES)[number]);
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). */}
<script
dangerouslySetInnerHTML={{
__html: `
if (typeof window !== 'undefined') {
if (!window.crypto) {
window.crypto = {};
}
if (!window.crypto.randomUUID) {
window.crypto.randomUUID = function() {
if (window.crypto.getRandomValues) {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = window.crypto.getRandomValues(new Uint8Array(1))[0] % 16;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
}
}
try {
const stored = localStorage.getItem('theme');
const parsed = stored ? JSON.parse(stored) : null;
const theme = parsed?.state?.theme || 'system';
if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) {}
`,
}}
/>
</head>
<body className={`${inter.variable} font-sans antialiased`} suppressHydrationWarning>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:bg-[#6366f1] focus:text-white focus:rounded-lg focus:text-sm focus:font-semibold focus:shadow-lg"
>
Skip to content
</a>
<NextIntlClientProvider locale={locale} messages={messages}>
<PwaRegister />
<LocaleAutoDetect />
<ThemeProvider>{children}</ThemeProvider>
</NextIntlClientProvider>
</body>
</html>
);
}