Commit 9d8bc3e9d8 for handsontable.com

commit 9d8bc3e9d8eaa85283e577f52ed32396f9c110a3
Author: Artur Mędrygał <artur.medrygal@handsontable.com>
Date:   Fri May 15 10:47:00 2026 +0200

    DEV-1678: Fix version tag when building release candidate documentation (#12584)

diff --git a/.github/workflows/docs-staging.yml b/.github/workflows/docs-staging.yml
index a378b16273..b5f575871f 100644
--- a/.github/workflows/docs-staging.yml
+++ b/.github/workflows/docs-staging.yml
@@ -58,7 +58,7 @@ jobs:
             if (isReleaseBranch) {
               console.log(`Release branch detected: ${ref}`);
               console.log(`Target version: ${targetVersion}`);
-              console.log('Will build docs in production mode to mimic the final release.');
+              console.log('Will build docs in production mode (GTM/HotJar, no dev badge).');
             }

       - name: Find PR
@@ -155,23 +155,6 @@ jobs:
           pnpm install
           npm run all build -- --e examples visual-tests

-      # RC release branches: patch handsontable/package.json to the clean target
-      # version (e.g., 17.1.0-rc1 -> 17.1.0) so the docs build uses the correct
-      # version in CodeSandbox URLs, the version dropdown, and common.json.
-      - name: Patch version for RC release branch
-        if: ${{ steps.release-info.outputs.is_release == 'true' }}
-        working-directory: .
-        run: |
-          TARGET_VERSION="${{ steps.release-info.outputs.target_version }}"
-          echo "Patching handsontable/package.json version to ${TARGET_VERSION}"
-          node -e "
-            const fs = require('fs');
-            const pkg = JSON.parse(fs.readFileSync('handsontable/package.json', 'utf-8'));
-            pkg.version = '${TARGET_VERSION}';
-            fs.writeFileSync('handsontable/package.json', JSON.stringify(pkg, null, 2) + '\n');
-          "
-          echo "Version patched: $(node -p "require('./handsontable/package.json').version")"
-
       # RC release branches: measure module sizes (same as production workflow).
       - name: Update module size measurements
         if: ${{ steps.release-info.outputs.is_release == 'true' }}
@@ -184,8 +167,7 @@ jobs:
           npm run build
         env:
           # RC release branches build in production mode to mimic the final
-          # production deployment (GTM/HotJar injected, dev badge hidden,
-          # correct version in the version dropdown).
+          # production deployment (GTM/HotJar injected, dev badge hidden).
           BUILD_MODE: ${{ steps.release-info.outputs.is_release == 'true' && 'production' || '' }}

       - name: Build and deploy the preview to Netlify
@@ -233,5 +215,6 @@ jobs:
           echo "**Build mode:** production" >> $GITHUB_STEP_SUMMARY
           echo "**Preview URL:** ${{ env.NETLIFY_SITE_URL }}/docs" >> $GITHUB_STEP_SUMMARY
           echo "" >> $GITHUB_STEP_SUMMARY
-          echo "This staging build mimics the production docs deployment for version ${{ steps.release-info.outputs.target_version }}." >> $GITHUB_STEP_SUMMARY
+          echo "This staging build uses production docs settings (BUILD_MODE=production) for release branch \`${{ steps.release-info.outputs.target_version }}\`." >> $GITHUB_STEP_SUMMARY
+          echo "The published \`handsontable\` version from package.json (including any RC tag) is used in examples and CodeSandbox links." >> $GITHUB_STEP_SUMMARY
           echo "Use it to verify the docs before the final release." >> $GITHUB_STEP_SUMMARY
diff --git a/docs/.nvmrc b/docs/.nvmrc
index 209e3ef4b6..2bd5a0a98a 100644
--- a/docs/.nvmrc
+++ b/docs/.nvmrc
@@ -1 +1 @@
-20
+22
diff --git a/docs/package.json b/docs/package.json
index c192fb0cab..623746a890 100755
--- a/docs/package.json
+++ b/docs/package.json
@@ -13,7 +13,7 @@
     "docs:code-examples:generate-js": "node scripts/transpile-doc-example.mjs",
     "build": "npm run docs:api && astro build",
     "preview": "astro preview",
-    "docs:test:plugins": "node --test src/plugins/__tests__/*.test.mjs",
+    "docs:test:plugins": "node --test src/plugins/__tests__/*.test.mjs src/lib/__tests__/*.test.mjs",
     "docs:lint": "eslint --ext .js,.mjs,.ts,.astro src content",
     "docs:lint:fix": "eslint --ext .js,.mjs,.ts,.astro src content --fix",
     "docs:visual-test": "npx playwright test",
diff --git a/docs/src/components/PageTitle.astro b/docs/src/components/PageTitle.astro
index 060279c36f..bb93ae05de 100644
--- a/docs/src/components/PageTitle.astro
+++ b/docs/src/components/PageTitle.astro
@@ -18,6 +18,8 @@ import { createRequire } from 'module';
 import { dirname, join } from 'path';
 import { fileURLToPath } from 'url';

+import { shouldShowNewerVersionBanner } from '../lib/newer-version-banner.mjs';
+
 const __dirname = dirname(fileURLToPath(import.meta.url));
 const require   = createRequire(import.meta.url);

@@ -56,7 +58,7 @@ try {
   // Network unavailable — banner will be hidden (graceful degradation).
 }

-const showNewerBanner = currentMinor !== '' && latestVersion !== '' && currentMinor !== latestVersion;
+const showNewerBanner = shouldShowNewerVersionBanner(currentMinor, latestVersion);

 // ── Framework labels ──────────────────────────────────────────────────────────
 const FRAMEWORK_LABELS: Record<string, string> = {
diff --git a/docs/src/lib/__tests__/newer-version-banner.test.mjs b/docs/src/lib/__tests__/newer-version-banner.test.mjs
new file mode 100644
index 0000000000..6f2be9313c
--- /dev/null
+++ b/docs/src/lib/__tests__/newer-version-banner.test.mjs
@@ -0,0 +1,32 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { shouldShowNewerVersionBanner } from '../newer-version-banner.mjs';
+
+test('no banner when current minor is ahead of production latest (RC preview)', () => {
+  assert.equal(shouldShowNewerVersionBanner('17.2', '17.1'), false);
+});
+
+test('banner when current minor is behind production latest', () => {
+  assert.equal(shouldShowNewerVersionBanner('17.0', '17.1'), true);
+});
+
+test('no banner when minors match', () => {
+  assert.equal(shouldShowNewerVersionBanner('17.1', '17.1'), false);
+});
+
+test('no banner when current is empty', () => {
+  assert.equal(shouldShowNewerVersionBanner('', '17.1'), false);
+});
+
+test('no banner when latest is empty', () => {
+  assert.equal(shouldShowNewerVersionBanner('17.1', ''), false);
+});
+
+test('no banner for next', () => {
+  assert.equal(shouldShowNewerVersionBanner('next', '17.1'), false);
+  assert.equal(shouldShowNewerVersionBanner('17.1', 'next'), false);
+});
+
+test('banner for several-minor gap', () => {
+  assert.equal(shouldShowNewerVersionBanner('16.0', '18.0'), true);
+});
diff --git a/docs/src/lib/newer-version-banner.mjs b/docs/src/lib/newer-version-banner.mjs
new file mode 100644
index 0000000000..2a9f5fc5b2
--- /dev/null
+++ b/docs/src/lib/newer-version-banner.mjs
@@ -0,0 +1,38 @@
+import semver from 'semver';
+
+/**
+ * Whether to show the "newer version available" banner on docs pages.
+ *
+ * The banner is shown only when production's latest **minor** line is strictly
+ * newer than this build's minor (user is viewing older docs). If this build
+ * is ahead of production (e.g. RC for the next minor), returns false.
+ *
+ * @param {string} currentMinor - Major.minor from local package.json (e.g. "17.1").
+ * @param {string} latestVersion - `latestVersion` from common.json (minor line).
+ * @returns {boolean}
+ */
+export function shouldShowNewerVersionBanner(currentMinor, latestVersion) {
+  if (!currentMinor || !latestVersion) {
+    return false;
+  }
+
+  const cur = String(currentMinor).trim();
+  const lat = String(latestVersion).trim();
+
+  if (!cur || !lat) {
+    return false;
+  }
+
+  if (cur.toLowerCase() === 'next' || lat.toLowerCase() === 'next') {
+    return false;
+  }
+
+  const currentCoerced = semver.coerce(`${cur}.0`);
+  const latestCoerced = semver.coerce(`${lat}.0`);
+
+  if (!currentCoerced || !latestCoerced) {
+    return false;
+  }
+
+  return semver.gt(latestCoerced, currentCoerced);
+}