Commit f14366d1c0 for handsontable.com
commit f14366d1c021ea5a9d3645b02a642f19aed08a02
Author: Krzysztof ‘Budzio’ Budnik <571316+budnix@users.noreply.github.com>
Date: Mon Aug 31 12:54:29 2026 +0200
DEV-2709: Keep the trial hard-stop block under the no-ui-warns flag (#13312)
* DEV-2709: Keep the trial block under no-ui-warns; drop the soft-stop bar
Two defects found while auditing every licenseKey variant against spec rev 6.
1. `no-ui-warns` switched off the trial hard-stop block. `mountBrandingSurface`
returned early on `!channels.ui` before reaching the `LOCK_CONTENT` routing,
so the flag removed the lock screen along with the badge. The specification
scopes the flag to UI *warnings* (S2.3) while the hard stop is the
*enforcement* of a licence that has stopped (S4.1). Since external/SaaS keys
carry the flag by default, every such trial key was unblockable. The lock is
now routed before the channel gate; only the badge sits behind it.
Only `trial_hard_stop` changes: `invalid` and `missing` describe keys whose
flags could not be read, so both already resolved to open channels.
2. `trial_soft_stop` rendered the bottom bar AND the auto-opening popover with
the same two sentences. One state speaks through one surface, so the bar is
withdrawn and the popover keeps the message. `entitlementDomMessages` now
holds `release_expired` alone - the only state whose message has no other
surface.
Verified in Chromium against the previously recorded audit run: the two states
above change and nothing else does.
Claude-Session: https://claude.ai/code/session_01Hz2fEDbbqkQRq5jRkJc7Nx
* DEV-2709: Add changelog entry for PR #13312
Claude-Session: https://claude.ai/code/session_01Hz2fEDbbqkQRq5jRkJc7Nx
* DEV-2709: Keep the soft-stop bar; scope the PR to the no-ui-warns block
Reverts the second fix after a side-by-side comparison of both builds. The
soft-stopped trial keeps its bottom bar alongside the popover.
The bar looked like pure duplication - the popover auto-opens with the same two
sentences - but it is carrying two things the popover cannot:
- It is the only license surface with a focusable link and a registered focus
scope (`licenseNotification.ts`). The popover is pointer-only by design:
`tabIndex = -1` on the badge, link and close button, no focus scope, no
`aria-live`.
- It is the only surface that survives dismissal. On a grid with no corner
cell, `is-cornerless` kills the hover path, and every CSS rule that reopens
the popover needs either that hover or the `is-open` class the X removes -
so once dismissed the message is gone for the instance's life. Reproduced in
a browser on both builds.
Removing it also broke `license-branding.spec.ts:93`, which asserts the bar.
That spec passes again.
The rationale is recorded next to `entitlementDomMessages` and in AGENTS.md so
the removal is not attempted a third time.
What remains is the first fix alone: `no-ui-warns` no longer switches off the
trial hard-stop block.
Also from review of that fix:
- Adds real-browser coverage for it. The unit test mocks `_getLicenseState`,
so it cannot see a regression in flag resolution; the new Playwright case
drives a genuine trial + `no-ui-warns` key end to end through the payload
read, `resolveChannels` and the branding routing.
- Drops two assertions from the new unit test that could not fail -
`mountBrandingSurface` returns after mounting the lock, so it never reaches
the badge path - and records that only `trial_hard_stop` is reachable with a
closed UI channel in production.
Verified: 4068 unit tests, 168 Playwright license tests across the theme x
bundle matrix, eslint (0 errors), test:types.
Claude-Session: https://claude.ai/code/session_01Hz2fEDbbqkQRq5jRkJc7Nx
* DEV-2709: Narrow the changelog entry to the shipped fix
The soft-stop bar removal was reverted, so the entry no longer mentions it.
Claude-Session: https://claude.ai/code/session_01Hz2fEDbbqkQRq5jRkJc7Nx
* DEV-2709: Address review - definition-site docs, real coverage, wording
Follow-up to the second review pass. No behavior change.
- Corrects the flag's contract at its DEFINITION sites, which still said
`no-ui-warns` means "no badge, no bar, no lock screen" (`constants.ts`) and
"nothing is rendered on top of the grid" (`types.ts`). Both were flatly
false after this PR, and they sit exactly where a maintainer would look
before "fixing the drift" by reverting the routing order.
- Records the split as a PRODUCT decision rather than attributing it to the
specification. S4.1 heads its behavior column "Behaviour (unless `silent`)"
over a hard-stop row reading "Trial: block", which read literally puts the
block inside the silenced set; S2.3's `trial` row separates the two. The
spec still needs amending, and the comments now say so instead of claiming
it already agrees.
- Replaces the vacuous half of the new E2E test. At the hard stop the badge
and bar are absent for EVERY key - the lock branch returns before the badge
mounts, and `_rendersBlockingModal` withholds the bar - so asserting their
absence there passed for an unflagged key too. The badge/bar half now runs
at the SOFT stop, comparing `trial` against `trial-external`, which is the
instant where it fails if `resolveChannels` stops reading the flag.
- Corrects "made every external/SaaS key unblockable", repeated in four
places. Only external TRIALS were ever blockable: a lapsed subscription has
no `LOCK_CONTENT` entry and `release_expired` never blocks.
- Records that the soft-stop bar-plus-popover shape diverges from S4.1's
"Trial: + modal", which predates this PR and is still open - the new comment
was cementing a shape without noting the spec says something else.
- American English ("license", not "licence") in the three new comments.
Verified: 4068 unit tests, 174 Playwright license tests across the theme x
bundle matrix, eslint (0 errors), test:types.
Claude-Session: https://claude.ai/code/session_01Hz2fEDbbqkQRq5jRkJc7Nx
* DEV-2709: Fix the describe nesting in the license branding E2E spec
Adding the external-use group closed `a trial past its grace period` one test
too early, so `is unaffected by the app using the Dialog plugin` and `takes the
lock down with the instance` ended up inside it. Both drive the default trial
key, so they passed either way - the suite just filed them under the wrong
heading.
The external-use group now sits after the hard-stop group rather than in the
middle of it.
Verified: 174 Playwright license tests across the theme x bundle matrix.
Claude-Session: https://claude.ai/code/session_01Hz2fEDbbqkQRq5jRkJc7Nx
diff --git a/.changelogs/13312.json b/.changelogs/13312.json
new file mode 100644
index 0000000000..81989e120b
--- /dev/null
+++ b/.changelogs/13312.json
@@ -0,0 +1,8 @@
+{
+ "issuesOrigin": "private",
+ "title": "Fixed an expired trial license key not blocking the grid when the key carried the `no-ui-warns` flag.",
+ "type": "fixed",
+ "issueOrPR": 13312,
+ "breaking": false,
+ "framework": "none"
+}
diff --git a/handsontable/AGENTS.md b/handsontable/AGENTS.md
index 9156958dd4..35ad0a6eb7 100644
--- a/handsontable/AGENTS.md
+++ b/handsontable/AGENTS.md
@@ -85,7 +85,7 @@ Gotcha: Filters `conditionCollection` uses physical indexes, `getDataAtCol()` us
- **Adding a new hook**: (1) Add the callback signature to the `GridSettings` interface in `src/core/settings.ts` (the `Events` type is derived automatically from hook-shaped entries). (2) Register the hook name in the `REGISTERED_HOOKS` array in `src/core/hooks/constants.ts`. The `addHook<K extends keyof Events>` overload then provides full IDE autocomplete. Full reference: `handsontable/.ai/HOOKS.md`.
- **Plugin public types**: Export new interfaces/types from the plugin `.ts` source file, then re-export via the plugin's barrel `index.ts` using `export type { ... }`. Do NOT add hand-crafted `.d.ts` stubs.
- **Two builds to test**: `handsontable.js` (base, no HyperFormula) and `handsontable.full.js` (includes HyperFormula). Test both when changing build-time behavior.
-- **License key validation is inlined and layered**: an entitlement key (plain-English prose followed by a `[<payload><checksum>]` block) is read by `src/utils/entitlementLicenseKey/` (`detectFormat.ts` = which format a key is in → `extractKeyData.ts` = block reader → `classify.ts` = window evaluation + silencing flags → `grants.ts` = capability tokens) — a port of `src/entitlement-key/` from the private `handsontable/license-key` repo; never add that package as a dependency, and keep the reader byte-compatible with it (a key the canonical validator rejects must not read here). The obfuscated legacy 25-character block in `helpers/mixed.ts` is frozen — an entitlement key routes out BEFORE it via the trailing-block shape test, and legacy CONSOLE behavior (including exact message text) must never change. **One legacy DOM behavior did change, by product decision (DEV-2562):** a missing or invalid key no longer renders a bottom bar — it renders the blocking modal instead (see below). Nothing else about the legacy path moved. **The checksum covers the bracket block only**: the prose may be rewrapped, rewritten or dropped and a genuine key still reads, but the block itself must be intact (its alphabet has no whitespace, so an email-wrapped block is invalid — do NOT 'repair' it by stripping whitespace INSIDE the brackets, that would accept keys the generator rejects; whitespace AROUND the whole key is a different thing and IS trimmed, by `_trimKey` at the head of both `_injectProductInfo` and `_getLicenseState`, because an untrimmed key reads as `invalid` and `invalid` now blocks — trim in one of the two and they desync). **No contract type in the payload**: which of `usage_until` / `release_until` is present decides how the license is measured, and the `trial` flag decides only how it is worded — never branch on a tier, mode or plan name, none of which exist in the key. Exactly one of the two dates per product, a real bare `YYYY-MM-DD`, or the key is invalid; `notice`/`grace` arrive IN the key (no behavioral constants in the library). Date rules that the specification's J-fixtures pin: `usage_until` is inclusive (valid until the UTC midnight that FOLLOWS it), the notice window is `notice > 0 && daysRemaining <= notice` with days counted UTC-midnight to UTC-midnight (60 at the window's open, 0 on the last licensed day), the hard stop is expiry + 1 day + `grace`, and `release_until` is compared to the build release date as TEXT (no clock, airgap-safe, fails OPEN when the build date is unavailable). Reading is STRICT about shape and LENIENT about vocabulary: an unknown product, capability token, flag or extra field is kept and ignored, never an error, or every token added on the issuing side would break builds already in the field. A key that grants another product but not `handsontable` is not a Handsontable license — it messages as invalid, and its grants stay unrestricted. The grants API is uniform across key families: legacy/missing/invalid keys resolve to unrestricted grants, so future capability gating can never take a feature away from an existing customer. The branding UI is a MODULE CATALOG (`src/utils/licenseBranding/`: `index.ts` = state routing (read once at init), `content.ts` = copy, `badge.ts` = corner badge + popover, `lockScreen.ts` = the blocking lock screen) — a core util, NOT a plugin (plugins can be absent or disabled; license UI must not be). The corner badge + popover render for TRIAL states ONLY (`POPOVER_CONTENT` in `content.ts` holds exactly `trial_valid`, `trial_notice`, `trial_soft_stop`). Every other state — expired legacy, non-commercial, a running or lapsed subscription, a perpetual license (and the two blocking states, which show the modal instead) — shows NO badge; its console message and any bottom bar still come from `initLicenseNotification`/`_injectProductInfo` (the legacy console/bar messaging stays untouched, and `_classifyLegacyKey` in `mixed.ts` mirrors the frozen checks read-only). The two silencing flags are per product and resolved once into `descriptor.channels`: `no-console-warns` closes the console, `no-ui-warns` closes the WHOLE front-end surface (badge, bar and lock) — that is what keeps license copy away from the end users of a customer's SaaS app, so never render a license surface without checking `channels.ui`. The corner badge is click-through (`pointer-events: none`) so the corner header keeps its native select-all — hover is detected by delegation (`is-corner-hover` class), and the glyph itself is pure CSS - an `::after` inside the corner header cell, gated by BOTH `ht-license-badge-on` (on the root element) and `ht-license-badge-corner` (a class JS stamps on THIS grid's own corner-clone table, resolved via the Walkontable overlay); the glyph CSS keys off that marker, NEVER the structural `.ht_clone_top_inline_start_corner` class, which also matches a nested grid's corner clone and would paint a stray badge there. So it can never overflow or drift out of the corner; never rebuild it as a measured overlay (the overlay version broke visual tests with misaligned badges) - JS measurement exists only to anchor the popover. The popover is a PURELY VISUAL floating element: no focus scope, no shortcuts, badge/link/close all `tabindex="-1"`, visibility is pointer-only, close is mouse-click only (the info is duplicated in the console + bottom bar). The hard stop renders the CORE-OWNED lock screen (`lockScreen.ts`) — NEVER the Dialog plugin (a shared surface an app legitimately uses: any `show` would replace the lock, any hide would look like a dismissal, and `dialog: true` setups would never tear it down); the lock instead REUSES the confirm-dialog CSS by wearing its class names (`ht-dialog ht-dialog--confirm handsontable ...` — the stylesheet always ships in full, so styling is inherited without importing the plugin) and copies its width sizing (pin `style.width` to the table workspace width on `afterViewRender`, or the `.ht-dialog` box spans the whole root wrapper, not the grid). The lock DOES use the focus manager (modal scope) + shortcut manager (Tab trap only - the lock is non-dismissable, so there is no Escape/close). THREE states render the lock, and `LOCK_CONTENT` in `content.ts` IS that routing — adding a state to that table is what makes it block: `trial_hard_stop`, `invalid`, and `missing`. The last two are the specification's §4.5 shape, whose message text was answered by the product owner as 'the sentences the bottom bar used to carry', so those two states now show the modal INSTEAD of a bar (`_rendersBlockingModal`/`_BLOCKING_MODAL_STATES` in `mixed.ts` withhold the bar in both emitters; the console message is untouched, and the two locks point at support@ rather than sales@ because both are install faults, and carry the bar's documentation link as a real anchor inside the description). A hard-stopped subscription (`usage_hard_stop`) still blocks nothing and shows nothing: it repeats its soft-stop console error and that is all (18.1 never blocks a paying customer). **Consequence for tests: a grid built with no `licenseKey` is now BLOCKED** — the lock deselects the cell and takes the keyboard, so every test grid must declare a key (the Jasmine helper injects `non-commercial-and-evaluation` already; jsdom unit tests that call `new Handsontable` directly must set it themselves, or selection-dependent assertions fail for no visible reason). The license key is read ONCE at init (like the console message and the bottom bar) — `updateSettings({licenseKey})` does NOT re-brand; applying a new key needs a fresh instance. Expiry compares against the build's release date, read BARE as `process.env.HOT_RELEASE_DATE` (in `licenseNotification.ts` and `licenseBranding/index.ts`) — the bundler (rspack DefinePlugin for `dist/`, SWC `inlineEnvVars` for `tmp/`) replaces it with a string literal, so NEVER wrap it in a `typeof process` guard: the guard is not inlined, compiles to `false` in browser bundles, blanks the date, and silently disables expired-key detection (an 18.0.0 regression). The badge reads its corner clone through the Walkontable API (`getOverlayByName`), NEVER a CSS `querySelector` on the root subtree (a nested grid — handsontable cell type — has its own corner clone earlier in document order that a selector would wrongly match). `extractEntitlementKeyData` memoizes on the key string (it is read twice per init). User-facing sentences repeated across surfaces (bar, popover, lock) live once as constants in `mixed.ts` (e.g. `_LICENSE_EXPIRED_TITLE`; every license symbol there is underscore-prefixed, because `index.ts` copies each non-underscore export of that module onto the public `Handsontable.helper`) — edit the constant, not each surface; every message is transcribed from the license specification, so reword it there first. Dates print as bare `YYYY-MM-DD`, with the `(UTC)` marker on console messages for `usage_until` and none on `release_until` (no clock takes part) or on the end-user-facing UI. The state-keyed tables (console/DOM notifications in `mixed.ts`, badge/lock content in `content.ts`) are typed `Partial<Record<LicenseStateKey, …>>`, NOT `Record<string, …>` — a typoed or unknown state key is then a compile error instead of a silently dropped entry. Test fixtures: real generator-produced keys in `src/utils/entitlementLicenseKey/__tests__/fixtures.js` (one per worked example of the specification) plus a test-only key builder (`buildTestKey.js`) for the shapes the generator refuses to produce; it must never be imported from `src/`.
+- **License key validation is inlined and layered**: an entitlement key (plain-English prose followed by a `[<payload><checksum>]` block) is read by `src/utils/entitlementLicenseKey/` (`detectFormat.ts` = which format a key is in → `extractKeyData.ts` = block reader → `classify.ts` = window evaluation + silencing flags → `grants.ts` = capability tokens) — a port of `src/entitlement-key/` from the private `handsontable/license-key` repo; never add that package as a dependency, and keep the reader byte-compatible with it (a key the canonical validator rejects must not read here). The obfuscated legacy 25-character block in `helpers/mixed.ts` is frozen — an entitlement key routes out BEFORE it via the trailing-block shape test, and legacy CONSOLE behavior (including exact message text) must never change. **One legacy DOM behavior did change, by product decision (DEV-2562):** a missing or invalid key no longer renders a bottom bar — it renders the blocking modal instead (see below). Nothing else about the legacy path moved. **The checksum covers the bracket block only**: the prose may be rewrapped, rewritten or dropped and a genuine key still reads, but the block itself must be intact (its alphabet has no whitespace, so an email-wrapped block is invalid — do NOT 'repair' it by stripping whitespace INSIDE the brackets, that would accept keys the generator rejects; whitespace AROUND the whole key is a different thing and IS trimmed, by `_trimKey` at the head of both `_injectProductInfo` and `_getLicenseState`, because an untrimmed key reads as `invalid` and `invalid` now blocks — trim in one of the two and they desync). **No contract type in the payload**: which of `usage_until` / `release_until` is present decides how the license is measured, and the `trial` flag decides only how it is worded — never branch on a tier, mode or plan name, none of which exist in the key. Exactly one of the two dates per product, a real bare `YYYY-MM-DD`, or the key is invalid; `notice`/`grace` arrive IN the key (no behavioral constants in the library). Date rules that the specification's J-fixtures pin: `usage_until` is inclusive (valid until the UTC midnight that FOLLOWS it), the notice window is `notice > 0 && daysRemaining <= notice` with days counted UTC-midnight to UTC-midnight (60 at the window's open, 0 on the last licensed day), the hard stop is expiry + 1 day + `grace`, and `release_until` is compared to the build release date as TEXT (no clock, airgap-safe, fails OPEN when the build date is unavailable). Reading is STRICT about shape and LENIENT about vocabulary: an unknown product, capability token, flag or extra field is kept and ignored, never an error, or every token added on the issuing side would break builds already in the field. A key that grants another product but not `handsontable` is not a Handsontable license — it messages as invalid, and its grants stay unrestricted. The grants API is uniform across key families: legacy/missing/invalid keys resolve to unrestricted grants, so future capability gating can never take a feature away from an existing customer. The branding UI is a MODULE CATALOG (`src/utils/licenseBranding/`: `index.ts` = state routing (read once at init), `content.ts` = copy, `badge.ts` = corner badge + popover, `lockScreen.ts` = the blocking lock screen) — a core util, NOT a plugin (plugins can be absent or disabled; license UI must not be). The corner badge + popover render for TRIAL states ONLY (`POPOVER_CONTENT` in `content.ts` holds exactly `trial_valid`, `trial_notice`, `trial_soft_stop`). Every other state — expired legacy, non-commercial, a running or lapsed subscription, a perpetual license (and the two blocking states, which show the modal instead) — shows NO badge; its console message and any bottom bar still come from `initLicenseNotification`/`_injectProductInfo` (the legacy console/bar messaging stays untouched, and `_classifyLegacyKey` in `mixed.ts` mirrors the frozen checks read-only). The two silencing flags are per product and resolved once into `descriptor.channels`: `no-console-warns` closes the console, `no-ui-warns` closes every WARNING surface (badge, popover and bar) — that is what keeps license copy away from the end users of a customer's SaaS app, so never render a license *warning* without checking `channels.ui`. **It does NOT close the LOCK (DEV-2709).** The flag covers UI *warnings*; the hard stop is the *enforcement* of a license that has stopped — so `mountBrandingSurface` routes `LOCK_CONTENT` BEFORE the channel gate, and only the badge sits behind it. What this fixed is external/SaaS **trials**: they are the only externally-issued keys that can be blocked at all (a lapsed subscription `usage_hard_stop` has no `LOCK_CONTENT` entry and blocks nothing, and `release_expired` never blocks). Only `trial_hard_stop` changes in practice — `invalid` and `missing` describe keys whose flags could not be read, so both always resolve to open channels and reached the lock either way. **This is a product decision, not a reading the specification states:** §4.1 heads its behavior column "Behaviour (unless `silent`)" over a hard-stop row reading "Trial: block", which read literally puts the block inside the silenced set, while §2.3's `trial` row separates "Trial messages" from "hard-stop block". The spec still needs amending to record the split — do not revert this from §4.1 alone. The same caveat is repeated at `NO_UI_WARNS_FLAG` (`entitlementLicenseKey/constants.ts`) and on `LicenseChannels` (`types.ts`), because those are the definition sites a maintainer reads first. The corner badge is click-through (`pointer-events: none`) so the corner header keeps its native select-all — hover is detected by delegation (`is-corner-hover` class), and the glyph itself is pure CSS - an `::after` inside the corner header cell, gated by BOTH `ht-license-badge-on` (on the root element) and `ht-license-badge-corner` (a class JS stamps on THIS grid's own corner-clone table, resolved via the Walkontable overlay); the glyph CSS keys off that marker, NEVER the structural `.ht_clone_top_inline_start_corner` class, which also matches a nested grid's corner clone and would paint a stray badge there. So it can never overflow or drift out of the corner; never rebuild it as a measured overlay (the overlay version broke visual tests with misaligned badges) - JS measurement exists only to anchor the popover. The popover is a PURELY VISUAL floating element: no focus scope, no shortcuts, badge/link/close all `tabindex="-1"`, visibility is pointer-only, close is mouse-click only (the info is duplicated in the console + bottom bar). The hard stop renders the CORE-OWNED lock screen (`lockScreen.ts`) — NEVER the Dialog plugin (a shared surface an app legitimately uses: any `show` would replace the lock, any hide would look like a dismissal, and `dialog: true` setups would never tear it down); the lock instead REUSES the confirm-dialog CSS by wearing its class names (`ht-dialog ht-dialog--confirm handsontable ...` — the stylesheet always ships in full, so styling is inherited without importing the plugin) and copies its width sizing (pin `style.width` to the table workspace width on `afterViewRender`, or the `.ht-dialog` box spans the whole root wrapper, not the grid). The lock DOES use the focus manager (modal scope) + shortcut manager (Tab trap only - the lock is non-dismissable, so there is no Escape/close). THREE states render the lock, and `LOCK_CONTENT` in `content.ts` IS that routing — adding a state to that table is what makes it block: `trial_hard_stop`, `invalid`, and `missing`. The last two are the specification's §4.5 shape, whose message text was answered by the product owner as 'the sentences the bottom bar used to carry', so those two states now show the modal INSTEAD of a bar (`_rendersBlockingModal`/`_BLOCKING_MODAL_STATES` in `mixed.ts` withhold the bar in both emitters; the console message is untouched, and the two locks point at support@ rather than sales@ because both are install faults, and carry the bar's documentation link as a real anchor inside the description). A hard-stopped subscription (`usage_hard_stop`) still blocks nothing and shows nothing: it repeats its soft-stop console error and that is all (18.1 never blocks a paying customer). **The soft-stopped trial deliberately shows BOTH a bar and its auto-opening popover, carrying the same two sentences — do not "de-duplicate" it (tried and reverted under DEV-2709).** The bar is the only license surface with a focusable link and a registered focus scope (`licenseNotification.ts`), while the popover is pointer-only by design (`tabIndex = -1` throughout, no focus scope, no `aria-live`); and the bar is the only surface that survives dismissal — on a grid with no corner cell, `is-cornerless` kills the hover path and every CSS rule that reopens the popover, so once the X is clicked the message is gone for the instance's life. Removing the bar there needs the popover to become keyboard-reachable and re-openable first. **Consequence for tests: a grid built with no `licenseKey` is now BLOCKED** — the lock deselects the cell and takes the keyboard, so every test grid must declare a key (the Jasmine helper injects `non-commercial-and-evaluation` already; jsdom unit tests that call `new Handsontable` directly must set it themselves, or selection-dependent assertions fail for no visible reason). The license key is read ONCE at init (like the console message and the bottom bar) — `updateSettings({licenseKey})` does NOT re-brand; applying a new key needs a fresh instance. Expiry compares against the build's release date, read BARE as `process.env.HOT_RELEASE_DATE` (in `licenseNotification.ts` and `licenseBranding/index.ts`) — the bundler (rspack DefinePlugin for `dist/`, SWC `inlineEnvVars` for `tmp/`) replaces it with a string literal, so NEVER wrap it in a `typeof process` guard: the guard is not inlined, compiles to `false` in browser bundles, blanks the date, and silently disables expired-key detection (an 18.0.0 regression). The badge reads its corner clone through the Walkontable API (`getOverlayByName`), NEVER a CSS `querySelector` on the root subtree (a nested grid — handsontable cell type — has its own corner clone earlier in document order that a selector would wrongly match). `extractEntitlementKeyData` memoizes on the key string (it is read twice per init). User-facing sentences repeated across surfaces (bar, popover, lock) live once as constants in `mixed.ts` (e.g. `_LICENSE_EXPIRED_TITLE`; every license symbol there is underscore-prefixed, because `index.ts` copies each non-underscore export of that module onto the public `Handsontable.helper`) — edit the constant, not each surface; every message is transcribed from the license specification, so reword it there first. Dates print as bare `YYYY-MM-DD`, with the `(UTC)` marker on console messages for `usage_until` and none on `release_until` (no clock takes part) or on the end-user-facing UI. The state-keyed tables (console/DOM notifications in `mixed.ts`, badge/lock content in `content.ts`) are typed `Partial<Record<LicenseStateKey, …>>`, NOT `Record<string, …>` — a typoed or unknown state key is then a compile error instead of a silently dropped entry. Test fixtures: real generator-produced keys in `src/utils/entitlementLicenseKey/__tests__/fixtures.js` (one per worked example of the specification) plus a test-only key builder (`buildTestKey.js`) for the shapes the generator refuses to produce; it must never be imported from `src/`.
- **Validator corrections via `setDataAtCell`**: If a validator calls `setDataAtCell` to write a corrected value (e.g. `correctFormat`), the source string **must end with `'Validator'`** (e.g. `'myCustomValidator'`). Without this suffix, the correction is silently overwritten when the same batch contains columns with async validators (async autocomplete `source`). See `src/core.ts` `validateChanges()` and the `handsontable-validator-dev` skill.
- **Newer-than-TS-5.1 lib types in emitted `.d.ts`**: Published types must be consumable by TS 5.1 (Angular 16's max). If your code causes `tsc` to emit `ArrayIterator`, `WeakKey`, `IteratorObject`, or similar lib types added after TS 5.1, the `verify-emitted-types` CI job will fail. Two ways to fix: add an explicit annotation at the source (`IterableIterator<T>`, `WeakMap<object, any>`), or extend `scripts/downlevel-dts.mjs` with a new replacement row. The source file is still compiled by the modern dev TS — only the published `.d.ts` is downleveled.
- **`toLocaleLowerCase(locale)` is a performance trap**: an explicit locale arg forces the ICU path (~45× slower) and throws on invalid tags. Use `localeLowerCase(value, locale)` from `helpers/string`. Only Turkish/Azeri/Lithuanian actually tailor lowercasing; the helper detects that and otherwise uses the fast `toLowerCase()`.
diff --git a/handsontable/src/helpers/__tests__/entitlementLicenseInfo.unit.js b/handsontable/src/helpers/__tests__/entitlementLicenseInfo.unit.js
index f9737ec849..0545a5d944 100644
--- a/handsontable/src/helpers/__tests__/entitlementLicenseInfo.unit.js
+++ b/handsontable/src/helpers/__tests__/entitlementLicenseInfo.unit.js
@@ -93,6 +93,9 @@ describe('entitlement license notification (via _injectProductInfo)', () => {
'Your Handsontable trial license key expired on 2026-09-26 (UTC). ' +
'To continue using Handsontable, you need to purchase a license.'
);
+ // The badge popover auto-opens with these same two sentences, and the bar stays anyway: it is
+ // the only surface with a focusable link, and the only one that survives the popover being
+ // dismissed. See the note on `entitlementDomMessages` in `helpers/mixed.ts`.
expect(node).not.toBe(null);
expect(bar()).toBe(
'Your Handsontable license key has expired. To continue using Handsontable, you need to ' +
diff --git a/handsontable/src/helpers/mixed.ts b/handsontable/src/helpers/mixed.ts
index d6d84ca75e..4504248e00 100644
--- a/handsontable/src/helpers/mixed.ts
+++ b/handsontable/src/helpers/mixed.ts
@@ -274,6 +274,21 @@ const entitlementConsoleNotifications: Partial<Record<LicenseStateKey, Entitleme
* Core-owned lock screen instead of a bar (see `_BLOCKING_MODAL_STATES` below
* and `utils/licenseBranding/lockScreen.ts`), and a non-trial license never
* renders a bar - it is developer-facing only in 18.1.
+ *
+ * The soft-stopped trial DOES carry a bar even though its badge popover auto-opens with the same two
+ * sentences. That duplication is deliberate: the bar is the only license surface with a focusable
+ * link and a registered focus scope (`utils/licenseNotification.ts`), while the popover is
+ * pointer-only by design (`tabIndex = -1` throughout, no focus scope, no `aria-live`). The bar is
+ * also the only surface left once the popover is dismissed - on a grid with no corner cell, nothing
+ * can reopen the popover for the rest of the instance's life. Removing this entry was tried and
+ * reverted (DEV-2709) for exactly those two reasons; leave it in place until the popover can carry
+ * the message on its own.
+ *
+ * Note the shape here is NOT what the specification literally describes. S4.1's soft-stop row reads
+ * "Console error. Trial: + modal", and no modal renders for `trial_soft_stop` - `LOCK_CONTENT` has
+ * no entry for it. Either "modal" is meant loosely for the auto-opening popover, or the state is
+ * missing a surface. That question predates DEV-2709 and is still open; this comment records the
+ * divergence rather than settling it.
*/
const entitlementDomMessages: Partial<Record<LicenseStateKey, (params: EntitlementMessageParams) => string>> = {
trial_soft_stop: () => toSingleLine`
diff --git a/handsontable/src/utils/__tests__/licenseBranding.unit.js b/handsontable/src/utils/__tests__/licenseBranding.unit.js
index d43dc43f80..a92dd82d07 100644
--- a/handsontable/src/utils/__tests__/licenseBranding.unit.js
+++ b/handsontable/src/utils/__tests__/licenseBranding.unit.js
@@ -747,8 +747,9 @@ describe('licenseBranding', () => {
});
describe('the no-ui-warns flag', () => {
- // A key issued for external use must show its end users nothing, whatever state it is in.
- it.each(['trial_notice', 'trial_soft_stop', 'trial_hard_stop', 'invalid', 'missing'])(
+ // A key issued for external use must show its end users no license WARNING - that is what the
+ // flag is for.
+ it.each(['trial_valid', 'trial_notice', 'trial_soft_stop'])(
'should render nothing for the "%s" state when the UI channel is closed',
(state) => {
setLifecycle(state, { licensedUntil: '2026-09-26' }, { console: true, ui: false });
@@ -761,6 +762,29 @@ describe('licenseBranding', () => {
expect(hotInstance.focusScope.registerScope).not.toHaveBeenCalled();
}
);
+
+ // ...but it must NOT switch the block off. The flag covers UI warnings; the hard stop is the
+ // enforcement of a license that has stopped (product decision, DEV-2709). Since an external/SaaS
+ // key carries this flag by default, honoring it here made external TRIALS unblockable - the only
+ // externally-issued keys that can be blocked at all, since a lapsed subscription blocks nothing.
+ // This assertion fails if the `channels.ui` gate is ever moved back in front of the
+ // `LOCK_CONTENT` routing - which is the whole point of it.
+ //
+ // Only `trial_hard_stop` is reachable this way in production: `invalid` and `missing` describe
+ // keys whose flags could not be read, so `_getLicenseState` returns OPEN_CHANNELS for both and
+ // the combination below exists only because that function is mocked here. They are kept as a
+ // guard on the routing order, not as a claim about shipped states.
+ it.each(['trial_hard_stop', 'invalid', 'missing'])(
+ 'should still render the lock screen for the "%s" state when the UI channel is closed',
+ (state) => {
+ setLifecycle(state, { licensedUntil: '2026-09-26' }, { console: true, ui: false });
+ const hotInstance = createMockHotInstance();
+
+ initLicenseBranding(hotInstance);
+
+ expect(hotInstance.rootOverlaysElement.querySelector('.ht-license-lock')).not.toBe(null);
+ }
+ );
});
describe('init-only license key', () => {
diff --git a/handsontable/src/utils/entitlementLicenseKey/constants.ts b/handsontable/src/utils/entitlementLicenseKey/constants.ts
index b6ab237e6e..70a6dd38d1 100644
--- a/handsontable/src/utils/entitlementLicenseKey/constants.ts
+++ b/handsontable/src/utils/entitlementLicenseKey/constants.ts
@@ -59,9 +59,15 @@ export const TRIAL_FLAG = 'trial';
export const NO_CONSOLE_WARNS_FLAG = 'no-console-warns';
/**
- * Closes the UI channel: no badge, no bar, no lock screen. Both this flag and
- * the one above are the default for a key issued for external, end-user-facing
- * use.
+ * Closes the UI WARNING surfaces: no badge, no popover, no bottom bar. Both
+ * this flag and the one above are the default for a key issued for external,
+ * end-user-facing use.
+ *
+ * It does NOT close the hard-stop lock screen. The lock is enforcement rather
+ * than a warning, so `mountBrandingSurface` (`utils/licenseBranding/index.ts`)
+ * routes it BEFORE reading this channel - see the note there. Product decision
+ * under DEV-2709; the specification's S4.1 table header does not yet record the
+ * split, so do not "restore" the old behavior from the spec alone.
*
* @type {string}
*/
diff --git a/handsontable/src/utils/entitlementLicenseKey/types.ts b/handsontable/src/utils/entitlementLicenseKey/types.ts
index 2686c5d5c6..fb6121a32d 100644
--- a/handsontable/src/utils/entitlementLicenseKey/types.ts
+++ b/handsontable/src/utils/entitlementLicenseKey/types.ts
@@ -77,9 +77,15 @@ export interface LicenseLifecycle {
/**
* Which notification channels the license leaves open. A product entry may
- * carry `no-console-warns` (nothing reaches the console) and `no-ui-warns`
- * (nothing is rendered on top of the grid); both are the default for a key
- * issued for external, end-user-facing use.
+ * carry `no-console-warns` (nothing reaches the console) and `no-ui-warns` (no
+ * WARNING is rendered on top of the grid - no badge, no popover, no bottom
+ * bar); both are the default for a key issued for external, end-user-facing
+ * use.
+ *
+ * `ui` governs warnings only. The hard-stop lock screen is enforcement and is
+ * mounted regardless - see `NO_UI_WARNS_FLAG` in `./constants` and the routing
+ * note in `utils/licenseBranding/index.ts`. A new surface reading this field
+ * has to decide which of the two it is before honoring it.
*/
export interface LicenseChannels {
console: boolean;
diff --git a/handsontable/src/utils/licenseBranding/index.ts b/handsontable/src/utils/licenseBranding/index.ts
index ae0c06fdeb..32116155e6 100644
--- a/handsontable/src/utils/licenseBranding/index.ts
+++ b/handsontable/src/utils/licenseBranding/index.ts
@@ -19,8 +19,23 @@ import type { HotInstance } from '../../core/types';
* running subscription and a covered perpetual license render nothing here; their console message
* and any bottom bar come from `initLicenseNotification`.
*
- * A key carrying `no-ui-warns` renders nothing at all - the flag closes this whole surface, which is
- * what keeps a licensed SaaS application from showing license copy to its own end users.
+ * A key carrying `no-ui-warns` renders no WARNING surface - no badge and no popover - which is what
+ * keeps a licensed SaaS application from showing license copy to its own end users. It does NOT
+ * suppress the lock screen: the flag covers UI *warnings* (S2.3), while the hard stop is the
+ * *enforcement* of a license that has stopped. The lock is therefore routed BEFORE the channel gate
+ * below.
+ *
+ * Scope of the change: only `trial_hard_stop` is affected. A lapsed SUBSCRIPTION (`usage_hard_stop`)
+ * has no `LOCK_CONTENT` entry and blocks nothing by product decision, and `release_expired` never
+ * blocks either - so what this fixed is external/SaaS TRIALS, which were the only externally-issued
+ * keys that could ever have been blocked. `invalid` and `missing` describe keys whose flags could
+ * not be read, and both resolve to open channels, so they reached the lock either way.
+ *
+ * This is a product decision recorded under DEV-2709, NOT a reading the specification states. S4.1
+ * heads its behavior column "Behaviour (unless `silent`)" over a hard-stop row that says
+ * "Trial: block", which read literally puts the block inside the silenced set; S2.3's `trial` row
+ * ("Trial messages + hard-stop block") separates the two. The split below is the answer to that
+ * ambiguity and the specification still needs amending to match - do not revert this from S4.1 alone.
*
* @param {HotInstance} hotInstance The root Handsontable instance.
* @param {ReturnType<typeof _getLicenseState>} descriptor The resolved license state descriptor.
@@ -31,11 +46,6 @@ function mountBrandingSurface(
descriptor: ReturnType<typeof _getLicenseState>,
): void {
const { lifecycle, channels } = descriptor;
-
- if (!channels.ui) {
- return;
- }
-
const buildLockContent = LOCK_CONTENT[lifecycle.state];
if (buildLockContent) {
@@ -44,6 +54,10 @@ function mountBrandingSurface(
return;
}
+ if (!channels.ui) {
+ return;
+ }
+
mountLicenseBadge(hotInstance, lifecycle);
}
diff --git a/tests/e2e/license-branding.spec.ts b/tests/e2e/license-branding.spec.ts
index 3d1f644c73..93cb4e3e1e 100644
--- a/tests/e2e/license-branding.spec.ts
+++ b/tests/e2e/license-branding.spec.ts
@@ -201,6 +201,17 @@ test.describe('entitlement license key branding', () => {
await expect(license.lock).toBeVisible();
});
+ // DEV-2709. `no-ui-warns` suppresses license WARNINGS; the hard stop is enforcement, not a
+ // warning, so the lock must survive the flag. This is driven through a real key so the whole
+ // chain is covered - payload read, `resolveChannels`, then the branding routing. The unit test
+ // for the same rule mocks `_getLicenseState`, so it cannot see a regression in flag resolution.
+ test('still blocks when the key carries no-ui-warns', async () => {
+ await license.goto(INSTANT.trialHardStop, { key: 'trial-external' });
+
+ await expect(license.lock).toBeVisible();
+ await expect(license.lock).toContainText('Your Handsontable trial license key expired on 2026-09-26.');
+ });
+
test('is unaffected by the app using the Dialog plugin for its own dialogs', async () => {
await license.goto(INSTANT.trialHardStop, { variant: 'dialog' });
@@ -225,6 +236,25 @@ test.describe('entitlement license key branding', () => {
});
});
+ test.describe('a trial issued for external use', () => {
+ // The other half of the same flag, asserted where it actually bites. At the HARD stop the badge
+ // and bar are absent for every key - the lock branch returns before the badge is mounted, and
+ // `_rendersBlockingModal` withholds the bar - so asserting their absence there proves nothing.
+ // The soft stop is the instant where an unflagged trial shows both, so this is the comparison
+ // that fails if `resolveChannels` ever stops reading `no-ui-warns`.
+ test('shows the badge and the bar without the flag, and neither with it', async () => {
+ await license.goto(INSTANT.trialSoftStop, { key: 'trial' });
+
+ await expect(license.badgeWrapper).toHaveCount(1);
+ await expect(license.bar).toContainText('Your Handsontable license key has expired');
+
+ await license.goto(INSTANT.trialSoftStop, { key: 'trial-external' });
+
+ await expect(license.badgeWrapper).toHaveCount(0);
+ await expect(license.bar).toHaveCount(0);
+ });
+ });
+
test.describe('a key that cannot be read, and no key at all', () => {
// DEV-2562: the two install faults BLOCK from 18.1 on (the specification's S4.5 shape). Their
// sentences moved out of the bottom bar and into the modal, so the bar must be gone.
diff --git a/tests/fixtures/demo/license-branding.html b/tests/fixtures/demo/license-branding.html
index ca1f1ce5f3..116348e3ed 100644
--- a/tests/fixtures/demo/license-branding.html
+++ b/tests/fixtures/demo/license-branding.html
@@ -59,10 +59,14 @@
// neither parsed nor checksummed, so the block reads identically.
//
// trial — usage_until 2026-09-26, notice 45, grace 15, flag `trial`
+ // trial-external — the same, with `no-ui-warns` added
// subscription — usage_until 2027-08-12, notice 60, grace 90, no flags
// subscription-external — the same, with `no-console-warns` + `no-ui-warns`
const LICENSE_KEYS = {
trial: '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCJdfX19a687a9f4d0d496c1ec86d97d58e971b458995f1a68ca117a6b406fa2f179923dcd42a789e3c56426690cf12c89e70abfbb6f45b96ba55fe49386d9e1b0080f2c]',
+ // A trial issued for external use. `no-ui-warns` must silence the badge and the bar, and must
+ // NOT silence the hard-stop lock - the flag suppresses warnings, not enforcement.
+ 'trial-external': '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI2LTA5LTI2Iiwibm90aWNlIjo0NSwiZ3JhY2UiOjE1LCJmbGFncyI6WyJ0cmlhbCIsIm5vLXVpLXdhcm5zIl19fX0dbeff87ec565b90d59175cd5aa86b93abd4164fc2967f9156e289f5db90f0b8c19ddfb44efd92a165b2d1c568c0097c78c243701acf200487372dc8b45a0f1be]',
subscription: '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6W119fX03de5d59e480be17d3c8cd340cb242cab64cac7888cbfba6c975c194f6613b8103792a6929f66852d18c7ac27c8c25fa9ab8a25b5e25f331669746c833a4f8361]',
'subscription-external': '[eyJwcm9kdWN0cyI6eyJoYW5kc29udGFibGUiOnsiY2FwYWJpbGl0aWVzIjpbImNvcmUiXSwidXNhZ2VfdW50aWwiOiIyMDI3LTA4LTEyIiwibm90aWNlIjo2MCwiZ3JhY2UiOjkwLCJmbGFncyI6WyJuby1jb25zb2xlLXdhcm5zIiwibm8tdWktd2FybnMiXX19fQbf6f3e8356a12fe1b2553ccd263dbc9a6f94c1fdc1aab949b3550865cb150c79027aa06a11c7618d5cbd1e14c4b73dc35f6bb81cd8c33c474194d082e2951106]',
// The two install faults, which block from 18.1 on: the subscription key with its checksum
diff --git a/tests/fixtures/pages/LicenseBrandingPage.ts b/tests/fixtures/pages/LicenseBrandingPage.ts
index a9b777774e..09393201d5 100644
--- a/tests/fixtures/pages/LicenseBrandingPage.ts
+++ b/tests/fixtures/pages/LicenseBrandingPage.ts
@@ -14,8 +14,8 @@ export const INSTANT = {
subscriptionHardStop: '2027-12-01T00:00:00Z',
} as const;
-type LicenseKeyName = 'trial' | 'subscription' | 'subscription-external' | 'tampered' |
- 'legacy-expired' | 'non-commercial-padded' | 'missing';
+type LicenseKeyName = 'trial' | 'trial-external' | 'subscription' | 'subscription-external' |
+ 'tampered' | 'legacy-expired' | 'non-commercial-padded' | 'missing';
type Variant = 'default' | 'no-row-headers' | 'no-headers-frozen' | 'narrow-corner' | 'dialog' |
'nested' | 'narrow';