ReviewOS

stacks/very-happy-dom

Canvas element doesn't inherit from HTMLElement (missing `style` property)

#1556
Closed gorango opened this 9 days ago ·
9 days ago

When creating a <canvas> element via document.createElement('canvas'), the returned element does not inherit from HTMLElement. Specifically, it's missing the style property, which breaks any code that tries to set inline styles on the canvas element.

Reproduction

import { Window } from 'very-happy-dom'

const win = new Window()
const canvas = win.document.createElement('canvas')

// Check basic properties
console.log('canvas:', !!canvas)                    // true
console.log('canvas.style:', canvas.style)          // undefined (BUG)
console.log('getContext:', typeof canvas.getContext) // 'function'

// This works
const ctx = canvas.getContext('2d')
console.log('ctx:', !!ctx)                          // true

// This throws TypeError: undefined is not an object
canvas.style.position = 'absolute'
canvas.style.width = '100px'

Expected Behavior

Canvas elements should inherit from HTMLElement and have all standard element properties including style, classList, id, className, addEventListener, etc.

Actual Behavior

The canvas element returned by document.createElement('canvas') is missing:

  • style (CSSStyleDeclaration)
  • classList (DOMTokenList)
  • id, className
  • addEventListener / removeEventListener
  • All other HTMLElement properties

Environment

  • very-happy-dom version: 0.1.21 (latest GitHub main)
  • Bun version: 1.2.21
  • OS: Linux x64

Notes

I checked the source code and saw that VirtualDocument.ts has special handling for canvas elements:

if (tagName.toLowerCase() === 'canvas') {
  const { HTMLCanvasElement } = require('../apis/Canvas')
  const el = new HTMLCanvasElement()
  el.ownerDocument = this
  return el
}

The HTMLCanvasElement class in src/apis/Canvas.ts appears to be a standalone class that doesn't extend HTMLElement. It should extend the base element class to inherit DOM properties like style.

Impact

This breaks testing of any library that applies inline styles to canvas elements (e.g., positioning, sizing, transforms). Many canvas-based UI components (minimaps, charts, editors) rely on canvas.style for layout.

Reproduction

Included in description (unreproducible in online sandboxes).

System Info

System:
    OS: Linux 6.17 EndeavourOS
    CPU: (8) x64 Intel(R) Core(TM) i7-8565U CPU @ 1.80GHz
    Memory: 3.32 GB / 15.29 GB
    Container: Yes
    Shell: 5.9 - /usr/bin/zsh
  Binaries:
    Node: 22.16.0 - /run/user/1000/fnm_multishells/696614_1776850704152/bin/node
    npm: 11.6.4 - /run/user/1000/fnm_multishells/696614_1776850704152/bin/npm
    bun: 1.2.21 - /run/user/1000/fnm_multishells/696614_1776850704152/bin/bun
    Deno: 2.3.3 - /home/g/.deno/bin/deno
  Browsers:
    Brave Browser: 146.1.88.132
    Firefox: 145.0.2

Used Package Manager

bun

Validations

  • Follow our Code of Conduct
  • Read the Contributing Guide.
  • Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.
  • Check that this is a concrete bug. For Q&A, please open a GitHub Discussion instead.
  • The provided reproduction is a minimal reproducible of the bug.
9 days ago

The reported problem is fixed on main as of 0757ce9 — but one part of the "Expected Behavior" is deliberately left open, so I am leaving this issue for you to close rather than auto-closing it.

Fixed

HTMLCanvasElement was a standalone class that reimplemented a partial node interface rather than extending VirtualElement. It now extends it, so the hand-rolled plumbing is deleted rather than reimplemented:

const canvas = document.createElement('canvas')

canvas.style.position = 'absolute' // no longer throws
canvas.classList.add('chart')
canvas.dataset.kind = 'line'
canvas.id = 'c1'
canvas.addEventListener('click', handler)

document.body.appendChild(canvas)
document.querySelector('#c1') === canvas // true
canvas.closest('body') // the body element
canvas.outerHTML // '<canvas ...>'

Two things fell out of the change that were worth fixing at the same time:

  • width/height are now reflected content attributes with the spec's 300x150 defaults. Previously they were plain fields, so canvas.width = 800 left getAttribute('width') as null. Absent, unparseable and negative values fall back to the default.
  • cloneNode preserves the canvas API. The base implementation constructs a plain VirtualElement, so a cloned canvas had no getContext. The clone now gets a fresh 2D context rather than sharing one, as in the browser.

Covered by 11 new tests in test/canvas.test.ts; 9 of them fail against the previous implementation.

Not fixed: instanceof HTMLElement

The issue also asks that canvas "inherit from HTMLElement". That is still false — but it is false for every element, not just canvas:

const window = new Window()
const document = window.document

document.createElement('div') instanceof window.HTMLElement // false
document.createElement('span') instanceof window.HTMLElement // false
document.createElement('input') instanceof window.HTMLElement // false
document.createElement('canvas') instanceof window.HTMLElement // false

createElement returns a VirtualElement, and HTMLElement extends VirtualElement, so an element from createElement can never be an instance of it. Canvas is now exactly as consistent as every other tag.

Making that true would mean createElement resolving a tag to its specific HTML*Element subclass — which is a library-wide change, and related to the fact that those ~63 subclasses are currently constructor-only shells (HTMLDialogElement has no showModal, HTMLTableElement no insertRow, and so on). Worth its own issue if you want it tracked; happy to open one.

One note for the record

Extending VirtualElement from apis/Canvas.ts made Canvas the first entry into the nodes module graph and exposed a latent import cycle:

VirtualElement -> parsers/html-parser -> VirtualSVGElement -> VirtualElement

which threw ReferenceError: Cannot access 'VirtualElement' before initialization at import time. I worked around it by moving the class to nodes/HTMLElementClasses.ts (where it belongs anyway, beside its siblings) and leaving apis/Canvas.ts with no runtime imports. The cycle itself is untouched and still latent — any future static import of VirtualElement from an early-initialising module can hit it again. It would be worth breaking properly, most likely by making html-parser resolve its element constructors lazily.

9 days ago

Fixed in 0757ce9 (HTMLCanvasElement is a real element, with reflected width/height and a cloneNode override). Released in v0.1.11, so this is now available to consumers rather than only on main. The scope notes I left earlier on this issue still stand — read those for what was deliberately left out.

Sign in to comment on this issue.