Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions docs/plans/2026-09-27-happy-dom-20-14-offscreen-canvas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# happy-dom 20.9 → 20.14.5 (#1365)

## Why this exists

Dependabot #1322 moved happy-dom 20.9.0 → 20.14.5 and its quality gate (run
36168289491, head `cfc31862`, 2026-09-25T17:37Z) failed in three renderer
files. #1360 took #1322's other bumps with happy-dom held back. #1365 asks which
happy-dom change broke event dispatch, and forbids weakening the tests to get
the bump through.

## What the evidence says

Reproduced locally with happy-dom 20.14.5 loaded (verified via
`navigator.userAgent` → `HappyDOM/20.14.5`; a symlinked vitest silently loads
the shared tree's 20.9.0, so the version has to be checked, not assumed).

1. **`terminalWheelBoundary.xterm.renderer.test.ts`: real, and it comes from
happy-dom.** All 5 tests fail in `term.open()` with `value must not be
falsy` from xterm's `WidthCache` (`RendererUtils.throwIfFalsy`). The cause
is not event dispatch. happy-dom **20.10.0** added a global
`OffscreenCanvas` (`lib/canvas/OffscreenCanvas.js`; 20.9.0 has no
`lib/canvas` at all). xterm's WidthCache prefers
`new OffscreenCanvas(1, 1)` whenever the global exists, so it bypasses the
test's shim, which patches only `HTMLCanvasElement.prototype.getContext`.
happy-dom's `OffscreenCanvas.getContext` returns `null` when no
`canvasAdapter` is configured, which is exactly the "no 2D context" gap the
shim already fills for `<canvas>`.
xterm's CharSizeService also probes `new OffscreenCanvas(100, 100)` for its
TextMetrics strategy. It wraps that probe in try/catch and falls back to
the DOM strategy, so it degrades on its own, and shim 2 (the measure-span
size) still applies.
2. **`CommandKeybindingsRow.capture.renderer.test.tsx` (7 tests) and
`ConversationsPicker.renderer.test.tsx` (2 tests): not happy-dom.** The
failures were `Unable to find … role "button" and name "Add"` or
`"everywhere"`, while the rendered DOM in the same log shows
`"Add a shortcut to New Tab"`. #1221 (keyboard-first) renamed those
controls. The tests were updated in `e092232c` at 17:52Z, 15 minutes after
the #1322 run. On current main both files pass unchanged with 20.14.5
(34/34 across the three files, with only the xterm file failing).

## Plan

1. Commit this plan.
2. Fail-first: bump `happy-dom` to `^20.14.5` in package.json and the lockfile,
touching nothing else. The existing xterm wheel test then fails on the real
path (`open()` → WidthCache). No new test is needed: the existing one is
the real-data oracle.
3. Fix: shim 1 installs the same fake 2D context on
`OffscreenCanvas.prototype.getContext` when the global exists, restored in
`afterEach` like the others. Update the WHY block: it still claims happy-dom
has no OffscreenCanvas, and that is now false. Keep the DOM char-measure
strategy: the fake context has no `fontBoundingBox*`, so xterm's
CharSizeService still falls back to the DOM strategy that shim 2 sizes.
Say so, so the next bump does not have to re-derive it.
Rejected alternative: deleting `globalThis.OffscreenCanvas` for the test.
That would hide the path Electron takes in production (Chromium has
OffscreenCanvas, so WidthCache uses it there too). Shimming it keeps the
test on production's branch.
4. Run the three files plus the renderer suite once at the end.

## Non-goals

No assertion changes. No other dependency moves.
24 changes: 19 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@
"electron-updater": "^6.8.9",
"electron-vite": "^5.0.0",
"esbuild": "0.25.12",
"happy-dom": "^20.9.0",
"happy-dom": "^20.14.5",
"tailwindcss": "^4.2.2",
"tsx": "^4.23.15",
"typescript": "^5.5.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,34 @@ import { attachTerminalWheelBoundary } from './terminalWheelBoundary'
// and scroll dimension is 0 (PR #792 review round 1 stopped there). The shims
// below supply only that missing browser environment:
// 1. A 2D context exposing the two members WidthCache touches (`font` and
// `measureText().width`). It returns a constant glyph width.
// `measureText().width`). It returns a constant glyph width. It goes on
// BOTH `HTMLCanvasElement` and `OffscreenCanvas`. happy-dom 20.10 added a
// global `OffscreenCanvas` whose `getContext` returns null when no canvas
// adapter is configured, and WidthCache prefers `new OffscreenCanvas(1, 1)`
// over `<canvas>` whenever that global exists. With only the `<canvas>`
// shim, `open()` threw `value must not be falsy` again on 20.14.5 (#1365).
// We shim rather than delete the global: Chromium has OffscreenCanvas, so
// production xterm takes the OffscreenCanvas branch as well.
// 2. A fixed size for xterm's `.xterm-char-measure-element` span, which
// CharSizeService's DOM strategy reads. happy-dom has no OffscreenCanvas,
// so the DOM strategy is the one xterm picks.
// 3. Explicit 0px left/top padding on `.xterm-screen`, which a browser
// computes from xterm.css. happy-dom's computed style returns '' for unset
// padding, and MouseCoordsService parseInt()s it into NaN.
// CharSizeService's DOM strategy reads. CharSizeService first tries a
// TextMetrics strategy on `new OffscreenCanvas(100, 100)`. It needs
// `fontBoundingBoxAscent`/`Descent`, which the fake context deliberately
// lacks, so that constructor throws and xterm's own try/catch falls back to
// the DOM strategy that this shim sizes. If the fake ever gains those
// fields, xterm switches strategies and this shim becomes dead.
// 3. Explicit 0px left/top padding on `.xterm-screen`. A browser computes
// 0px from the CSS initial value: xterm.css has no padding rule for
// `.xterm-screen`, and neither does our styles.css. happy-dom's computed
// style returns '' for unset padding, and MouseCoordsService parseInt()s it
// into NaN.
// Separately, happy-dom's WheelEvent extends UIEvent rather than MouseEvent,
// so it lacks the modifier and clientX/clientY fields every browser WheelEvent
// carries; `wheel()` below sets them explicitly. Either gap yields
// `ESC[<64;NaN;NaNM` instead of a real report, a happy-dom artifact rather
// than an xterm bug. The first real run had both gaps, and a run with only the
// padding shim still got NaN from the missing clientX. That unset padding alone
// also yields NaN comes from reading happy-dom's computed style (it has no
// default padding) and was not observed on its own.
// padding shim still got NaN from the missing clientX. A run with clientX set
// but without the padding shim (#1446 review c) produced exactly the same bytes,
// so each gap alone is enough to break the report.
// Everything asserted is computed by xterm itself from real WheelEvents:
// - StandardWheelEvent delta normalization;
// - the `fastScrollSensitivity` Alt multiplier;
Expand Down Expand Up @@ -76,6 +89,18 @@ function installBrowserShims(): void {
} as unknown as typeof canvasPrototype.getContext
restoreShims.push(() => { canvasPrototype.getContext = originalGetContext })

// A `typeof` guard, not an assertion. Without the global, xterm falls back
// to `<canvas>`, which the shim above already covers, so its absence is
// not a failure.
if (typeof OffscreenCanvas !== 'undefined') {
const offscreenPrototype = OffscreenCanvas.prototype
const originalOffscreenGetContext = offscreenPrototype.getContext
offscreenPrototype.getContext = function (this: OffscreenCanvas, contextId: string) {
return contextId === '2d' ? fakeContext : originalOffscreenGetContext.call(this, contextId as '2d')
} as unknown as typeof offscreenPrototype.getContext
restoreShims.push(() => { offscreenPrototype.getContext = originalOffscreenGetContext })
}

const sizes = [['offsetWidth', GLYPH_WIDTH_PX * MEASURE_SPAN_REPEAT], ['offsetHeight', GLYPH_HEIGHT_PX]] as const
for (const [property, size] of sizes) {
const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, property)
Expand Down
Loading