Skip to content

fix: vnode clone compatible with frozen intrinsics#5078

Draft
kumavis wants to merge 1 commit into
preactjs:mainfrom
kumavis:fix-hardenedjs-compat
Draft

fix: vnode clone compatible with frozen intrinsics#5078
kumavis wants to merge 1 commit into
preactjs:mainfrom
kumavis:fix-hardenedjs-compat

Conversation

@kumavis

@kumavis kumavis commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Disclaimer: I (a human) identified this issue years ago and have always just worked around it via a local patch. I used an LLM (opus 4.7) to generate the fix. I reviewed the change and wrote most of the PR description.

This PR fixes a compatibility issue when Preact is run in Hardened JavaScript environments such as:

The compatibility issue comes from a javascript quirk known as "the override mistake" related to frozen intrinsics. For this bug, the minimal environment change is just Object.freeze(Object.prototype).

In this environment, assigning the constructor property via assignment syntax (=) or Object.assign causes an error.

  TypeError: Cannot assign to read only property 'constructor'

Specifically, Object.assign({}, vnode) in renderComponent() (and cloneNode in src/diff/index.js) tries to copy a vnode's constructor: undefined own property via [[Set]]. Under environments with a frozen Object.prototype the Object.prototype.constructor is non-writable, so the assignment fails.

This PR introduces a cloneVNode utility that ensures VNodes are cloned safely. It also reorders the constructor key for consistent object shape between VNodes and their clones.

Note: I erred on the side of excessive documentation in comments, let me know if you would like this reduced.

@kumavis kumavis changed the title fix: vnode clone compatible with hardenedjs env fix: vnode clone compatible with frozen intrinsics Apr 25, 2026
@github-actions

github-actions Bot commented Apr 25, 2026

Copy link
Copy Markdown

📊 Tachometer Benchmark Results

Summary

duration

  • create10k: unsure 🔍 -0% - +0% (-3.70ms - +3.50ms)
    preact-local vs preact-main
  • filter-list: faster ✔ 0% - 1% (0.01ms - 0.12ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -1% - +2% (-0.72ms - +1.08ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 -1% - +1% (-0.18ms - +0.12ms)
    preact-local vs preact-main
  • replace1k: faster ✔ 0% - 3% (0.00ms - 1.83ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -4% - +5% (-0.08ms - +0.09ms)
    preact-local vs preact-main
  • todo: unsure 🔍 -1% - +1% (-0.32ms - +0.25ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -0% - +5% (-0.09ms - +1.44ms)
    preact-local vs preact-main

usedJSHeapSize

  • create10k: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-main
  • filter-list: unsure 🔍 -0% - +0% (-0.00ms - +0.01ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -5% - +1% (-0.24ms - +0.07ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 +0% - +0% (+0.00ms - +0.01ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 -0% - +0% (-0.00ms - +0.00ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -2% - +3% (-0.02ms - +0.03ms)
    preact-local vs preact-main
  • todo: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -0% - +0% (-0.00ms - +0.01ms)
    preact-local vs preact-main

Results

create10k

duration

VersionAvg timevs preact-localvs preact-main
preact-local807.97ms - 813.14ms-unsure 🔍
-0% - +0%
-3.70ms - +3.50ms
preact-main808.15ms - 813.17msunsure 🔍
-0% - +0%
-3.50ms - +3.70ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local19.07ms - 19.07ms-unsure 🔍
+0% - +0%
+0.00ms - +0.00ms
preact-main19.07ms - 19.07msunsure 🔍
-0% - -0%
-0.00ms - -0.00ms
-
filter-list

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.54ms - 16.60ms-faster ✔
0% - 1%
0.01ms - 0.12ms
preact-main16.59ms - 16.68msslower ❌
0% - 1%
0.01ms - 0.12ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.55ms - 1.55ms-unsure 🔍
-0% - +0%
-0.00ms - +0.01ms
preact-main1.55ms - 1.55msunsure 🔍
-0% - +0%
-0.01ms - +0.00ms
-
hydrate1k

duration

VersionAvg timevs preact-localvs preact-main
preact-local58.19ms - 59.48ms-unsure 🔍
-1% - +2%
-0.72ms - +1.08ms
preact-main58.03ms - 59.28msunsure 🔍
-2% - +1%
-1.08ms - +0.72ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local4.97ms - 5.09ms-unsure 🔍
-5% - +1%
-0.24ms - +0.07ms
preact-main4.97ms - 5.26msunsure 🔍
-1% - +5%
-0.07ms - +0.24ms
-
many-updates

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.32ms - 16.54ms-unsure 🔍
-1% - +1%
-0.18ms - +0.12ms
preact-main16.36ms - 16.56msunsure 🔍
-1% - +1%
-0.12ms - +0.18ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local3.73ms - 3.74ms-unsure 🔍
+0% - +0%
+0.00ms - +0.01ms
preact-main3.73ms - 3.73msunsure 🔍
-0% - -0%
-0.01ms - -0.00ms
-
replace1k
  • Browser: chrome-headless
  • Sample size: 100
  • Built by: CI #5561
  • Commit: a628f41

duration

VersionAvg timevs preact-localvs preact-main
preact-local61.46ms - 62.54ms-faster ✔
0% - 3%
0.00ms - 1.83ms
preact-main62.18ms - 63.66msunsure 🔍
-0% - +3%
+0.00ms - +1.83ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local3.00ms - 3.01ms-unsure 🔍
-0% - +0%
-0.00ms - +0.00ms
preact-main3.00ms - 3.01msunsure 🔍
-0% - +0%
-0.00ms - +0.00ms
-

run-warmup-0

VersionAvg timevs preact-localvs preact-main
preact-local27.49ms - 28.19ms-unsure 🔍
-1% - +2%
-0.36ms - +0.60ms
preact-main27.39ms - 28.06msunsure 🔍
-2% - +1%
-0.60ms - +0.36ms
-

run-warmup-1

VersionAvg timevs preact-localvs preact-main
preact-local33.61ms - 34.67ms-unsure 🔍
-2% - +2%
-0.81ms - +0.65ms
preact-main33.71ms - 34.72msunsure 🔍
-2% - +2%
-0.65ms - +0.81ms
-

run-warmup-2

VersionAvg timevs preact-localvs preact-main
preact-local31.00ms - 32.04ms-unsure 🔍
-3% - +2%
-0.90ms - +0.69ms
preact-main31.03ms - 32.23msunsure 🔍
-2% - +3%
-0.69ms - +0.90ms
-

run-warmup-3

VersionAvg timevs preact-localvs preact-main
preact-local27.36ms - 27.58ms-unsure 🔍
-1% - +0%
-0.34ms - +0.11ms
preact-main27.39ms - 27.78msunsure 🔍
-0% - +1%
-0.11ms - +0.34ms
-

run-warmup-4

VersionAvg timevs preact-localvs preact-main
preact-local25.33ms - 26.70ms-unsure 🔍
-4% - +3%
-1.10ms - +0.84ms
preact-main25.46ms - 26.83msunsure 🔍
-3% - +4%
-0.84ms - +1.10ms
-

run-final

VersionAvg timevs preact-localvs preact-main
preact-local22.45ms - 22.83ms-unsure 🔍
-2% - +1%
-0.37ms - +0.19ms
preact-main22.52ms - 22.94msunsure 🔍
-1% - +2%
-0.19ms - +0.37ms
-
text-update
  • Browser: chrome-headless
  • Sample size: 150
  • Built by: CI #5561
  • Commit: a628f41

duration

VersionAvg timevs preact-localvs preact-main
preact-local1.79ms - 1.91ms-unsure 🔍
-4% - +5%
-0.08ms - +0.09ms
preact-main1.79ms - 1.90msunsure 🔍
-5% - +4%
-0.09ms - +0.08ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.00ms - 1.04ms-unsure 🔍
-2% - +3%
-0.02ms - +0.03ms
preact-main0.99ms - 1.03msunsure 🔍
-3% - +2%
-0.03ms - +0.02ms
-
todo

duration

VersionAvg timevs preact-localvs preact-main
preact-local31.64ms - 32.05ms-unsure 🔍
-1% - +1%
-0.32ms - +0.25ms
preact-main31.67ms - 32.08msunsure 🔍
-1% - +1%
-0.25ms - +0.32ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.26ms - 1.26ms-unsure 🔍
+0% - +0%
+0.00ms - +0.00ms
preact-main1.26ms - 1.26msunsure 🔍
-0% - -0%
-0.00ms - -0.00ms
-
update10th1k
  • Browser: chrome-headless
  • Sample size: 140
  • Built by: CI #5561
  • Commit: a628f41

duration

VersionAvg timevs preact-localvs preact-main
preact-local26.73ms - 27.89ms-unsure 🔍
-0% - +5%
-0.09ms - +1.44ms
preact-main26.13ms - 27.13msunsure 🔍
-5% - +0%
-1.44ms - +0.09ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local2.96ms - 2.97ms-unsure 🔍
-0% - +0%
-0.00ms - +0.01ms
preact-main2.96ms - 2.97msunsure 🔍
-0% - +0%
-0.01ms - +0.00ms
-

tachometer-reporter-action v2 for CI

@kumavis
kumavis marked this pull request as ready for review April 26, 2026 03:30
@coveralls

coveralls commented Apr 26, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 98.544% (+0.02%) from 98.528% — kumavis:fix-hardenedjs-compat into preactjs:main

@kumavis

kumavis commented Apr 30, 2026

Copy link
Copy Markdown
Contributor Author

requesting a review from @JoviDeCroock (bc of recent change to vnode constructor logic (strict equality))

@JoviDeCroock

Copy link
Copy Markdown
Member

While I think the fix makes sense, there's a site that has been forgotten by the LLM. compat/src/suspense.js also has a clone afaict. That being said there are a lot of libraries in the ecosystem that will still do this through options hooks so I am thinking whether there is a more general solution that would prevent us from patching all of them as well

@kumavis

kumavis commented May 1, 2026

Copy link
Copy Markdown
Contributor Author

there's a site that has been forgotten by the LLM

good catch, thanks for the review.

there are a lot of libraries in the ecosystem that will still do this through options hooks

can you help me understand this? sounds like 3rd parties are also cloning nodes, but I don't get the "through option hooks" part.

the good news is this change would not break those ecosystem libraries, and users running in HardenedJS environments get a working core preact.

to help the ecosystem we could export cloneVNode and/or provide a vnode.clone(). either way any ecosystem package would need to update its manual cloning. A HardenedJS consumer would need to either patch the ecosystem package or choose a compatible one. I have not personally encountered an issue with ecosystem packages in HardenedJS, but I'm not a heavy user.

@kumavis
kumavis force-pushed the fix-hardenedjs-compat branch 3 times, most recently from 4b73972 to 00658dc Compare May 1, 2026 22:11
@kumavis
kumavis marked this pull request as draft May 1, 2026 22:18
@JoviDeCroock

JoviDeCroock commented May 2, 2026

Copy link
Copy Markdown
Member

The through options hook part is mostly the options.vnode hook but come to think more of it, most of these are mutating the VNode rather than doing immutable copies so it's possible that we're good on this change. The performance looks to be neutral as well so I am gravitating towards accepting the change despite the size changes, going to have a think about whether we can reduce the byte-size (staying true to our philosophy of byte size).

Do note that this is a PR for the main branch which is currently v11 (beta) and we also have the v10.x branch which is the mainline atm.

Object.assign({}, vnode) in renderComponent(), cloneNode in
src/diff/index.js, and detachedClone in compat/src/suspense.js
tries to copy a vnode's `constructor: undefined` own property via
[[Set]]. Under environments where `Object.prototype.constructor`
is a non-writable data property (e.g. raw
`Object.freeze(Object.prototype)` or hardenedjs configured with
`overrideTaming: 'min'`), the assignment trips the override
mistake and fails with:

  TypeError: Cannot assign to read only property 'constructor'

Initial render works because createVNode builds vnodes with an
object literal ([[CreateDataProperty]], not Set), but any
setState/forceUpdate-triggered re-render goes through
Object.assign({}, oldVNode) and trips the override mistake.

Note: hardening configs that tame the override mistake by
replacing `Object.prototype.constructor` with an accessor pair
(SES default, Node's `--frozen-intrinsics`) do NOT exhibit this
bug — but the fix is still required for the untamed configs
above. The added Node subprocess test guards against future
changes to that taming.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude <noreply@anthropic.com>
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.

3 participants