Skip to content

Commit dc9e701

Browse files
committed
fix(banner): prevent dismissal flicker before hydration
1 parent b2dc64f commit dc9e701

4 files changed

Lines changed: 30 additions & 6 deletions

File tree

‎apps/site/app/[locale]/layout.tsx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
/* eslint-disable @eslint-react/dom-no-dangerously-set-innerhtml -- The script is static; banner data is escaped in data attributes. */
2+
13
import { availableLocales, defaultLocale } from '@node-core/website-i18n';
24
import { Analytics } from '@vercel/analytics/react';
35
import { SpeedInsights } from '@vercel/speed-insights/next';
@@ -7,14 +9,20 @@ import { NextIntlClientProvider } from 'next-intl';
79
import BaseLayout from '#site/layouts/Base';
810
import { VERCEL_ENV } from '#site/next.constants.mjs';
911
import { IBM_PLEX_MONO, OPEN_SANS } from '#site/next.fonts';
12+
import { siteConfig } from '#site/next.json.mjs';
1013
import { ThemeProvider } from '#site/providers/themeProvider';
14+
import { BANNER_DISMISSAL_STORAGE_KEY } from '#site/util/banner';
1115

1216
import type { FC, PropsWithChildren } from 'react';
1317

1418
import '#site/styles/index.css';
1519

1620
const fontClasses = classNames(IBM_PLEX_MONO.variable, OPEN_SANS.variable);
1721

22+
const banner = siteConfig.websiteBanners.index;
23+
24+
const bannerPreHydrationScript = `try{var s=document.currentScript;if(s&&localStorage.getItem(s.dataset.storageKey)===s.dataset.banner){document.documentElement.dataset.bannerDismissed='true'}}catch{}`;
25+
1826
type RootLayoutProps = PropsWithChildren<{
1927
params: Promise<{ locale: string }>;
2028
}>;
@@ -32,6 +40,15 @@ const RootLayout: FC<RootLayoutProps> = async ({ children, params }) => {
3240
lang={hrefLang}
3341
suppressHydrationWarning
3442
>
43+
<head>
44+
{banner && (
45+
<script
46+
data-banner={banner.text}
47+
data-storage-key={BANNER_DISMISSAL_STORAGE_KEY}
48+
dangerouslySetInnerHTML={{ __html: bannerPreHydrationScript }}
49+
/>
50+
)}
51+
</head>
3552
<body suppressHydrationWarning>
3653
<NextIntlClientProvider>
3754
<ThemeProvider>

‎apps/site/components/withBanner.tsx‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,38 +7,40 @@ import { useEffect, useState } from 'react';
77

88
import Link from '#site/components/Link';
99
import { siteConfig } from '#site/next.json.mjs';
10+
import { BANNER_DISMISSAL_STORAGE_KEY } from '#site/util/banner';
1011
import { dateIsBetween } from '#site/util/date';
1112

1213
import type { FC } from 'react';
1314

14-
const STORAGE_KEY = 'banner-dismissal';
15-
1615
const WithBanner: FC<{ section: string }> = ({ section }) => {
1716
const banner = siteConfig.websiteBanners[section];
1817
const t = useTranslations();
1918

2019
const [dismissed, setDismissed] = useState(false);
2120

22-
// localStorage is only available on the client, so the banner is always
23-
// rendered on the server and hidden after hydration if previously dismissed
21+
// The pre-hydration script hides a dismissed banner before the first paint.
22+
// This effect then synchronizes that state with React after hydration.
2423
useEffect(() => {
2524
if (banner) {
26-
setDismissed(localStorage.getItem(STORAGE_KEY) === banner.text);
25+
setDismissed(
26+
localStorage.getItem(BANNER_DISMISSAL_STORAGE_KEY) === banner.text
27+
);
2728
}
2829
}, [banner]);
2930

3031
if (banner && !dismissed && dateIsBetween(banner.startDate, banner.endDate)) {
3132
const bannerType = banner.type || 'default';
3233

3334
const onClose = () => {
34-
localStorage.setItem(STORAGE_KEY, banner.text);
35+
localStorage.setItem(BANNER_DISMISSAL_STORAGE_KEY, banner.text);
3536
setDismissed(true);
3637
};
3738

3839
return (
3940
<Banner
4041
type={banner.type}
4142
aria-label={t(`components.banner.${bannerType}`)}
43+
data-dismissible-banner=""
4244
onClose={onClose}
4345
>
4446
{banner.link ? (

‎apps/site/styles/index.css‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
@import '@node-core/ui-components/src/styles/index.css';
1111
@import '@node-core/rehype-shiki/index.css';
1212

13+
html[data-banner-dismissed='true'] [data-dismissible-banner] {
14+
display: none;
15+
}
16+
1317
/**
1418
* To enhance readability for Korean users, line spacing is increased,
1519
* line breaks in the middle of words are prevented, and long words are

‎apps/site/util/banner.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const BANNER_DISMISSAL_STORAGE_KEY = 'banner-dismissal';

0 commit comments

Comments
 (0)