From f90d5a210d8c3421cc0ad99bbab65bbc0830b6c2 Mon Sep 17 00:00:00 2001 From: AdamMagued Date: Tue, 3 Nov 2026 09:03:23 +0200 Subject: [PATCH] fix(theme-classic): show announcement bar on first load when id changes When an announcement bar is dismissed, docusaurus stores both the dismissal flag and announcement id in localStorage. Before React hydration, getAnnouncementBarInlineScript runs to set data-announcement-bar-initially-dismissed on the root HTML element. Previously, this script evaluated only the dismissal flag, which kept newly configured announcements with a different id hidden on the first page load until a subsequent page reload. Pass the configured announcementBar object into getAnnouncementBarInlineScript and compare the stored announcement id against the configured id before checking the dismissal flag. --- .../__snapshots__/inlineScripts.test.ts.snap | 42 ++++ .../src/__tests__/inlineScripts.test.ts | 215 +++++++++++++++++- .../docusaurus-theme-classic/src/index.ts | 2 +- .../src/inlineScripts.ts | 16 +- 4 files changed, 272 insertions(+), 3 deletions(-) diff --git a/packages/docusaurus-theme-classic/src/__tests__/__snapshots__/inlineScripts.test.ts.snap b/packages/docusaurus-theme-classic/src/__tests__/__snapshots__/inlineScripts.test.ts.snap index 2cee3bc7aa98..fdf84f2bcb94 100644 --- a/packages/docusaurus-theme-classic/src/__tests__/__snapshots__/inlineScripts.test.ts.snap +++ b/packages/docusaurus-theme-classic/src/__tests__/__snapshots__/inlineScripts.test.ts.snap @@ -1,5 +1,47 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`inlineScripts > getAnnouncementBarInlineScript > generates inline javascript with announcement bar id 1`] = ` +"(function() { + function isDismissed() { + try { + if (localStorage.getItem('docusaurus.announcement.id') !== "announcement-1") { + return false; + } + return localStorage.getItem('docusaurus.announcement.dismiss') === 'true'; + } catch (err) {} + return false; + } + document.documentElement.setAttribute('data-announcement-bar-initially-dismissed', isDismissed()); +})();" +`; + +exports[`inlineScripts > getAnnouncementBarInlineScript > generates inline javascript with custom siteStorage namespace 1`] = ` +"(function() { + function isDismissed() { + try { + if (localStorage.getItem('docusaurus.announcement.id_custom_ns') !== "announcement-1") { + return false; + } + return localStorage.getItem('docusaurus.announcement.dismiss_custom_ns') === 'true'; + } catch (err) {} + return false; + } + document.documentElement.setAttribute('data-announcement-bar-initially-dismissed', isDismissed()); +})();" +`; + +exports[`inlineScripts > getAnnouncementBarInlineScript > generates inline javascript without announcementBar (fallback) 1`] = ` +"(function() { + function isDismissed() { + try { + return localStorage.getItem('docusaurus.announcement.dismiss') === 'true'; + } catch (err) {} + return false; + } + document.documentElement.setAttribute('data-announcement-bar-initially-dismissed', isDismissed()); +})();" +`; + exports[`inlineScripts > inline javascript for default mode and no switch 1`] = ` "(function() { function getSystemColorMode() { diff --git a/packages/docusaurus-theme-classic/src/__tests__/inlineScripts.test.ts b/packages/docusaurus-theme-classic/src/__tests__/inlineScripts.test.ts index 2690e0b09c89..98c75b7accae 100644 --- a/packages/docusaurus-theme-classic/src/__tests__/inlineScripts.test.ts +++ b/packages/docusaurus-theme-classic/src/__tests__/inlineScripts.test.ts @@ -6,7 +6,41 @@ */ import {describe, expect, it} from 'vitest'; -import {getThemeInlineScript} from '../inlineScripts'; +import { + getThemeInlineScript, + getAnnouncementBarInlineScript, +} from '../inlineScripts'; + +function executeInlineScript({ + script, + storage = {}, + throwsError = false, +}: { + script: string; + storage?: Record; + throwsError?: boolean; +}) { + const attributes: Record = {}; + const mockLocalStorage = { + getItem: (key: string) => { + if (throwsError) { + throw new Error('SecurityError: LocalStorage is disabled'); + } + return storage[key] ?? null; + }, + }; + const mockDocument = { + documentElement: { + setAttribute: (name: string, value: string | boolean) => { + attributes[name] = String(value); + }, + getAttribute: (name: string) => attributes[name] ?? null, + }, + }; + const fn = new Function('localStorage', 'document', script); + fn(mockLocalStorage, mockDocument); + return attributes; +} describe('inlineScripts', () => { it('inline javascript for default options', () => { @@ -40,4 +74,183 @@ describe('inlineScripts', () => { }), ).toMatchSnapshot(); }); + + describe('getAnnouncementBarInlineScript', () => { + const siteStorage = { + type: 'localStorage' as const, + namespace: '', + }; + + it('generates inline javascript with announcement bar id', () => { + expect( + getAnnouncementBarInlineScript({ + siteStorage, + announcementBar: { + id: 'announcement-1', + }, + }), + ).toMatchSnapshot(); + }); + + it('generates inline javascript with custom siteStorage namespace', () => { + expect( + getAnnouncementBarInlineScript({ + siteStorage: { + type: 'localStorage', + namespace: '_custom_ns', + }, + announcementBar: { + id: 'announcement-1', + }, + }), + ).toMatchSnapshot(); + }); + + it('generates inline javascript without announcementBar (fallback)', () => { + expect( + getAnnouncementBarInlineScript({ + siteStorage, + }), + ).toMatchSnapshot(); + }); + + it('treats announcement as not dismissed when storage is empty', () => { + const script = getAnnouncementBarInlineScript({ + siteStorage, + announcementBar: { + id: 'announcement-1', + }, + }); + const attributes = executeInlineScript({script, storage: {}}); + expect(attributes['data-announcement-bar-initially-dismissed']).toBe( + 'false', + ); + }); + + it('treats announcement as dismissed when stored id matches and dismiss is true', () => { + const script = getAnnouncementBarInlineScript({ + siteStorage, + announcementBar: { + id: 'announcement-1', + }, + }); + const attributes = executeInlineScript({ + script, + storage: { + 'docusaurus.announcement.id': 'announcement-1', + 'docusaurus.announcement.dismiss': 'true', + }, + }); + expect(attributes['data-announcement-bar-initially-dismissed']).toBe( + 'true', + ); + }); + + it('treats announcement as not dismissed when new id is configured after previous dismissal', () => { + const script = getAnnouncementBarInlineScript({ + siteStorage, + announcementBar: { + id: 'announcement-2', + }, + }); + const attributes = executeInlineScript({ + script, + storage: { + 'docusaurus.announcement.id': 'announcement-1', + 'docusaurus.announcement.dismiss': 'true', + }, + }); + expect(attributes['data-announcement-bar-initially-dismissed']).toBe( + 'false', + ); + }); + + it('treats announcement as not dismissed when dismiss is false even with matching id', () => { + const script = getAnnouncementBarInlineScript({ + siteStorage, + announcementBar: { + id: 'announcement-1', + }, + }); + const attributes = executeInlineScript({ + script, + storage: { + 'docusaurus.announcement.id': 'announcement-1', + 'docusaurus.announcement.dismiss': 'false', + }, + }); + expect(attributes['data-announcement-bar-initially-dismissed']).toBe( + 'false', + ); + }); + + it('respects siteStorage namespace during execution', () => { + const script = getAnnouncementBarInlineScript({ + siteStorage: { + type: 'localStorage', + namespace: '_my_docs', + }, + announcementBar: { + id: 'announcement-1', + }, + }); + const dismissedAttrs = executeInlineScript({ + script, + storage: { + 'docusaurus.announcement.id_my_docs': 'announcement-1', + 'docusaurus.announcement.dismiss_my_docs': 'true', + }, + }); + expect(dismissedAttrs['data-announcement-bar-initially-dismissed']).toBe( + 'true', + ); + + const otherStorageAttrs = executeInlineScript({ + script, + storage: { + 'docusaurus.announcement.id': 'announcement-1', + 'docusaurus.announcement.dismiss': 'true', + }, + }); + expect( + otherStorageAttrs['data-announcement-bar-initially-dismissed'], + ).toBe('false'); + }); + + it('escapes special characters in announcement bar id safely', () => { + const complexId = 'announcement-"special"-\'quotes\'-id'; + const script = getAnnouncementBarInlineScript({ + siteStorage, + announcementBar: { + id: complexId, + }, + }); + const attributes = executeInlineScript({ + script, + storage: { + 'docusaurus.announcement.id': complexId, + 'docusaurus.announcement.dismiss': 'true', + }, + }); + expect(attributes['data-announcement-bar-initially-dismissed']).toBe( + 'true', + ); + }); + + it('treats announcement as not dismissed when localStorage access throws', () => { + const script = getAnnouncementBarInlineScript({ + siteStorage, + announcementBar: { + id: 'announcement-1', + }, + }); + const attributes = executeInlineScript({ + script, + throwsError: true, + }); + expect(attributes['data-announcement-bar-initially-dismissed']).toBe( + 'false', + ); + }); + }); }); diff --git a/packages/docusaurus-theme-classic/src/index.ts b/packages/docusaurus-theme-classic/src/index.ts index 8c5a236835a3..b57e3bf69eb1 100644 --- a/packages/docusaurus-theme-classic/src/index.ts +++ b/packages/docusaurus-theme-classic/src/index.ts @@ -134,7 +134,7 @@ export default function themeClassic( innerHTML: ` ${getThemeInlineScript({colorMode, siteStorage})} ${DataAttributeQueryStringInlineJavaScript} -${announcementBar ? getAnnouncementBarInlineScript({siteStorage}) : ''} +${announcementBar ? getAnnouncementBarInlineScript({siteStorage, announcementBar}) : ''} `, }, ], diff --git a/packages/docusaurus-theme-classic/src/inlineScripts.ts b/packages/docusaurus-theme-classic/src/inlineScripts.ts index fe49094a52c4..d49b375d85cd 100644 --- a/packages/docusaurus-theme-classic/src/inlineScripts.ts +++ b/packages/docusaurus-theme-classic/src/inlineScripts.ts @@ -76,22 +76,36 @@ export const DataAttributeQueryStringInlineJavaScript = ` // the announcement bar asap (before React hydration) export function getAnnouncementBarInlineScript({ siteStorage, + announcementBar, }: { siteStorage: SiteStorage; + announcementBar?: { + id?: string; + }; }): string { // Duplicated constant. Unfortunately we can't import it from theme-common, as // we need to support older nodejs versions without ESM support // TODO: import from theme-common once we support Node.js with ESM support // + move all those announcementBar stuff there too + const AnnouncementBarIdStorageKey = `docusaurus.announcement.id${siteStorage.namespace}`; const AnnouncementBarDismissStorageKey = `docusaurus.announcement.dismiss${siteStorage.namespace}`; const AnnouncementBarDismissDataAttribute = 'data-announcement-bar-initially-dismissed'; + const announcementBarId = announcementBar?.id; /* language=js */ return `(function() { function isDismissed() { try { - return localStorage.getItem('${AnnouncementBarDismissStorageKey}') === 'true'; + ${ + announcementBarId !== undefined + ? `if (localStorage.getItem('${AnnouncementBarIdStorageKey}') !== ${JSON.stringify( + announcementBarId, + )}) { + return false; + }\n ` + : '' + }return localStorage.getItem('${AnnouncementBarDismissStorageKey}') === 'true'; } catch (err) {} return false; }