diff --git a/runner/apps/authoring/package.json b/runner/apps/authoring/package.json index dbed4293f..8babd7b02 100644 --- a/runner/apps/authoring/package.json +++ b/runner/apps/authoring/package.json @@ -15,7 +15,7 @@ "@handsontable/demo-runtime": "workspace:*", "@sentry/react": "^10.68.0", "fflate": "^0.8.2", - "handsontable": "18.0.0", + "handsontable": "18.1.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/runner/apps/authoring/src/catalog.ts b/runner/apps/authoring/src/catalog.ts index f589f8792..04ab922c5 100644 --- a/runner/apps/authoring/src/catalog.ts +++ b/runner/apps/authoring/src/catalog.ts @@ -1,3 +1,4 @@ +import { stableBucketVersions } from "@handsontable/demo-runtime"; import type { Catalog, CatalogIndexEntry } from "@handsontable/demo-runtime"; import catalogJson from "../../../catalog.json"; import docsBucketsJson from "../../../docs-buckets.json"; @@ -22,9 +23,22 @@ export function getEntry(framework: string): CatalogIndexEntry { return e; } -/** Fallback version options (used until /api/versions responds). */ -export const VERSION_OPTIONS = ["18.0.0", "17.1.0", "17.0.1"]; -export const DEFAULT_VERSION = "18.0.0"; +/** + * Fallback version options — what the picker shows until /api/versions responds, + * and what it keeps showing when that fetch fails (`App.tsx`, versions-fetch). + * + * Derived from the committed starter buckets rather than hand-typed (DEV-2735). + * The literal list this replaces was set once by feature work and nothing ever + * bumped it: it still offered 18.0.0 as the newest choice months after 18.1.0 + * became npm `latest`, so a visitor whose /api/versions call had not landed yet + * could not pick the current release at all. `bucketVersions` is rewritten by + * the weekly bucket re-pin, so this now moves on its own — and every entry has + * a bucket behind it, which a hand-typed npm version does not guarantee. + */ +export const VERSION_OPTIONS = stableBucketVersions(catalog.bucketVersions); +/** What an unparameterised visit starts on, and the sentinel `App.tsx` reads as + * "the visitor has not chosen" before swapping in npm `latest`. */ +export const DEFAULT_VERSION = VERSION_OPTIONS[0]; /** Fetch real published versions from the API (npm-backed). */ export async function fetchVersions( diff --git a/runner/apps/authoring/src/theme/presetUrls.ts b/runner/apps/authoring/src/theme/presetUrls.ts index 1fe9f732f..ccb03c9a8 100644 --- a/runner/apps/authoring/src/theme/presetUrls.ts +++ b/runner/apps/authoring/src/theme/presetUrls.ts @@ -2,7 +2,7 @@ // // The panel is built against one pinned Handsontable (see `BUNDLED_VERSION`), // but a demo runs whatever the version picker says, and the presets are not -// frozen: `tokens/main` is 262 keys at 17.0.1 and 279 at 18.0.0. A resolved +// frozen: `tokens/main` is 262 keys at 17.0.1 and 285 at 18.1.0. A resolved // value shown from the wrong version is a confidently wrong number, which is // worse than the empty box it replaced. // diff --git a/runner/apps/authoring/src/theme/presets.ts b/runner/apps/authoring/src/theme/presets.ts index 345ae146d..3437b42d1 100644 --- a/runner/apps/authoring/src/theme/presets.ts +++ b/runner/apps/authoring/src/theme/presets.ts @@ -11,7 +11,7 @@ // These are this app's own copy — the fallback and the synchronous first render. // The panel resolves against the version the *demo* is pinned to, fetched at // runtime by `presetsFor.ts`, because the presets do move: `tokens/main` is 262 -// keys at 17.0.1 and 279 at 18.0.0. `colors/*`, `density` and `sizing` happen to +// keys at 17.0.1 and 285 at 18.1.0. `colors/*`, `density` and `sizing` happen to // be byte-identical across 17 → 18, so most of what the Foundation tab shows is // stable either way (DEV-2560). @@ -73,7 +73,7 @@ export function densitySizes(variant: DensityVariant): Record { * Named because the panel says so out loud: when a demo's own version cannot be * loaded, the fallback note has to name the version whose numbers are on screen. */ -export const BUNDLED_VERSION = "18.0.0"; +export const BUNDLED_VERSION = "18.1.0"; /** * The nine colours every theme is built from. diff --git a/runner/catalog.json b/runner/catalog.json index 01282a860..f531b3e15 100644 --- a/runner/catalog.json +++ b/runner/catalog.json @@ -7,6 +7,12 @@ "18", "next" ], + "bucketVersions": { + "15": "15.3.0", + "16": "16.2.0", + "17": "17.1.0", + "18": "18.1.0" + }, "tiers": { "1": "client-side (Sandpack, in-browser bundler)", "2": "SSR/meta-framework (Cloudflare Sandbox container)" diff --git a/runner/docs/style-panel.md b/runner/docs/style-panel.md index 8b916e6b2..7dc105143 100644 --- a/runner/docs/style-panel.md +++ b/runner/docs/style-panel.md @@ -150,7 +150,7 @@ The panel is built against one pinned Handsontable — `BUNDLED_VERSION` in `theme/presets.ts`, kept in step with the `apps/authoring` dependency by `pipeline/theme-presets-version.test.mjs` — but a demo runs whatever the version picker says, and the presets are not frozen: `tokens/main` is 262 keys at 17.0.1 -and 279 at 18.0.0, and two `rowHeader*` colours changed. Showing this app's +and 285 at 18.1.0, and two `rowHeader*` colours changed. Showing this app's numbers for someone else's version is a confidently wrong value, which is worse than the empty box it replaced. diff --git a/runner/e2e/version-fallback.spec.ts b/runner/e2e/version-fallback.spec.ts new file mode 100644 index 000000000..64551d037 --- /dev/null +++ b/runner/e2e/version-fallback.spec.ts @@ -0,0 +1,33 @@ +import { readFileSync } from "node:fs"; +import { expect, test } from "@playwright/test"; +import { stableBucketVersions } from "../packages/runtime/src/version.js"; + +// The picker's fallback has to be a *current* version when /api/versions cannot +// answer (DEV-2735). +// +// Before this the fallback was a literal in `catalog.ts` that nothing bumped: +// it still named 18.0.0 as the newest choice months after 18.1.0 became npm +// `latest`, so a visitor whose versions call had not landed — or had failed, +// which is the fail-open branch, not an edge case — was offered no current +// release at all. The unit tests cover the derivation and the pin; this covers +// the part neither can see, that the derived value actually reaches the picker. +// +// Read from `catalog.json` rather than written down here: a hardcoded version +// in a spec is the same drift this fix removes, one release from being wrong. +const { bucketVersions } = JSON.parse( + readFileSync(new URL("../catalog.json", import.meta.url), "utf8"), +) as { bucketVersions: Record }; +const NEWEST = stableBucketVersions(bucketVersions)[0]; + +test("the version fallback names the newest bucket when /api/versions is unreachable", async ({ + page, +}) => { + // `abort`, not a stubbed body: this asserts the fail-open path, which is what + // a visitor gets on a registry hiccup or an API deploy. + await page.route("**/api/versions", (route) => route.abort()); + + await page.goto("/"); + + // `.first()`: the preview bar and the status bar both print it. + await expect(page.getByText(`Handsontable ${NEWEST}`).first()).toBeVisible(); +}); diff --git a/runner/packages/runtime/src/index.ts b/runner/packages/runtime/src/index.ts index a37ccfe0b..8d50faf10 100644 --- a/runner/packages/runtime/src/index.ts +++ b/runner/packages/runtime/src/index.ts @@ -21,6 +21,7 @@ export { isHandsontablePackage, isNextPrereleaseVersion, pickLatestNextVersion, + stableBucketVersions, pkgPrNewDependencyUrl, parsePkgPrNewFromUrl, DEFAULT_MAX_MAJOR, diff --git a/runner/packages/runtime/src/types.ts b/runner/packages/runtime/src/types.ts index 6908a2b04..5de1e7212 100644 --- a/runner/packages/runtime/src/types.ts +++ b/runner/packages/runtime/src/types.ts @@ -64,6 +64,9 @@ export type CatalogIndexEntry = Omit< export interface Catalog { generatedFrom: string; buckets: string[]; + /** Bucket key → the Handsontable that bucket's artifacts are pinned to, i.e. + * each manifest's `hotVersion` (DEV-2735). Includes the `next` prerelease. */ + bucketVersions: Record; tiers: Record; examples: CatalogIndexEntry[]; } diff --git a/runner/packages/runtime/src/version.ts b/runner/packages/runtime/src/version.ts index 632f0fe23..1a9113d03 100644 --- a/runner/packages/runtime/src/version.ts +++ b/runner/packages/runtime/src/version.ts @@ -59,6 +59,48 @@ export function isNextPrereleaseVersion(value: string): boolean { /** Matches nightly (`0.0.0-next--`) and dotted (`19.0.0-next.1`) prereleases. */ const ANY_NEXT_VERSION_RE = /^\d+\.\d+\.\d+-next[.-]/; +/** An exact published release — no range, no dist-tag, no prerelease. */ +const EXACT_RELEASE_RE = /^\d+\.\d+\.\d+$/; + +/** + * The bucket versions worth offering as the authoring app's version-picker + * fallback: stable releases only, newest first (DEV-2735). + * + * Input is `catalog.json`'s `bucketVersions` — every starter bucket's pinned + * `hotVersion`, which the weekly re-pin keeps current. That makes it a better + * fallback than the hand-typed list it replaces on two counts: it cannot go + * stale on its own, and every entry has a bucket behind it, so switching to one + * cannot land on a version whose `/.json` does not exist. + * + * `catalog.json` already holds releases only — the nightly is left out at + * generation time, so it cannot make a committed file stale daily. The shape + * filter here is deliberate belt-and-braces: this is the last step before a + * version reaches the picker, and a range, a dist-tag or a prerelease arriving + * as a plain choice is worse than a short list. + * + * Throws rather than returning an empty list, and says so in the return type so + * a caller can take `[0]` as the default version without a non-null assertion. + * An empty fallback is an empty dropdown and a version-less first visit — + * silent, and the same failure class as the stale pin this exists to prevent. + */ +export function stableBucketVersions( + bucketVersions: Record, +): [string, ...string[]] { + const releases = + bucketVersions && typeof bucketVersions === "object" && !Array.isArray(bucketVersions) + ? Object.values(bucketVersions).filter( + (v) => typeof v === "string" && EXACT_RELEASE_RE.test(v), + ) + : []; + const [newest, ...rest] = [...new Set(releases)].sort(semver.rcompare); + if (newest === undefined) { + throw new Error( + `bucketVersions holds no exact release to fall back on: ${JSON.stringify(bucketVersions)}`, + ); + } + return [newest, ...rest]; +} + /** * Newest `-next` version by npm publish date, from a registry document's * `time` map — or null when none exists. The `next` dist-tag is deliberately diff --git a/runner/pipeline/import.mjs b/runner/pipeline/import.mjs index 4d29af2f5..9a92336b2 100644 --- a/runner/pipeline/import.mjs +++ b/runner/pipeline/import.mjs @@ -487,6 +487,27 @@ export function writeCatalogIndex({ outDir = OUT_DIR, indexPath = INDEX_PATH } = const catalog = { generatedFrom: "handsontable/examples examples/", buckets, + // Each release bucket's pinned Handsontable, lifted out of the manifests so + // the app can read it from the index it already bundles (DEV-2735). The + // authoring app's version-picker fallback is derived from this, which is + // what keeps it moving when the weekly re-pin bumps a bucket — before this + // the fallback was hand-typed and sat a release behind for months. + // + // Releases only: `next` is a nightly, so including it would make this file + // — and every open PR that touches it — go stale daily, to carry a value + // the picker filters back out anyway (it surfaces nightlies through its own + // `next` control). Filtered by shape rather than by the bucket key, so a + // future prerelease bucket cannot leak in either. + bucketVersions: Object.fromEntries( + buckets.flatMap((bucket) => { + const manifestPath = path.join(outDir, bucket, "manifest.json"); + const { hotVersion } = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + if (!hotVersion) { + throw new Error(`[import] bucket ${bucket}: manifest has no hotVersion`); + } + return /^\d+\.\d+\.\d+$/.test(hotVersion) ? [[bucket, hotVersion]] : []; + }), + ), tiers: { "1": "client-side (Sandpack, in-browser bundler)", "2": "SSR/meta-framework (Cloudflare Sandbox container)", diff --git a/runner/pipeline/import.test.mjs b/runner/pipeline/import.test.mjs index 8a8f1c71c..6fbd5f88a 100644 --- a/runner/pipeline/import.test.mjs +++ b/runner/pipeline/import.test.mjs @@ -187,6 +187,11 @@ test("the catalog index lists buckets and drops files from every entry", async ( const catalog = writeCatalogIndex({ outDir, indexPath }); assert.deepEqual(catalog.buckets, ["15", "next"]); + // Lifted from each release bucket's manifest — the authoring app's picker + // fallback is derived from this map (DEV-2735). The `next` bucket imported + // above is deliberately absent: it is a nightly, and a daily-moving value in + // a committed file goes stale under every open PR that touches it. + assert.deepEqual(catalog.bucketVersions, { 15: "15.3.0" }); assert.equal(catalog.examples.length, Object.keys(FRAMEWORKS).length); for (const entry of catalog.examples) { assert.equal("files" in entry, false, `${entry.framework}: index entry must not inline files`); diff --git a/runner/pipeline/starter-bucket-versions.test.mjs b/runner/pipeline/starter-bucket-versions.test.mjs new file mode 100644 index 000000000..f0defdac0 --- /dev/null +++ b/runner/pipeline/starter-bucket-versions.test.mjs @@ -0,0 +1,57 @@ +// `stableBucketVersions` — the authoring app's version-picker fallback, derived +// from the committed bucket state instead of hand-typed (DEV-2735). +// +// The fallback is what the picker shows until `/api/versions` answers, and what +// it keeps showing when that fetch fails (App.tsx:1666). Before this it was a +// literal that nothing updated: it still offered 18.0.0/17.1.0/17.0.1 months +// after 18.1.0 shipped. Deriving it from `catalog.json`'s `bucketVersions` ties +// it to the weekly bucket re-pin, so a release moves it without a human. +// +// Lives in the runtime package rather than `apps/authoring/src/catalog.ts` +// because that module uses bare, attribute-less JSON imports and is Vite-only +// by design — node cannot load it, so the logic would be untestable there. +import test from "node:test"; +import assert from "node:assert/strict"; +import { stableBucketVersions } from "../packages/runtime/dist/version.js"; + +test("drops the next prerelease and orders newest first", () => { + assert.deepEqual( + stableBucketVersions({ + 15: "15.3.0", + 16: "16.2.0", + 17: "17.1.0", + 18: "18.1.0", + next: "0.0.0-next-962a9cf-20260902", + }), + ["18.1.0", "17.1.0", "16.2.0", "15.3.0"], + ); +}); + +test("orders numerically, not lexically", () => { + // "9.0.0" > "10.0.0" as strings, and "18.9.0" > "18.10.0" — a plain sort() + // would put the older release first and make it the default version. + assert.deepEqual( + stableBucketVersions({ 9: "9.0.0", 10: "10.0.0", 18: "18.9.0", 19: "18.10.0" }), + ["18.10.0", "18.9.0", "10.0.0", "9.0.0"], + ); +}); + +test("ignores anything that is not an exact release", () => { + assert.deepEqual( + stableBucketVersions({ 17: "17.1.0", a: "^18.0.0", b: "latest", c: "19.0.0-next.1", d: "" }), + ["17.1.0"], + ); +}); + +// Absent-and-silent is the same failure class this fix exists to remove: an +// empty VERSION_OPTIONS is an empty dropdown and a version-less first visit, +// which nothing would report. A throw at module load is visible. +test("refuses a map with nothing usable in it", () => { + for (const bad of [undefined, null, {}, [], "18.1.0", { next: "0.0.0-next-abc1234-20260101" }]) { + assert.throws( + () => stableBucketVersions(bad), + /bucketVersions/, + `${JSON.stringify(bad) ?? "undefined"} must not yield an empty fallback`, + ); + } +}); diff --git a/runner/pipeline/starter-catalog-smoke.test.mjs b/runner/pipeline/starter-catalog-smoke.test.mjs index 7f937f008..7bfccc0b2 100644 --- a/runner/pipeline/starter-catalog-smoke.test.mjs +++ b/runner/pipeline/starter-catalog-smoke.test.mjs @@ -100,8 +100,23 @@ test("every starter artifact is runnable and pinned to the bucket hotVersion", ( test("catalog.json is an index: buckets match dirs, entries carry no files", () => { const catalog = JSON.parse(fs.readFileSync(path.join(RUNNER_DIR, "catalog.json"), "utf8")); - const dirs = loadBucketManifests().map((b) => b.bucket).sort(); + const buckets = loadBucketManifests(); + const dirs = buckets.map((b) => b.bucket).sort(); assert.deepEqual([...catalog.buckets].sort(), dirs, "index buckets match bucket directories"); + // The authoring app reads its version-picker fallback out of this map + // (DEV-2735), and `theme-presets-version.test.mjs` checks the handsontable + // pin against it — so a hand-edited catalog.json must not be able to claim a + // version no bucket was actually built at. Release buckets only: the `next` + // nightly is left out of the index on purpose. + assert.deepEqual( + catalog.bucketVersions, + Object.fromEntries( + buckets + .filter((b) => /^\d+\.\d+\.\d+$/.test(b.manifest.hotVersion)) + .map((b) => [b.bucket, b.manifest.hotVersion]), + ), + "index bucketVersions match each release manifest's hotVersion", + ); assert.deepEqual( catalog.examples.map((e) => e.framework).sort(), Object.keys(FRAMEWORKS).sort(), diff --git a/runner/pipeline/theme-presets-version.test.mjs b/runner/pipeline/theme-presets-version.test.mjs index b2a7fa9a0..b130c3476 100644 --- a/runner/pipeline/theme-presets-version.test.mjs +++ b/runner/pipeline/theme-presets-version.test.mjs @@ -39,3 +39,37 @@ test("the pin is exact, so the constant can be exact", () => { "a range would make the bundled numbers unnameable", ); }); + +// The pin itself has to track a *release*, not just its own constant. The two +// assertions above only compare the pin and BUNDLED_VERSION to each other, so +// they drift together and stay "consistent" while both go stale — which is +// exactly what happened: both sat at 18.0.0 for months after 18.1.0 became npm +// `latest` (DEV-2735). The starter buckets do not have that problem, because +// `.github/workflows/import-starters.yml` re-resolves each bucket's hotVersion +// from npm every Monday and opens a PR with the regenerated `catalog.json`. +// +// Checked against that committed state rather than the live registry: no +// pipeline test touches the network (eleven of them stub `globalThis.fetch` to +// keep it that way), and `docs/TESTING.md` rules out an env gate to hide one. +// +// Deliberately per-major, not "must equal the newest bucket". When 19 ships, +// bucket 19 appears and a highest-bucket rule would demand the app move major +// — a much larger job (THEME_API_MIN_MAJOR, preset shape, wrapper peers) that +// must not be forced by a red suite. Within its own major this still catches +// the reported drift. Do not "tighten" it. +test("the pin tracks its own major's starter bucket", () => { + const pin = JSON.parse(read("apps/authoring/package.json")).dependencies.handsontable; + const { bucketVersions } = JSON.parse(read("catalog.json")); + const major = pin.split(".")[0]; + + assert.ok( + bucketVersions?.[major], + `catalog.json has no bucket ${major}; run \`node pipeline/import.mjs --index\``, + ); + assert.equal( + pin, + bucketVersions[major], + `the handsontable pin (${pin}) trails bucket ${major} (${bucketVersions[major]}) — ` + + "bump apps/authoring/package.json, BUNDLED_VERSION and the lockfile together", + ); +}); diff --git a/runner/pnpm-lock.yaml b/runner/pnpm-lock.yaml index d262c94e4..3bfbfa9af 100644 --- a/runner/pnpm-lock.yaml +++ b/runner/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: specifier: ^0.8.2 version: 0.8.3 handsontable: - specifier: 18.0.0 - version: 18.0.0 + specifier: 18.1.0 + version: 18.1.0 react: specifier: ^19.0.0 version: 19.2.7 @@ -1552,8 +1552,8 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} - handsontable@18.0.0: - resolution: {integrity: sha512-U4H6gDrwGTfjahSyoGHhFHZvOZWB7ReQ3iBvJUIcZPdqdppdZ+FSYNovw+3MH1guJfGgRIhoLYnbSm8Gd6Ft8w==} + handsontable@18.1.0: + resolution: {integrity: sha512-23S/Jny67NRevaZgTXSAonhFW28du3J6DJm/TDN35Gj5hpztUxlRMEG0SugEEf0lQxdDhLRmz81Ikkt7xvuNRA==} hono@4.12.28: resolution: {integrity: sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==} @@ -3123,7 +3123,7 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 - handsontable@18.0.0: {} + handsontable@18.1.0: {} hono@4.12.28: {}