From 4c519a8c47efd0ec9a562fc86240755a5ae5b4bd Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Wed, 21 Oct 2026 14:20:23 +0530 Subject: [PATCH 1/4] fix(core): ignore canary and prerelease versions in update notifier --- packages/docusaurus/bin/beforeCli.mjs | 28 +++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/docusaurus/bin/beforeCli.mjs b/packages/docusaurus/bin/beforeCli.mjs index bb076c340553..7256318eeaa0 100644 --- a/packages/docusaurus/bin/beforeCli.mjs +++ b/packages/docusaurus/bin/beforeCli.mjs @@ -35,6 +35,24 @@ const { engines: {node: requiredVersion}, } = packageJson; +/** + * We don't want to display update message for canary or prerelease versions. + * See https://github.com/facebook/docusaurus/issues/5378 + * and https://github.com/facebook/docusaurus/issues/11013 + * @param {import('update-notifier').UpdateInfo | undefined} update + */ +export function ignoreUpdate(update) { + const current = update?.current; + if (!current) { + return false; + } + const isCanaryRelease = + current.startsWith('0.0.0') || + current.includes('canary') || + Boolean(semver.prerelease(current)); + return isCanaryRelease; +} + /** * Notify user if `@docusaurus` packages are outdated * @@ -75,16 +93,6 @@ export default async function beforeCli() { logger.error(err); } - /** - * We don't want to display update message for canary releases. - * See https://github.com/facebook/docusaurus/issues/5378 - * @param {import('update-notifier').UpdateInfo} update - */ - function ignoreUpdate(update) { - const isCanaryRelease = update?.current?.startsWith('0.0.0'); - return isCanaryRelease; - } - if ( notifier.config && notifier.update && From cb093f4544c0127e66ab63c58858418d9de48b07 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Wed, 21 Oct 2026 14:20:30 +0530 Subject: [PATCH 2/4] test(core): add unit tests for ignoreUpdate in beforeCli --- .../bin/__tests__/beforeCli.test.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 packages/docusaurus/bin/__tests__/beforeCli.test.ts diff --git a/packages/docusaurus/bin/__tests__/beforeCli.test.ts b/packages/docusaurus/bin/__tests__/beforeCli.test.ts new file mode 100644 index 000000000000..b2b63aaa109c --- /dev/null +++ b/packages/docusaurus/bin/__tests__/beforeCli.test.ts @@ -0,0 +1,85 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {describe, expect, it} from 'vitest'; +import type {UpdateInfo} from 'update-notifier'; +import {ignoreUpdate} from '../beforeCli.mjs'; + +describe('beforeCli ignoreUpdate', () => { + it('returns false when update is undefined or current is missing', () => { + expect(ignoreUpdate(undefined)).toBe(false); + expect(ignoreUpdate({} as UpdateInfo)).toBe(false); + }); + + it('ignores legacy 0.0.0 canary releases', () => { + expect( + ignoreUpdate({ + current: '0.0.0-1234', + latest: '3.7.0', + name: '@docusaurus/core', + type: 'major', + }), + ).toBe(true); + }); + + it('ignores versions with canary suffix (closes #11013)', () => { + expect( + ignoreUpdate({ + current: '3.7.0-canary-6264', + latest: '3.7.0', + name: '@docusaurus/core', + type: 'patch', + }), + ).toBe(true); + expect( + ignoreUpdate({ + current: '3.8.0-canary-7123+7b6826b', + latest: '3.8.0', + name: '@docusaurus/core', + type: 'patch', + }), + ).toBe(true); + }); + + it('ignores prerelease versions', () => { + expect( + ignoreUpdate({ + current: '4.0.0-alpha.0', + latest: '4.0.0', + name: '@docusaurus/core', + type: 'major', + }), + ).toBe(true); + expect( + ignoreUpdate({ + current: '4.0.0-beta.1', + latest: '4.0.0', + name: '@docusaurus/core', + type: 'major', + }), + ).toBe(true); + }); + + it('does not ignore regular stable versions', () => { + expect( + ignoreUpdate({ + current: '3.6.0', + latest: '3.7.0', + name: '@docusaurus/core', + type: 'minor', + }), + ).toBe(false); + expect( + ignoreUpdate({ + current: '3.7.0', + latest: '3.7.1', + name: '@docusaurus/core', + type: 'patch', + }), + ).toBe(false); + }); +}); \ No newline at end of file From 8d44563f8c0fbb7782bcc90b7acc385a8400effb Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Fri, 30 Oct 2026 14:22:48 +0530 Subject: [PATCH 3/4] fix(core): preserve node version check when ignoring updates When ignoreUpdate(notifier.update) returns true (e.g. for Canary or prerelease builds), wrap the update notification display instead of returning early from beforeCli(). This ensures that the update notification is suppressed as intended while preserving the minimum Node.js version verification for all installations. Signed-off-by: Hardik Kaurani --- .../bin/__tests__/beforeCli.test.ts | 193 +++++++++++++++++- packages/docusaurus/bin/beforeCli.mjs | 122 ++++++----- 2 files changed, 250 insertions(+), 65 deletions(-) diff --git a/packages/docusaurus/bin/__tests__/beforeCli.test.ts b/packages/docusaurus/bin/__tests__/beforeCli.test.ts index b2b63aaa109c..3c65ea15f800 100644 --- a/packages/docusaurus/bin/__tests__/beforeCli.test.ts +++ b/packages/docusaurus/bin/__tests__/beforeCli.test.ts @@ -5,9 +5,26 @@ * LICENSE file in the root directory of this source tree. */ -import {describe, expect, it} from 'vitest'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {logger} from '@docusaurus/logger'; +import beforeCli, {ignoreUpdate} from '../beforeCli.mjs'; import type {UpdateInfo} from 'update-notifier'; -import {ignoreUpdate} from '../beforeCli.mjs'; + +let mockNotifierUpdate: UpdateInfo | undefined; +const mockNotifierConfig = { + get: vi.fn(), + set: vi.fn(), +}; + +vi.mock('update-notifier', () => ({ + default: vi.fn(() => ({ + config: mockNotifierConfig, + get update() { + return mockNotifierUpdate; + }, + check: vi.fn(), + })), +})); describe('beforeCli ignoreUpdate', () => { it('returns false when update is undefined or current is missing', () => { @@ -82,4 +99,174 @@ describe('beforeCli ignoreUpdate', () => { }), ).toBe(false); }); -}); \ No newline at end of file +}); + +describe('beforeCli control flow', () => { + const originalNodeVersion = process.version; + + beforeEach(() => { + vi.clearAllMocks(); + mockNotifierUpdate = undefined; + Object.defineProperty(process, 'version', { + value: 'v26.0.0', + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process, 'version', { + value: originalNodeVersion, + configurable: true, + }); + }); + + it('suppresses update notification for canary installations while passing Node.js check', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown as ( + code?: string | number | null | undefined, + ) => never); + + mockNotifierUpdate = { + current: '3.7.0-canary-6264', + latest: '3.7.0', + name: '@docusaurus/core', + type: 'patch', + }; + + await beforeCli(); + + expect(mockNotifierConfig.set).toHaveBeenCalledWith( + 'update', + mockNotifierUpdate, + ); + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('displays update notification for stable outdated installations while passing Node.js check', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown as ( + code?: string | number | null | undefined, + ) => never); + + mockNotifierUpdate = { + current: '3.6.0', + latest: '3.7.0', + name: '@docusaurus/core', + type: 'minor', + }; + + await beforeCli(); + + expect(mockNotifierConfig.set).toHaveBeenCalledWith( + 'update', + mockNotifierUpdate, + ); + expect(consoleLogSpy).toHaveBeenCalled(); + expect(consoleLogSpy.mock.calls[0]?.[0]).toContain('Update available'); + expect(consoleLogSpy.mock.calls[0]?.[0]).toContain('3.6.0'); + expect(consoleLogSpy.mock.calls[0]?.[0]).toContain('3.7.0'); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('does not bypass minimum Node.js version validation when canary update is suppressed', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const loggerErrorSpy = vi + .spyOn(logger, 'error') + .mockImplementation(() => {}); + const loggerInfoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {}); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown as ( + code?: string | number | null | undefined, + ) => never); + + mockNotifierUpdate = { + current: '3.7.0-canary-6264', + latest: '3.7.0', + name: '@docusaurus/core', + type: 'patch', + }; + + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + configurable: true, + }); + + await beforeCli(); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Minimum Node.js version not met :(', + ); + expect(loggerInfoSpy).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('does not bypass minimum Node.js version validation when prerelease update is suppressed', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const loggerErrorSpy = vi + .spyOn(logger, 'error') + .mockImplementation(() => {}); + const loggerInfoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {}); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown as ( + code?: string | number | null | undefined, + ) => never); + + mockNotifierUpdate = { + current: '4.0.0-alpha.0', + latest: '4.0.0', + name: '@docusaurus/core', + type: 'major', + }; + + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + configurable: true, + }); + + await beforeCli(); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Minimum Node.js version not met :(', + ); + expect(loggerInfoSpy).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('triggers error and exit on unsupported Node.js versions even when no update is available', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const loggerErrorSpy = vi + .spyOn(logger, 'error') + .mockImplementation(() => {}); + const loggerInfoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {}); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => {}) as unknown as ( + code?: string | number | null | undefined, + ) => never); + + mockNotifierUpdate = undefined; + + Object.defineProperty(process, 'version', { + value: 'v18.0.0', + configurable: true, + }); + + await beforeCli(); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Minimum Node.js version not met :(', + ); + expect(loggerInfoSpy).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/docusaurus/bin/beforeCli.mjs b/packages/docusaurus/bin/beforeCli.mjs index 7256318eeaa0..e4a66aa2e4eb 100644 --- a/packages/docusaurus/bin/beforeCli.mjs +++ b/packages/docusaurus/bin/beforeCli.mjs @@ -103,74 +103,72 @@ export default async function beforeCli() { // See https://github.com/yeoman/update-notifier/issues/209 notifier.config.set('update', notifier.update); - if (ignoreUpdate(notifier.update)) { - return; - } - - const siteDocusaurusPackagesForUpdate = Object.keys({ - ...sitePkg.dependencies, - ...sitePkg.devDependencies, - }) - .filter((p) => p.startsWith('@docusaurus')) - .map((p) => p.concat('@latest')) - .join(' '); + if (!ignoreUpdate(notifier.update)) { + const siteDocusaurusPackagesForUpdate = Object.keys({ + ...sitePkg.dependencies, + ...sitePkg.devDependencies, + }) + .filter((p) => p.startsWith('@docusaurus')) + .map((p) => p.concat('@latest')) + .join(' '); + + const getYarnVersion = async () => { + if (!(await fs.pathExists(path.resolve('yarn.lock')))) { + return undefined; + } + + const yarnVersionResult = await execa`yarn --version`; + const majorVersion = parseInt( + yarnVersionResult.stdout?.trim().split('.')[0] ?? '', + 10, + ); + if (!Number.isNaN(majorVersion)) { + return majorVersion; + } - const getYarnVersion = async () => { - if (!(await fs.pathExists(path.resolve('yarn.lock')))) { return undefined; - } - - const yarnVersionResult = await execa`yarn --version`; - const majorVersion = parseInt( - yarnVersionResult.stdout?.trim().split('.')[0] ?? '', - 10, - ); - if (!Number.isNaN(majorVersion)) { - return majorVersion; - } - - return undefined; - }; - - const getUpgradeCommand = async () => { - const yarnVersion = await getYarnVersion(); - if (!yarnVersion) { - return `npm i ${siteDocusaurusPackagesForUpdate}`; - } - return yarnVersion >= 2 - ? `yarn up ${siteDocusaurusPackagesForUpdate}` - : `yarn upgrade ${siteDocusaurusPackagesForUpdate}`; - }; - - /** @type {import('boxen').Options} */ - const boxenOptions = { - padding: 1, - margin: 1, - align: 'center', - borderColor: 'yellow', - borderStyle: { - topLeft: ' ', - topRight: ' ', - bottomLeft: ' ', - bottomRight: ' ', - top: '-', - bottom: '-', - left: ' ', - right: ' ', - }, - }; - - const docusaurusUpdateMessage = boxen( - `Update available ${logger.dim( - `${notifier.update.current}`, - )} → ${logger.green(`${notifier.update.latest}`)} + }; + + const getUpgradeCommand = async () => { + const yarnVersion = await getYarnVersion(); + if (!yarnVersion) { + return `npm i ${siteDocusaurusPackagesForUpdate}`; + } + return yarnVersion >= 2 + ? `yarn up ${siteDocusaurusPackagesForUpdate}` + : `yarn upgrade ${siteDocusaurusPackagesForUpdate}`; + }; + + /** @type {import('boxen').Options} */ + const boxenOptions = { + padding: 1, + margin: 1, + align: 'center', + borderColor: 'yellow', + borderStyle: { + topLeft: ' ', + topRight: ' ', + bottomLeft: ' ', + bottomRight: ' ', + top: '-', + bottom: '-', + left: ' ', + right: ' ', + }, + }; + + const docusaurusUpdateMessage = boxen( + `Update available ${logger.dim( + `${notifier.update.current}`, + )} → ${logger.green(`${notifier.update.latest}`)} To upgrade Docusaurus packages with the latest version, run the following command: ${logger.code(await getUpgradeCommand())}`, - boxenOptions, - ); + boxenOptions, + ); - console.log(docusaurusUpdateMessage); + console.log(docusaurusUpdateMessage); + } } // Notify user if node version needs to be updated From ce96b5bd60b9447345200f18141742ae1405e445 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Sat, 31 Oct 2026 01:02:27 +0530 Subject: [PATCH 4/4] test(core): hoist update-notifier test mocks in beforeCli tests Initialize mockNotifierConfig and mockState using vi.hoisted() so that shared mock objects are defined before Vitest hoists and executes the vi.mock('update-notifier') factory. Signed-off-by: Hardik Kaurani --- .../bin/__tests__/beforeCli.test.ts | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/docusaurus/bin/__tests__/beforeCli.test.ts b/packages/docusaurus/bin/__tests__/beforeCli.test.ts index 3c65ea15f800..9789d3f6a8aa 100644 --- a/packages/docusaurus/bin/__tests__/beforeCli.test.ts +++ b/packages/docusaurus/bin/__tests__/beforeCli.test.ts @@ -10,17 +10,21 @@ import {logger} from '@docusaurus/logger'; import beforeCli, {ignoreUpdate} from '../beforeCli.mjs'; import type {UpdateInfo} from 'update-notifier'; -let mockNotifierUpdate: UpdateInfo | undefined; -const mockNotifierConfig = { - get: vi.fn(), - set: vi.fn(), -}; +const {mockNotifierConfig, mockState} = vi.hoisted(() => ({ + mockNotifierConfig: { + get: vi.fn(), + set: vi.fn(), + }, + mockState: { + update: undefined as UpdateInfo | undefined, + }, +})); vi.mock('update-notifier', () => ({ default: vi.fn(() => ({ config: mockNotifierConfig, get update() { - return mockNotifierUpdate; + return mockState.update; }, check: vi.fn(), })), @@ -106,7 +110,7 @@ describe('beforeCli control flow', () => { beforeEach(() => { vi.clearAllMocks(); - mockNotifierUpdate = undefined; + mockState.update = undefined; Object.defineProperty(process, 'version', { value: 'v26.0.0', configurable: true, @@ -128,7 +132,7 @@ describe('beforeCli control flow', () => { code?: string | number | null | undefined, ) => never); - mockNotifierUpdate = { + mockState.update = { current: '3.7.0-canary-6264', latest: '3.7.0', name: '@docusaurus/core', @@ -139,7 +143,7 @@ describe('beforeCli control flow', () => { expect(mockNotifierConfig.set).toHaveBeenCalledWith( 'update', - mockNotifierUpdate, + mockState.update, ); expect(consoleLogSpy).not.toHaveBeenCalled(); expect(processExitSpy).not.toHaveBeenCalled(); @@ -153,7 +157,7 @@ describe('beforeCli control flow', () => { code?: string | number | null | undefined, ) => never); - mockNotifierUpdate = { + mockState.update = { current: '3.6.0', latest: '3.7.0', name: '@docusaurus/core', @@ -164,7 +168,7 @@ describe('beforeCli control flow', () => { expect(mockNotifierConfig.set).toHaveBeenCalledWith( 'update', - mockNotifierUpdate, + mockState.update, ); expect(consoleLogSpy).toHaveBeenCalled(); expect(consoleLogSpy.mock.calls[0]?.[0]).toContain('Update available'); @@ -185,7 +189,7 @@ describe('beforeCli control flow', () => { code?: string | number | null | undefined, ) => never); - mockNotifierUpdate = { + mockState.update = { current: '3.7.0-canary-6264', latest: '3.7.0', name: '@docusaurus/core', @@ -219,7 +223,7 @@ describe('beforeCli control flow', () => { code?: string | number | null | undefined, ) => never); - mockNotifierUpdate = { + mockState.update = { current: '4.0.0-alpha.0', latest: '4.0.0', name: '@docusaurus/core', @@ -253,7 +257,7 @@ describe('beforeCli control flow', () => { code?: string | number | null | undefined, ) => never); - mockNotifierUpdate = undefined; + mockState.update = undefined; Object.defineProperty(process, 'version', { value: 'v18.0.0',