Skip to content

Adopt React Native 0.82 DOM Node APIs#475

Open
yaminyassin wants to merge 4 commits into
facebook:mainfrom
yaminyassin:rn-0.82-dom-node-apis
Open

Adopt React Native 0.82 DOM Node APIs#475
yaminyassin wants to merge 4 commits into
facebook:mainfrom
yaminyassin:rn-0.82-dom-node-apis

Conversation

@yaminyassin
Copy link
Copy Markdown

@yaminyassin yaminyassin commented May 12, 2026

Summary

Bumps react-native peer dep to >=0.82.0 and adopts the stable DOM Node APIs from RN 0.82. The native ref polyfill becomes an overlay on top of RN's host node instead of a parallel object that re-implements DOM traversal.

The first commit on this branch (4a49d1f) wrapped the node in a Proxy. After review feedback that Hermes can't inline through Proxy traps, follow-up 8fbae4f replaces it with Object.create(node) prototype delegation: strict-dom defines its overrides as own properties on the wrapper, and everything else delegates to the node via the prototype chain. Same observable behavior, static hidden class Hermes can optimize, no per-access bound-function allocations.

Tracks #462.

What's still polyfilled

  • nodeName: uppercase DOM name. RN exposes tagName as 'RN:View'.
  • getBoundingClientRect and the length getters (offsetWidth, clientHeight, ...): divided by the active viewportScale. Only installed when scale isn't 1, so the common path skips them.
  • <img>.complete: returns false when the underlying Image node doesn't expose it.
  • <input> / <textarea> setSelectionRange / selectionStart / selectionEnd: polyfilled over setSelection. Each one is gated on the underlying property being missing, so the polyfill switches itself off the day RN exposes the W3C API.

What forwards to the RN node

ownerDocument, getRootNode, parentNode, parentElement, children, childNodes, sibling navigation, contains, compareDocumentPosition, pointer-capture, legacy measure* and setNativeProps. With the node as the wrapper's prototype, RN's prototype methods invoked on the wrapper see this === wrapper; the symbol-keyed internals they consult (INSTANCE_HANDLE_KEY, OWNER_DOCUMENT_KEY from NodeInternals.js) are reachable through the chain back to the node, so RN's implementations resolve their backing state correctly.

instanceof keeps working. The chain is wrapper → node → ReactNativeElement.prototype → ReadOnlyElement.prototype → ReadOnlyNode.prototype, one link longer than the pre-PR clone but the class prototypes are still on it. Verified on a live iPad simulator (wrapper.constructor.name === 'ReactNativeElement') and in Chrome ('HTMLDivElement').

Identity caching via WeakMap<Node, Node> is preserved, so the same RN node yields the same wrapped ref across renders.

Descriptor flags

configurable: true is set on every override so Object.defineProperty can redefine them later (tests, devtools, or a future fix to viewport-scale memoization). Value descriptors (nodeName, getBoundingClientRect, setSelectionRange) are not writable, matching the DOM spec for read-only properties. Strict-mode assignments like wrapper.setSelectionRange = ... now throw TypeError instead of silently shadowing. Demoed live in the example apps.

Breaking

Peer dep react-native >=0.79.5>=0.82.0. Apps on older RN stay on the previous react-strict-dom release.

Notes for reviewers

  • Hermes / Proxy concern. Addressed in 8fbae4f. The delegation variant has a 1-to-15 own-property hidden class (size depends on viewport scale and tag), no traps, no per-access binding. The CSS benchmarks in benchmarks/ don't exercise the ref read path, so the existing numbers aren't informative for this change. Happy to add a targeted micro-benchmark if useful.

  • Expo SDK. No SDK pairs exactly with RN 0.82 (SDK 53 = 0.79, SDK 54 ≈ 0.81, SDK 55 = 0.83). The example apps move to SDK 55 / RN 0.83.6, which satisfies the new peer dep and ships the same DOM Node surface.

  • React devDeps. RN 0.82+ requires React >=19.1.1. The workspace root and library packages' devDependencies pin react / react-dom / react-test-renderer to ~19.2.0 to keep a single React instance across the monorepo.

  • Flow types in 0.83. Animated.createAnimatedComponent is now a single-type-arg generic; fixed by dropping the over-specified two-type-arg form. ImageProps / TextInputProps became exact; the wide spread of strict-dom's ReactNativeProps is covered by two targeted $FlowFixMe annotations. Narrowing that spread properly is a separate follow-up.

  • Ten ref tests in tests/html/html-refs-test.native.js pin every wrapped-ref behavior: uppercase nodeName, gBCR pass-through at scale 1 and scaled at non-1, DOM Node API pass-through (ownerDocument, getRootNode, childNodes, children), strict-ref identity stability, <img>.complete fallback and pass-through, and the input selection trio. All ten pass against both the original Proxy commit and the prototype-delegation follow-up.

  • One pre-existing latent issue noted. memoizedStrictRefs is keyed only on node, not on (node, viewportScale). A node rendered first outside ViewportProvider and then inside (or with a changing scale) keeps the wrapper from the first wrap. Predates this PR; calling out for a follow-up. Not blocking. I worked around it locally in a realtime demo by putting key={viewportWidth} on the inner reporter to force a remount.

Yamin Yassin added 3 commits May 12, 2026 23:20
Bump the react-native peer dependency to >=0.82.0 and rewrite
useStrictDOMElement to wrap the underlying RN host node in a thin
Proxy instead of cloning it via Object.create / Object.defineProperties.

React Native 0.82 shipped the stable DOM Node APIs that strict-dom
helped drive into RN (DOM traversal, ownerDocument, getRootNode,
children/childNodes, pointer-capture methods, etc.), so the native
ref polyfill becomes a lightweight overlay rather than a parallel
implementation. The Proxy traps only the keys strict-dom still needs
to control:

- nodeName: uppercase DOM name (RN exposes tagName as 'RN:View')
- getBoundingClientRect and length getters: divided by the active
  viewportScale
- <img>.complete: fallback to false when the underlying RN Image
  node does not expose it
- <input>/<textarea> selection trio (setSelectionRange,
  selectionStart, selectionEnd): polyfilled on top of setSelection
  while RN's TextInput lacks the W3C selection API

Everything else (ownerDocument, getRootNode, parentNode, children,
childNodes, sibling navigation, pointer-capture, legacy measure*)
forwards directly to the underlying RN node via Reflect.get. Function
values are bound to the target so internal `this`-references inside
RN's implementations resolve correctly.

Identity caching via a WeakMap<Node, Proxy> is preserved so the same
underlying RN node always yields the same wrapped ref.

The selection polyfill is gated on the underlying property being
absent, so the day RN exposes the W3C selection API on TextInput the
polyfill self-disables.

RN 0.83 also tightened the Flow types around Animated.createAnimatedComponent
(now a single-type-arg generic) and made ImageProps / TextInputProps
exact. The Animated factory call sites are updated to the new
signature; the wide-spread of strict-dom's ReactNativeProps onto the
exact host components is suppressed with targeted $FlowFixMe
annotations (real follow-up tracked separately).

Adds 10 ref tests in tests/html/html-refs-test.native.js documenting
the contract: uppercase nodeName, getBoundingClientRect pass-through
at scale=1, getBoundingClientRect scaled when viewportScale != 1, the
DOM Node API pass-through (ownerDocument / getRootNode / childNodes /
children), identity stability of the strict ref across renders, and
the <img>.complete fallback (both when omitted and when provided).

Bundle size: native/index.js drops ~363 minified / ~69 brotli bytes.
Web build is byte-identical.
Bring apps/expo-app and apps/platform-tests onto a real RN >=0.82.0
runtime so they exercise the new useStrictDOMElement Proxy path
against the DOM Node APIs from the previous commit.

Note: there is no Expo SDK that pairs exactly with RN 0.82 (SDK 53 =
RN 0.79, SDK 54 ~= RN 0.81, SDK 55 = RN 0.83). SDK 55 / RN 0.83.6
still satisfies the library's >=0.82.0 peer dep and ships the same
DOM Node API surface, so it is the closest landing zone.

Co-traveling dependency versions (@expo/metro-runtime,
expo-build-properties, expo-status-bar, react-native-web, etc.) come
from `npx expo install --check` for SDK 55; no hand-rolled versions.

Also pin react / react-dom / react-test-renderer to ~19.2.0 across
the workspace root and the two library packages' devDependencies.
RN 0.82+ requires React >=19.1.1, and aligning the workspace devDeps
avoids a multiple-React-instances error in the jest suite that would
otherwise surface once the apps hoist React 19.2.x at the root.
@yaminyassin
Copy link
Copy Markdown
Author

yaminyassin commented May 13, 2026

Related follow-up: #477: switches strict-dom from the deep import react-native/Libraries/Text/TextAncestor to the public unstable_TextAncestorContext API exposed in facebook/react-native#52368. Logically depends on this PR (the bumped peer dep is what makes the public API guaranteed available). Once both land, the corresponding RN-side cleanup can delete the compatibility shim.

@martinbooth
Copy link
Copy Markdown
Contributor

Hey, thanks for this! It looks good, however can we use the object clone approach rather than the Proxy? I know that using a Proxy here is much more ergonomic than the way the code is currently written, but the performance of using Proxy is substantially slower than the existing code. Due to the very dynamic nature of a Proxy hermes (the js runtime most react native applications use) is unable to apply the optimization static code can benefit from

@yaminyassin
Copy link
Copy Markdown
Author

yaminyassin commented May 15, 2026

Restored the original approach and improved a couple of things!
Tell me what you think. i've already validated and profiled the behaviors on my devices and everything looks good 😊

RN 0.82 settled the DOM Node prototype hierarchy, so we can stop
cloning the host node. Object.create(node) makes the raw node the
wrapper's prototype; strict-dom defines its overrides on top;
everything else falls through. Symbol-keyed internals like
INSTANCE_HANDLE_KEY stay reachable through the chain, so RN's
prototype methods work when called on the wrapper.

Changes vs the previous clone:

* No descriptor snapshot. Reads stay in sync with the node.
* No try/catch fallback. defineProperty on a fresh object can't
  fail in normal use.
* getBoundingClientRect and the length getters only install when
  viewportScale isn't 1. Scale 1 skips them.
* nodeName is a value descriptor now, since tagName.toUpperCase()
  doesn't change for a given wrapper.
* writable: true removed from the value descriptors. DOM spec is
  read-only for nodeName, getBoundingClientRect and
  setSelectionRange, so strict-mode assignments throw now.
* configurable: true on every override.

instanceof still works: the chain is one link longer (wrapper to
node to ReactNativeElement.prototype) but the class prototype is
still on it. Ten tests in html-refs-test.native.js pass.
@yaminyassin yaminyassin force-pushed the rn-0.82-dom-node-apis branch from 8fbae4f to 7e32435 Compare May 15, 2026 14:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants