Skip to content

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

Open
yaminyassin wants to merge 4 commits into
react:mainfrom
yaminyassin:flow-typesafety-hardening-v2
Open

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

Conversation

@yaminyassin

@yaminyassin yaminyassin 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.input
  onChange={(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.div
  onClick={(event) => {
    if (event.shiftKey) selectRange();   // ok
    event.preventDefault();              // ok
    console.log(event.clientX);
    //                ^^^^^^^ Flow: property `clientX` is missing (it is `pageX`)
  }}
/>;

Key handlers get a stable key field across platforms:

<html.input
  onKeyDown={(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';
import type { StrictChangeEvent } from 'react-strict-dom';

function handleChange(event: StrictChangeEvent) {
  setValue(event.target.value);
}

<html.input onChange={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.input onChange={(event: SyntheticEvent<HTMLInputElement>) => {}} />;

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

<html.div
  onPointerMove={(event) => {
    // event: unknown — narrow before reading off it
    if (event != null && typeof event === 'object' && 'clientX' in event) {
      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.

Handlers Native Web intent Notes
onChange, onInput, onClick, onKeyDown, onLoad, onError re-synthesized RSD 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, onLostPointerCapture pass-through (PointerEvent) Raw pointer payload forwarded.
onTouchStart/End/Move/Cancel pass-through (TouchEvent) Raw touch payload forwarded.
onMouseDown/Up/Enter/Leave/Over/Out pass-through (MouseEvent) Raw mouse payload forwarded.
onBlur, onFocus pass-through (FocusEvent) Raw focus payload forwarded.
onScroll (UIEvent), onSelectionChange (FormEvent) pass-through Raw payload forwarded.
onAuxClick, onContextMenu, onMouseMove, onCopy, onCut, onPaste, onWheel, onKeyUp, onBeforeInput, onInvalid, onSelect, onFullscreenChange, onFullscreenError, onFocusIn, onFocusOut pass-through Forwarded; 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?

@meta-cla meta-cla Bot added the cla signed label Jun 15, 2026
@github-actions

Copy link
Copy Markdown

workflow: benchmarks/size

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

Results Base Patch Ratio
react-strict-dom/dist/web/index.js
· compressed 3,251 3,251 1.00
· minified 10,375 10,375 1.00
react-strict-dom/dist/web/runtime.js
· compressed 1,645 1,645 1.00
· minified 4,131 4,131 1.00
react-strict-dom/dist/native/index.js
· compressed 16,618 16,718 1.01 +
· minified 64,626 65,007 1.01 +
react-strict-animated/dist/web/index.js
· compressed 6,861 6,861 1.00
· minified 23,486 23,486 1.00
react-strict-animated/dist/native/index.js
· compressed 797 797 1.00
· minified 2,518 2,518 1.00

@github-actions

github-actions Bot 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.

Results Base Patch Ratio
css.create
· small 1,147,965 1,141,587 0.99 -
· small with units 496,405 485,186 0.98 -
· small with variables 666,058 669,902 1.01 +
· several small 355,112 346,399 0.98 -
· large 201,309 195,875 0.97 -
· large with polyfills 147,568 145,891 0.99 -
· complex 101,224 100,017 0.99 -
· unsupported 208,848 207,596 0.99 -
css.createTheme
· simple theme 224,878 226,243 1.01 +
· polyfill theme 213,851 214,805 1.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 yaminyassin force-pushed the flow-typesafety-hardening-v2 branch from d1c714d to 4391699 Compare June 17, 2026 15:06
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.

1 participant