Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/site/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,20 @@ import { NextIntlClientProvider } from 'next-intl';
import BaseLayout from '#site/layouts/Base';
import { VERCEL_ENV } from '#site/next.constants.mjs';
import { IBM_PLEX_MONO, OPEN_SANS } from '#site/next.fonts';
import { siteConfig } from '#site/next.json.mjs';
import { ThemeProvider } from '#site/providers/themeProvider';
import { BANNER_DISMISSAL_STORAGE_KEY } from '#site/util/banner';

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

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

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

const banner = siteConfig.websiteBanners.index;

const bannerPreHydrationScript = `try{var s=document.currentScript;if(s&&localStorage.getItem(s.dataset.storageKey)===s.dataset.banner){document.documentElement.dataset.bannerDismissed='true'}}catch{}`;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea... that's not happening


type RootLayoutProps = PropsWithChildren<{
params: Promise<{ locale: string }>;
}>;
Expand All @@ -32,6 +38,16 @@ const RootLayout: FC<RootLayoutProps> = async ({ children, params }) => {
lang={hrefLang}
suppressHydrationWarning
>
<head>
{banner && (
<script
data-banner={banner.text}
data-storage-key={BANNER_DISMISSAL_STORAGE_KEY}
>
{bannerPreHydrationScript}
</script>
)}
</head>
<body suppressHydrationWarning>
<NextIntlClientProvider>
<ThemeProvider>
Expand Down
25 changes: 24 additions & 1 deletion apps/site/components/withBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
'use client';

import { ArrowUpRightIcon } from '@heroicons/react/24/outline';
import Banner from '@node-core/ui-components/Common/Banner';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';

import Link from '#site/components/Link';
import { siteConfig } from '#site/next.json.mjs';
import { BANNER_DISMISSAL_STORAGE_KEY } from '#site/util/banner';
import { dateIsBetween } from '#site/util/date';

import type { FC } from 'react';
Expand All @@ -12,13 +16,32 @@
const banner = siteConfig.websiteBanners[section];
const t = useTranslations();

if (banner && dateIsBetween(banner.startDate, banner.endDate)) {
const [dismissed, setDismissed] = useState(false);

// The pre-hydration script hides a dismissed banner before the first paint.
// This effect then synchronizes that state with React after hydration.
useEffect(() => {
if (banner) {
setDismissed(

Check warning on line 25 in apps/site/components/withBanner.tsx

View workflow job for this annotation

GitHub Actions / Quality checks

Do not call the 'set' function 'setDismissed' of 'useState' synchronously in an effect. This can lead to unnecessary re-renders and performance issues
localStorage.getItem(BANNER_DISMISSAL_STORAGE_KEY) === banner.text
);
}
}, [banner]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dismissed banner flashes on remount

Medium Severity

After closing the banner in the same visit, client-side navigation that swaps page layouts remounts WithNavBar with dismissed reset to false. Dismissal is reapplied only in a useEffect after paint, and onClose does not set html[data-banner-dismissed], so the pre-hydration CSS hide does not apply and the banner can flash until the effect runs.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dc9e701. Configure here.


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

const onClose = () => {
localStorage.setItem(BANNER_DISMISSAL_STORAGE_KEY, banner.text);
setDismissed(true);
};

return (
<Banner
type={banner.type}
aria-label={t(`components.banner.${bannerType}`)}
data-dismissible-banner=""
onClose={onClose}
>
{banner.link ? (
<Link href={banner.link}>{banner.text}</Link>
Expand Down
4 changes: 4 additions & 0 deletions apps/site/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
@import '@node-core/ui-components/src/styles/index.css';
@import '@node-core/rehype-shiki/index.css';

html[data-banner-dismissed='true'] [data-dismissible-banner] {
display: none;
}

/**
* To enhance readability for Korean users, line spacing is increased,
* line breaks in the middle of words are prevented, and long words are
Expand Down
1 change: 1 addition & 0 deletions apps/site/util/banner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const BANNER_DISMISSAL_STORAGE_KEY = 'banner-dismissal';
2 changes: 1 addition & 1 deletion packages/ui-components/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@node-core/ui-components",
"version": "1.7.1",
"version": "1.7.2",
"type": "module",
"exports": {
"./*": {
Expand Down
15 changes: 14 additions & 1 deletion packages/ui-components/src/Common/Banner/index.module.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
@reference "../../styles/index.css";

.banner {
@apply flex
@apply relative
flex
w-full
flex-row
items-center
Expand Down Expand Up @@ -40,3 +41,15 @@
.warning {
@apply bg-warning-600;
}

.closeButton {
@apply absolute
right-4
text-lg;

span {
@apply text-white/60
transition-colors
hover:text-white;
}
}
8 changes: 8 additions & 0 deletions packages/ui-components/src/Common/Banner/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,12 @@ export const Warning: Story = {
},
};

export const Dismissible: Story = {
args: {
children: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
type: 'default',
onClose: () => {},
},
};

export default { component: Banner } as Meta;
23 changes: 21 additions & 2 deletions packages/ui-components/src/Common/Banner/index.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,40 @@
import type { FC, HTMLAttributes, PropsWithChildren } from 'react';
import classNames from 'classnames';

import type {
FC,
HTMLAttributes,
MouseEventHandler,
PropsWithChildren,
} from 'react';

import styles from './index.module.css';

export type BannerProps = {
type?: 'default' | 'warning' | 'error';
onClose?: MouseEventHandler<HTMLButtonElement>;
} & HTMLAttributes<HTMLElement>;

const Banner: FC<PropsWithChildren<BannerProps>> = ({
type = 'default',
onClose,
children,
...props
}) => (
<section
className={`${styles.banner} ${styles[type] || styles.default}`}
className={classNames(styles.banner, styles[type] || styles.default)}
{...props}
>
{children}
{onClose && (
<button
type="button"
className={styles.closeButton}
aria-label="Close banner"
onClick={onClose}
>
<span aria-hidden="true">&times;</span>
</button>
)}
</section>
);

Expand Down
Loading