ReviewOS

stacks/very-happy-dom

BrowserFrame.window is a three-property stub rather than a Window

#1590
Closed glennmichael123 opened this 8 days ago ·
8 days ago

Summary

BrowserFrame.window is declared as Window & { document: VirtualDocument } but is actually a three-property object literal cast through as any. It is not a Window, so nothing that a page-context script or a Playwright-style API would reach for is present: no storage, no timers, no getComputedStyle, no navigator, no event target methods, and location is a two-property stub.

This is the substrate the rest of the Browser API sits on, so it limits what evaluate() (#1587) can meaningfully resolve, and it blocks any Playwright-shaped API that needs to run in page context.

Reproduction

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

const frame = new Browser().newPage().mainFrame

console.log(Object.keys(frame.window)) // [ "document", "location", "Event" ]
console.log(frame.window instanceof Window) // false
console.log(typeof frame.window.localStorage) // undefined
console.log(typeof frame.window.setTimeout) // undefined
console.log(typeof frame.window.getComputedStyle) // undefined
console.log(typeof frame.window.navigator) // undefined
console.log(typeof frame.window.addEventListener) // undefined
console.log(frame.window.location.pathname) // undefined

Confirmed against main at 5fae54d4 with Bun 1.4.3.

Probing 41 members that exist on a real new Window() — localStorage, sessionStorage, fetch, setTimeout, clearTimeout, setInterval, requestAnimationFrame, queueMicrotask, matchMedia, getComputedStyle, navigator, history, customElements, addEventListener, dispatchEvent, removeEventListener, MutationObserver, IntersectionObserver, ResizeObserver, CustomEvent, HTMLElement, Image, DOMParser, URL, AbortController, indexedDB, crypto, performance, innerWidth, innerHeight, self, window, top, parent, happyDOM, CSS, XMLHttpRequest, WebSocket, FormData, Range, getSelection — finds 41 of 41 missing on a frame window.

Current implementation

BrowserFrame's constructor builds the window by hand:

// src/browser/BrowserFrame.ts
this.document = new VirtualDocument()

this.window = {
  document: this.document,
  location: {
    href: this._url,
    toString: () => this._url,
  },
  Event: VirtualEvent,
} as any

The as any is what allows the declared Window type to disagree with the value, so this does not surface at the type level for consumers of frame.window.

Expected behavior

A frame should own a real Window instance, with its document as the frame's document:

  • frame.window instanceof Window is true
  • storage, timers, observers, navigator, matchMedia and getComputedStyle are present and operate on the frame's document
  • frame.window.location is a real Location, with pathname, search, hash, origin, protocol, host and the assignment setters, staying in sync with frame.url
  • frame.window.document === frame.document continues to hold
  • self/window self-references resolve, and top/parent reflect the frame tree for child frames

Constructing the Window with the page's viewport and the context's settings would also let per-context configuration reach page code.

8 days ago

Fixed on main in b570adc. Leaving this open for you to close, since one deliberate scope call is worth confirming first.

What changed

Frames construct a real Window, configured from their owners — the page's viewport and console, and the browser's settings. The window owns its document, so frame.document is frame.window.document rather than a second parallel document.

Re-running the probe from the report:

Object.keys(frame.window)        →  the full Window surface (was ["document","location","Event"])
frame.window instanceof Window   →  true   (was false)
typeof frame.window.localStorage →  object (was undefined)
typeof frame.window.setTimeout   →  function (was undefined)
frame.window.location.pathname   →  "/a/b" (was undefined)

The 41-member probe now reports 0 of 41 missing.

Follow-on changes

  • frame.url goes through the window's real Location via happyDOM.setURL(), so pathname, search, hash, origin, protocol and host stay coherent instead of being absent.
  • page.setViewport() now propagates to frames. It previously only updated the page's own field, so a frame kept reporting the size it was built with.
  • BrowserPage inherits its console from the browser. It hardcoded globalThis.console, so new Browser({ console }) set browser.console and nothing downstream ever read it. That surfaced only because the frame's Window now takes a console.

One behaviour change to be aware of

frame.url is now a serialized URL, because it reads from a real Location:

page.url = 'https://example.com'
page.mainFrame.url // 'https://example.com/'  — was 'https://example.com'

This matches new URL() and new Window({ url }); the stub echoed the raw string back. One existing assertion in browser-api.stress.test.ts covered the old behaviour and is updated.

Deliberately out of scope

  • goto(), waitForNavigation(), reload() and the history methods are still no-ops — #1586.
  • evaluate() still does not accept arguments or evaluate strings in the frame realm — #1587.

Both should be markedly easier now: the blocker was that there were no real globals to resolve against, and evaluate()'s function form already resolves them correctly.

For whoever picks up #1587 — the declared type of frame.window was the global DOM Window from lib.dom, not this package's Window class. That is why a 42-member interface was satisfied by a three-key object without TypeScript complaining. It is now the real class, so that class of mismatch will not recur silently.

Tests

test/browser-frame-window.test.ts covers the window surface, Location, viewport and settings/console propagation — 12 of its 17 tests fail against the previous stub. The module-graph guard from f35ef09 is extended over browser/*, since BrowserFrame now imports Window at module scope while VirtualDocument imports browser/CookieContainer the other way.

8 days ago

Fixed in b570adc (BrowserFrame.window is a real Window, not a three-property stub). 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.