Summary
VirtualDocument.head and VirtualDocument.body are plain fields assigned once in the constructor. Assigning documentElement.innerHTML replaces the document element's children, but goes through VirtualElement's innerHTML setter, which knows nothing about those fields — so they keep pointing at the original <head> and <body>, which are now detached.
Everything then operates on an orphaned tree. It fails silently: no error, just writes that go nowhere and reads that come back empty.
This is the path BrowserPage.content takes, so it affects the Browser API's main setup step.
Reproduction
import { Window } from 'very-happy-dom'
const window = new Window()
const document = window.document
document.documentElement.innerHTML = '<head><title>Page title</title></head><body><h1>Hi</h1></body>'
console.log(document.body.isConnected) // false
console.log(document.body === document.querySelector('body')) // false
console.log(document.body.innerHTML) // ""
console.log(document.querySelector('body').innerHTML) // "<h1>Hi</h1>"
// document.title reads through the stale head, so it disagrees with the DOM
console.log(document.title) // ""
console.log(document.querySelector('title').textContent) // "Page title"
// Injecting a stylesheet into the head silently goes nowhere
const style = document.createElement('style')
style.textContent = 'p { color: red }'
document.head.appendChild(style)
console.log(document.querySelector('style')) // nullSame through the Browser API, since BrowserPage.content assigns documentElement.innerHTML:
import { Browser } from 'very-happy-dom'
const page = new Browser().newPage()
page.content = '<body><h1>Hi</h1></body>'
const document = page.mainFrame.document
console.log(document.body.isConnected) // false
console.log(document.body === document.querySelector('body')) // falseConfirmed against main at b570adc with Bun 1.4.3.
Scope
Only the documentElement.innerHTML path is affected. The paths that reassign the fields explicitly are fine:
| Path | Result |
|---|---|
document.body.innerHTML = … | correct — body is not replaced |
JSDOM full-document parse | correct |
document.write() | correct |
documentElement.innerHTML = … | stale |
page.content = … | stale |
Current implementation
// src/nodes/VirtualDocument.ts
head: VirtualElement | null = null
body: VirtualElement | null = nullAssigned in the constructor, and reassigned in the parse path (this.head = childEl / this.body = childEl) which is why document.write and the JSDOM constructor stay correct. Nothing re-syncs them when documentElement's children are replaced from VirtualElement.
get title() reads this.head?.querySelector('title'), which is why the title symptom follows from the same cause.
Expected behavior
document.head and document.body should always be the current first <head>/<body> child of documentElement, however those children were produced:
document.body === document.querySelector('body')document.body.isConnected === truedocument.head.appendChild(el)putselin the live documentdocument.titleagrees withdocument.querySelector('title').textContent
Resolving them from documentElement rather than caching would give one source of truth. A few sites currently assign to head/body directly (the constructor and the parse path) and would need reworking to suit.
Worth deciding at the same time: document.querySelector('#x') currently finds elements that are only in the detached tree, because the id index is keyed by owner document rather than connectivity. That is arguably a second bug, and it is what masks this one in tests that go through getElementById/#id lookups after setting content.