Skip to content

Type-safe props, events, and refs for react-strict-dom - #504

Merged
martinbooth merged 4 commits into
react:mainfrom
yaminyassin:flow-typesafety-hardening-v2
Jun 23, 2026
Merged

Type-safe props, events, and refs for react-strict-dom#504
martinbooth merged 4 commits into
react:mainfrom
yaminyassin:flow-typesafety-hardening-v2

Conversation

@yaminyassin

@yaminyassinyaminyassin commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Summary

This change replaces the $FlowFixMe suppressions with real types, moves the prop-building mutation out of the hook bodies so it stops fighting the React hook rules, and pins the result down with Flow tests.

This is a types-only change. It does not alter runtime behavior, so it can land on its own and be reviewed as such.

Motivation

$FlowFixMe is a checked-in promise to come back later. For the event props it had two costs:

  1. No checking on handler arguments. onClick={(e) => e.tpye} typed clean.
  2. No editor autocomplete on the event payload, so authors guessed field names
    or read the source.

The props are the public surface of the library. They are the right place to spend the type budget.

What changed

Event payloads are typed. A new StrictReactDOMEvents module holds the event shapes the native factories actually construct: change, input, key, click, and image load/error. Handlers whose runtime shape is defined by the platform (most pointer, mouse, touch, and clipboard events) use a single StrictOpaqueEventHandler instead of $FlowFixMe. The payload types are re-exported from the native and web entry points so consumers can annotate their own handlers.

Prop mutation moved out of the hooks. The native factories build their nativeProps by mutating an object (role defaults, the display:block emulation, the hidden polyfill). Doing that inside a hook body needed a react-rule-hook-mutation suppression. That logic now lives in plain helpers (applyViewProps, applyHtmlProps) that take the nativeProps and write to it directly, so the suppressions are gone.

HostInstance routes through the type boundary.types/renderer.native.js now exports HostInstance from react-native

How this improves DX

  • Event handlers are checked. A typo in a payload field or a wrong handler signature is a Flow error at the call site, not a runtime surprise.
  • Editors autocomplete the payload fields, so authors stop reading the source to find out what onChange hands them.
  • The payload types ship from the package entrypoints, so a consumer can write (event: StrictChangeEvent) => void and have it stay in sync with the library.
  • The hook bodies read as straight data flow now. The mutation helpers say what they own in their signatures instead of leaning on per-line suppressions.

Type safety in practice

A typo in a payload field is a Flow error where it is written, not a runtime undefined:

<html.inputonChange={(event)=>{setValue(event.target.valeu);// ^^^^^ Flow: property `valeu` is missing in StrictChangeEvent}}/>;

The click payload carries the modifier and position fields it actually has, so reaching past them is caught:

<html.divonClick={(event)=>{if(event.shiftKey)selectRange();// okevent.preventDefault();// okconsole.log(event.clientX);// ^^^^^^^ Flow: property `clientX` is missing (it is `pageX`)}}/>;

Key handlers get a stable key field across platforms:

<html.inputonKeyDown={(event)=>{if(event.key==='Enter')submit();}}/>;

Annotating a handler defined outside JSX uses the exported payload types, which stay in sync with the library:

import{html}from'react-strict-dom';importtype{StrictChangeEvent}from'react-strict-dom';functionhandleChange(event: StrictChangeEvent){setValue(event.target.value);}<html.inputonChange={handleChange}/>;

A handler whose signature does not match the prop is rejected, so a web payload shape cannot be assumed on a strict element:

// Flow: expected (event: StrictChangeEvent) => void<html.inputonChange={(event: SyntheticEvent<HTMLInputElement>)=>{}}/>;

The opaque handlers still pass the event through, but unknown forces a check before use rather than handing back any:

<html.divonPointerMove={(event)=>{// event: unknown — narrow before reading off itif(event!=null&&typeofevent==='object'&&'clientX'inevent){track(event.clientX);}}}/>;

Compat Table

means the native runtime wires the handler today; means the type exists and the event passes through, but no native module wires it yet.

HandlersNativeWeb intentNotes
onChange, onInput, onClick, onKeyDown, onLoad, onErrorre-synthesizedRSD builds a strict, narrowed payload: type + target.value, a stable key payload, or a normalized image load/error shape.
onPointer* (Down/Up/Move/Enter/Leave/Over/Out/Cancel), onGotPointerCapture, onLostPointerCapturepass-through (PointerEvent)Raw pointer payload forwarded.
onTouchStart/End/Move/Cancelpass-through (TouchEvent)Raw touch payload forwarded.
onMouseDown/Up/Enter/Leave/Over/Outpass-through (MouseEvent)Raw mouse payload forwarded.
onBlur, onFocuspass-through (FocusEvent)Raw focus payload forwarded.
onScroll (UIEvent), onSelectionChange (FormEvent)pass-throughRaw payload forwarded.
onAuxClick, onContextMenu, onMouseMove, onCopy, onCut, onPaste, onWheel, onKeyUp, onBeforeInput, onInvalid, onSelect, onFullscreenChange, onFullscreenError, onFocusIn, onFocusOutpass-throughForwarded; native runtime not wired yet.

Notes for reviewers

A few calls I would like a second opinion on:

  1. The exported payload types (StrictChangeEvent, StrictClickEvent...) are new public API. If the names or shapes are wrong, now is the time to change them.
  2. Should the re-synthesized and pass-through handlers get concrete payload types now, or stay opaque until a native implementation wires each one?

@github-actions

Copy link
Copy Markdown

workflow: benchmarks/size

Comparison of minified (terser) and compressed (brotli) size results, measured in bytes. Smaller is better.

ResultsBasePatchRatio
react-strict-dom/dist/web/index.js
· compressed3,2513,2511.00
· minified10,37510,3751.00
react-strict-dom/dist/web/runtime.js
· compressed1,6451,6451.00
· minified4,1314,1311.00
react-strict-dom/dist/native/index.js
· compressed16,61816,7181.01+
· minified64,62665,0071.01+
react-strict-animated/dist/web/index.js
· compressed6,8616,8611.00
· minified23,48623,4861.00
react-strict-animated/dist/native/index.js
· compressed7977971.00
· minified2,5182,5181.00

@github-actions

github-actionsBot commented Jun 15, 2026

Copy link
Copy Markdown

workflow: benchmarks/perf (native)

Comparison of performance test results, measured in operations per second. Larger is better.

ResultsBasePatchRatio
css.create
· small1,147,9651,141,5870.99-
· small with units496,405485,1860.98-
· small with variables666,058669,9021.01+
· several small355,112346,3990.98-
· large201,309195,8750.97-
· large with polyfills147,568145,8910.99-
· complex101,224100,0170.99-
· unsupported208,848207,5960.99-
css.createTheme
· simple theme224,878226,2431.01+
· polyfill theme213,851214,8051.00+

Yamin Yassin added 4 commits June 17, 2026 15:49
The event props on the Strict* prop types were all $FlowFixMe, so handlers
got no checking and authors got no autocomplete. Add StrictReactDOMEvents
with the event shapes the native factories actually build (change, input,
key, click, image load/error) and a StrictOpaqueEventHandler for the
pass-through handlers whose runtime shape is platform-specific.
Wire those through StrictReactDOMProps and the button/image/input/select/
textarea prop types, and re-export the payload types from the native and
web entrypoints so consumers can annotate their own handlers.
Type the props the native modules build and drop the suppressions that were
hiding the gaps. The prop mutation that the factories do (role defaults,
display:block emulation, the hidden polyfill) moves out of the hook bodies
into plain helpers so it can write to the caller-owned nativeProps without
tripping react-rule-hook-mutation.
Route HostInstance through the renderer.native type boundary rather than
importing it from 'react-native' in each module, matching how the runtime
already funnels RN access through the local wrapper.
Two suppressions are left in place on purpose: the skew check in
useStyleTransition and the provideInheritableStyle comparison both sit on top
of latent behavior bugs, so the fixes (and the type cleanup that goes with
them) land in a separate PR rather than riding along with a types-only change.
The default style table cast every entry with $FlowFixMe[incompatible-type];
the types line up now, so the casts are gone. For the debug-style object that
stylex.create does not generate, use a small local DebugCompiledStyle type
instead of an unclear-type cast.
The one suppression left on validateStrictProps stays, with a note on why:
typing the argument precisely would block the in-place delete of invalid keys
that the function relies on.
Now that the props are typed, pin the behaviour down. refs-and-props covers
the host-element ref types and the props that should be accepted;
expected-errors locks in the misuse Flow has to reject, so a future loosening
of the types fails here instead of slipping through. Extend html-types-match
with the new event payload types.
@yaminyassin
yaminyassinforce-pushed the flow-typesafety-hardening-v2 branch from d1c714d to 4391699CompareJune 17, 2026 15:06
@martinbooth
martinbooth merged commit c877f5c into react:mainJun 23, 2026
7 checks passed
@yaminyassin

Copy link
Copy Markdown
ContributorAuthor

@martinbooth can we get a patch release with this change?

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@yaminyassin@martinbooth