Skip to content

Recognize nodes from other realms via realm-safe type checks - #156

Open
lizarusi wants to merge 5 commits into
bigskysoftware:mainfrom
lizarusi:fix-cross-realm-newcontent
Open

Recognize nodes from other realms via realm-safe type checks#156
lizarusi wants to merge 5 commits into
bigskysoftware:mainfrom
lizarusi:fix-cross-realm-newcontent

Conversation

@lizarusi

@lizarusi lizarusi commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Hi!

While using idiomorph at Walnut, we hit a bug that we've been carrying as a local patch — contributing the fix upstream.

The bug

Passing a node created in another JS realm (e.g. an iframe's contentDocument) as newContent throws:

TypeError: newContent is not iterable
    at normalizeParent (src/idiomorph.js)
    at Object.morph (src/idiomorph.js)

Repro:

const target = document.querySelector("#target");
const iframe = document.createElement("iframe");
document.body.append(iframe);
const foreign = iframe.contentDocument.createElement("button");
foreign.textContent = "Bar";
Idiomorph.morph(target, foreign); // 💥 TypeError: newContent is not iterable

Every realm has its own constructors, so cross-realm nodes fail instanceof Node in normalizeParent and fall through to the array/HTMLCollection branch, which blows up on the spread.

The crash is only the visible part. instanceof stays false for these nodes even after they are adopted into this document, so once past normalization they also silently fail every other realm-sensitive instanceof check: template content handling, id-based matching of new children (newChild instanceof Element), and syncInputValue — meaning input/option/textarea values would silently not sync from cross-realm content. The second test demonstrates that.

We hit this in production at Walnut, where we morph DOM captured from customer apps across document boundaries — we've been carrying a fix as a local patch since 0.7.2.

The fix

Replace realm-sensitive instanceof checks with tiny duck-typing helpers (nodeType / localName) — the same approach morphdom uses. The helpers carry JSDoc type predicates, so TypeScript narrows exactly like instanceof did and npm run typecheck stays green. The document.activeElement checks in the focus-preservation code keep instanceof, since the active element always belongs to this document.

First commit adds the failing tests, second commit makes them pass. There is a dedicated test per converted check — entry normalization, id-based element preservation (both the dummy-parent and SlicedParentNode paths), template content morphing, input/option/textarea value syncing, head handling, and Document normalization — and each test was verified to fail if its single check alone is reverted to instanceof. npm run typecheck, npm run format:check, and the full suite pass, with coverage at 100%.

Relates to #103 — whichever direction the newContent type-narrowing goes, cross-realm nodes currently crash rather than being handled or rejected cleanly, so this seems worth fixing today.

@lizarusi
lizarusi force-pushed the fix-cross-realm-newcontent branch from c9da9c7 to a52cd2f Compare August 13, 2026 13:10
Comment thread src/idiomorph.js
Comment thread src/idiomorph.js Outdated
Comment thread src/idiomorph.js Outdated
Comment thread src/idiomorph.js
@myabc

myabc commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

@lizarusi I'm not a maintainer - just a contributor/community member - so please take my drive-by suggestions with a grain of salt! That said - with a few fixes to the JSDocs and a guard for HTML docs - this patch has my vote!

@myabc

myabc commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Additionally, restoreFocus hardcodes document.activeElement (src/idiomorph.js:301) rather than ctx.target.ownerDocument. This would need to be fixed to fully support morphing foreign documents.

@lizarusi
lizarusi force-pushed the fix-cross-realm-newcontent branch 2 times, most recently from 83a029b to 40d5be0 Compare August 26, 2026 12:21
@lizarusi

Copy link
Copy Markdown
Contributor Author

@myabc Thanks a lot for looking into this! I've applied your suggestions (the isHtmlElement namespace guard and the JSDoc types) and added tests to verify them, plus fixed restoreFocus to resolve the active element via ctx.target.ownerDocument as you pointed out.

Additionally, I decided to keep value instanceof <constructor> as a first check before duck-typing: property probes can be fooled by form named-property shadowing (e.g. a form containing <input name="nodeType">), which instanceof is immune to — duck-typing now only kicks in for nodes from other realms.

@lizarusi
lizarusi requested a review from myabc August 26, 2026 12:38
lizarusi and others added 5 commits August 27, 2026 13:26
… behaviors

One test per realm-sensitive check: entry normalization (crash),
id-based element preservation (detached and SlicedParentNode paths),
template content morphing, input/option/textarea value syncing,
head handling, and Document normalization.
Nodes created in another JS realm (e.g. an iframe's document) fail
`instanceof` checks, even after being adopted into this document,
because each realm has its own constructors. This made morph() throw
"TypeError: newContent is not iterable" when given a cross-realm node,
and silently skip template handling, id-based matching,
input/option/textarea value syncing, head handling, and Document
normalization for cross-realm nodes.

Replace realm-sensitive `instanceof` checks with helpers that duck-type
via `nodeType` and `localName` (the approach morphdom uses). The
`document.activeElement` checks keep `instanceof`, since the active
element always belongs to this document.

Each converted check has a dedicated test that fails if that single
check is reverted to `instanceof`.
localName-only checks matched foreign-namespace elements (e.g. SVG
<template>, XML <head>), sending them down HTML-specific code paths:
SVG templates crashed morphChildren via an undefined .content, and
non-HTML head elements were diverted into head-merge logic. Fold the
five helpers into an isHtmlElement factory that also requires the
XHTML namespace, and tighten the JSDoc param types.

Suggested-by: myabc <myabc@users.noreply.github.com>

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENWz1cbCtHAYGK5kGFLyBD
The duck-typed nodeType checks can be fooled by named-property
shadowing: a form containing <input name="nodeType"> shadows
form.nodeType with that input element, so isNode rejected the form and
normalizeParent scattered its children into the morph, dropping the
<form> element itself. instanceof cannot be shadowed, so try it first
and fall back to duck-typing only for nodes from other realms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENWz1cbCtHAYGK5kGFLyBD
saveAndRestoreFocus consulted the host realm's document.activeElement
and instanceof checks, so restoreFocus silently no-oped when morphing
content inside another document (e.g. an iframe). Resolve the active
element via ctx.target.ownerDocument and the realm-safe input/textarea
helpers.

Suggested-by: myabc <myabc@users.noreply.github.com>

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENWz1cbCtHAYGK5kGFLyBD
@lizarusi
lizarusi force-pushed the fix-cross-realm-newcontent branch from 40d5be0 to e18d2c5 Compare August 27, 2026 11:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants