From fca9d93f4d57272395bf1d0f2b4b07d457d10cd0 Mon Sep 17 00:00:00 2001 From: Yamin Yassin Date: Mon, 15 Jun 2026 12:09:01 +0100 Subject: [PATCH 1/4] Type event-handler props instead of $FlowFixMe 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. --- packages/react-strict-dom/src/native/index.js | 9 ++ .../src/types/StrictReactDOMButtonProps.js | 4 +- .../src/types/StrictReactDOMEvents.js | 56 +++++++++++ .../src/types/StrictReactDOMImageProps.js | 8 +- .../src/types/StrictReactDOMInputProps.js | 17 ++-- .../src/types/StrictReactDOMProps.js | 92 +++++++++---------- .../src/types/StrictReactDOMSelectProps.js | 15 ++- .../src/types/StrictReactDOMTextAreaProps.js | 17 ++-- packages/react-strict-dom/src/web/index.js | 9 ++ 9 files changed, 155 insertions(+), 72 deletions(-) create mode 100644 packages/react-strict-dom/src/types/StrictReactDOMEvents.js diff --git a/packages/react-strict-dom/src/native/index.js b/packages/react-strict-dom/src/native/index.js index e4b64e96..8906cce1 100644 --- a/packages/react-strict-dom/src/native/index.js +++ b/packages/react-strict-dom/src/native/index.js @@ -40,6 +40,15 @@ type ProviderProps = Readonly<{ }>; export type { StaticStyles, StyleTheme, StyleVars, Styles, StylesWithout }; +export type { + StrictChangeEvent, + StrictClickEvent, + StrictImageErrorEvent, + StrictImageLoadEvent, + StrictInputEvent, + StrictKeyEvent, + StrictOpaqueEventHandler +} from '../types/StrictReactDOMEvents'; function ThemeProvider(props: ProviderProps): React.Node { const { children, customProperties } = props; diff --git a/packages/react-strict-dom/src/types/StrictReactDOMButtonProps.js b/packages/react-strict-dom/src/types/StrictReactDOMButtonProps.js index 916ed0c3..1c4f87e8 100644 --- a/packages/react-strict-dom/src/types/StrictReactDOMButtonProps.js +++ b/packages/react-strict-dom/src/types/StrictReactDOMButtonProps.js @@ -9,8 +9,8 @@ import type { StrictReactDOMProps } from './StrictReactDOMProps'; -export type StrictReactDOMButtonProps = { +export type StrictReactDOMButtonProps = Readonly<{ ...StrictReactDOMProps, disabled?: ?boolean, type?: ?('button' | 'submit') -}; +}>; diff --git a/packages/react-strict-dom/src/types/StrictReactDOMEvents.js b/packages/react-strict-dom/src/types/StrictReactDOMEvents.js new file mode 100644 index 00000000..e67b50db --- /dev/null +++ b/packages/react-strict-dom/src/types/StrictReactDOMEvents.js @@ -0,0 +1,56 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +/** + * Event payloads shared by the web and native prop types. The concrete payloads + * are the cross-platform subset the native factories construct (a strict subset + * of web's `SyntheticEvent`); `StrictOpaqueEventHandler` is for pass-through + * handlers whose runtime shape differs per platform. + */ + +export type StrictChangeEvent = Readonly<{ + target: Readonly<{ value: string }>, + type: 'change' +}>; + +export type StrictInputEvent = Readonly<{ + target: Readonly<{ value: string }>, + type: 'input' +}>; + +// Platform extras are permitted. +export type StrictKeyEvent = Readonly<{ key: string, type: ?string, ... }>; + +export type StrictClickEvent = Readonly<{| + altKey: boolean, + button: number, + ctrlKey: boolean, + defaultPrevented: boolean, + getModifierState: (key: string) => boolean, + metaKey: boolean, + pageX: number, + pageY: number, + preventDefault: () => void, + shiftKey: boolean, + stopPropagation: () => void, + type: 'click' +|}>; + +export type StrictImageLoadEvent = Readonly<{ + target: Readonly<{ naturalHeight: ?number, naturalWidth: ?number }>, + type: 'load' +}>; + +export type StrictImageErrorEvent = Readonly<{ + type: 'error' +}>; + +// Pass-through handlers receive the raw platform event (DOM event on web, nested +// RN synthetic event on native), so the param is `unknown`. +export type StrictOpaqueEventHandler = (event: unknown) => void; diff --git a/packages/react-strict-dom/src/types/StrictReactDOMImageProps.js b/packages/react-strict-dom/src/types/StrictReactDOMImageProps.js index 41525beb..12c4f817 100644 --- a/packages/react-strict-dom/src/types/StrictReactDOMImageProps.js +++ b/packages/react-strict-dom/src/types/StrictReactDOMImageProps.js @@ -8,6 +8,10 @@ */ import type { StrictReactDOMProps } from './StrictReactDOMProps'; +import type { + StrictImageErrorEvent, + StrictImageLoadEvent +} from './StrictReactDOMEvents'; export type StrictReactDOMImageProps = Readonly<{ ...StrictReactDOMProps, @@ -18,8 +22,8 @@ export type StrictReactDOMImageProps = Readonly<{ fetchPriority?: ?('high' | 'low' | 'auto'), height?: number, loading?: ?('eager' | 'lazy'), - onError?: $FlowFixMe, - onLoad?: $FlowFixMe, + onError?: (event: StrictImageErrorEvent) => void, + onLoad?: (event: StrictImageLoadEvent) => void, referrerPolicy?: ?( | 'no-referrer' | 'no-referrer-when-downgrade' diff --git a/packages/react-strict-dom/src/types/StrictReactDOMInputProps.js b/packages/react-strict-dom/src/types/StrictReactDOMInputProps.js index 25fda721..eae8982a 100644 --- a/packages/react-strict-dom/src/types/StrictReactDOMInputProps.js +++ b/packages/react-strict-dom/src/types/StrictReactDOMInputProps.js @@ -8,6 +8,11 @@ */ import type { AutoComplete, StrictReactDOMProps } from './StrictReactDOMProps'; +import type { + StrictChangeEvent, + StrictInputEvent, + StrictOpaqueEventHandler +} from './StrictReactDOMEvents'; export type StrictReactDOMInputProps = Readonly<{ ...StrictReactDOMProps, @@ -22,12 +27,12 @@ export type StrictReactDOMInputProps = Readonly<{ minLength?: ?number, multiple?: ?boolean, name?: ?string, - onBeforeInput?: $FlowFixMe, - onChange?: $FlowFixMe, - onInput?: $FlowFixMe, - onInvalid?: $FlowFixMe, - onSelect?: $FlowFixMe, - onSelectionChange?: $FlowFixMe, + onBeforeInput?: StrictOpaqueEventHandler, + onChange?: (event: StrictChangeEvent) => void, + onInput?: (event: StrictInputEvent) => void, + onInvalid?: StrictOpaqueEventHandler, + onSelect?: StrictOpaqueEventHandler, + onSelectionChange?: StrictOpaqueEventHandler, placeholder?: ?Stringish, readOnly?: ?boolean, required?: ?boolean, diff --git a/packages/react-strict-dom/src/types/StrictReactDOMProps.js b/packages/react-strict-dom/src/types/StrictReactDOMProps.js index ff3ca610..8c979322 100644 --- a/packages/react-strict-dom/src/types/StrictReactDOMProps.js +++ b/packages/react-strict-dom/src/types/StrictReactDOMProps.js @@ -8,6 +8,11 @@ */ import type { Styles } from './styles'; +import type { + StrictClickEvent, + StrictKeyEvent, + StrictOpaqueEventHandler +} from './StrictReactDOMEvents'; // Excludes all abstract roles that should not be used by authors. type AriaRole = @@ -173,21 +178,6 @@ type SyntheticEvent<+T> = Readonly<{| |}>; */ -type StrictClickEvent = Readonly<{| - altKey: boolean, - button: number, - ctrlKey: boolean, - defaultPrevented: boolean, - getModifierState: (key: string) => boolean, - metaKey: boolean, - pageX: number, - pageY: number, - preventDefault: () => void, - shiftKey: boolean, - stopPropagation: () => void, - type: 'click' -|}>; - export type StrictReactDOMProps = Readonly<{ ...ReactStrictDOMDataProps, @@ -241,43 +231,43 @@ export type StrictReactDOMProps = Readonly<{ 'aria-valuetext'?: ?Stringish, // Event props - onAuxClick?: $FlowFixMe, - onBlur?: $FlowFixMe, + onAuxClick?: StrictOpaqueEventHandler, + onBlur?: StrictOpaqueEventHandler, onClick?: (event: StrictClickEvent) => void, - onContextMenu?: $FlowFixMe, - onCopy?: $FlowFixMe, - onCut?: $FlowFixMe, - onFocus?: $FlowFixMe, - onFocusIn?: $FlowFixMe, - onFocusOut?: $FlowFixMe, - onFullscreenChange?: $FlowFixMe, - onFullscreenError?: $FlowFixMe, - onGotPointerCapture?: $FlowFixMe, - onKeyDown?: (event: Readonly<{ key: string, type: ?string, ... }>) => void, - onKeyUp?: $FlowFixMe, - onLostPointerCapture?: $FlowFixMe, - onPaste?: $FlowFixMe, - onPointerCancel?: $FlowFixMe, - onPointerDown?: $FlowFixMe, - onPointerEnter?: $FlowFixMe, - onPointerLeave?: $FlowFixMe, - onPointerMove?: $FlowFixMe, - onPointerOut?: $FlowFixMe, - onPointerOver?: $FlowFixMe, - onPointerUp?: $FlowFixMe, - onScroll?: $FlowFixMe, - onWheel?: $FlowFixMe, - onMouseDown?: $FlowFixMe, // TEMP - onMouseEnter?: $FlowFixMe, // TEMP - onMouseLeave?: $FlowFixMe, // TEMP - onMouseMove?: $FlowFixMe, // TEMP - onMouseOut?: $FlowFixMe, // TEMP - onMouseOver?: $FlowFixMe, // TEMP - onMouseUp?: $FlowFixMe, // TEMP - onTouchCancel?: $FlowFixMe, // TEMP - onTouchStart?: $FlowFixMe, // TEMP - onTouchEnd?: $FlowFixMe, // TEMP - onTouchMove?: $FlowFixMe, // TEMP + onContextMenu?: StrictOpaqueEventHandler, + onCopy?: StrictOpaqueEventHandler, + onCut?: StrictOpaqueEventHandler, + onFocus?: StrictOpaqueEventHandler, + onFocusIn?: StrictOpaqueEventHandler, + onFocusOut?: StrictOpaqueEventHandler, + onFullscreenChange?: StrictOpaqueEventHandler, + onFullscreenError?: StrictOpaqueEventHandler, + onGotPointerCapture?: StrictOpaqueEventHandler, + onKeyDown?: (event: StrictKeyEvent) => void, + onKeyUp?: (event: StrictKeyEvent) => void, + onLostPointerCapture?: StrictOpaqueEventHandler, + onPaste?: StrictOpaqueEventHandler, + onPointerCancel?: StrictOpaqueEventHandler, + onPointerDown?: StrictOpaqueEventHandler, + onPointerEnter?: StrictOpaqueEventHandler, + onPointerLeave?: StrictOpaqueEventHandler, + onPointerMove?: StrictOpaqueEventHandler, + onPointerOut?: StrictOpaqueEventHandler, + onPointerOver?: StrictOpaqueEventHandler, + onPointerUp?: StrictOpaqueEventHandler, + onScroll?: StrictOpaqueEventHandler, + onWheel?: StrictOpaqueEventHandler, + onMouseDown?: StrictOpaqueEventHandler, + onMouseEnter?: StrictOpaqueEventHandler, + onMouseLeave?: StrictOpaqueEventHandler, + onMouseMove?: StrictOpaqueEventHandler, + onMouseOut?: StrictOpaqueEventHandler, + onMouseOver?: StrictOpaqueEventHandler, + onMouseUp?: StrictOpaqueEventHandler, + onTouchCancel?: StrictOpaqueEventHandler, + onTouchStart?: StrictOpaqueEventHandler, + onTouchEnd?: StrictOpaqueEventHandler, + onTouchMove?: StrictOpaqueEventHandler, // Other autoCapitalize?: ?('none' | 'sentences' | 'words' | 'characters'), diff --git a/packages/react-strict-dom/src/types/StrictReactDOMSelectProps.js b/packages/react-strict-dom/src/types/StrictReactDOMSelectProps.js index 04a4fb53..777f4b8a 100644 --- a/packages/react-strict-dom/src/types/StrictReactDOMSelectProps.js +++ b/packages/react-strict-dom/src/types/StrictReactDOMSelectProps.js @@ -8,6 +8,11 @@ */ import type { AutoComplete, StrictReactDOMProps } from './StrictReactDOMProps'; +import type { + StrictChangeEvent, + StrictInputEvent, + StrictOpaqueEventHandler +} from './StrictReactDOMEvents'; export type StrictReactDOMSelectProps = Readonly<{ ...StrictReactDOMProps, @@ -16,10 +21,10 @@ export type StrictReactDOMSelectProps = Readonly<{ multiple?: ?boolean, name?: ?string, required?: ?boolean, - onBeforeInput?: $FlowFixMe, - onChange?: $FlowFixMe, - onInput?: $FlowFixMe, - onInvalid?: $FlowFixMe, - onSelect?: $FlowFixMe, + onBeforeInput?: StrictOpaqueEventHandler, + onChange?: (event: StrictChangeEvent) => void, + onInput?: (event: StrictInputEvent) => void, + onInvalid?: StrictOpaqueEventHandler, + onSelect?: StrictOpaqueEventHandler, value?: ?(Stringish | Array) }>; diff --git a/packages/react-strict-dom/src/types/StrictReactDOMTextAreaProps.js b/packages/react-strict-dom/src/types/StrictReactDOMTextAreaProps.js index 313a81eb..191ad252 100644 --- a/packages/react-strict-dom/src/types/StrictReactDOMTextAreaProps.js +++ b/packages/react-strict-dom/src/types/StrictReactDOMTextAreaProps.js @@ -8,6 +8,11 @@ */ import type { AutoComplete, StrictReactDOMProps } from './StrictReactDOMProps'; +import type { + StrictChangeEvent, + StrictInputEvent, + StrictOpaqueEventHandler +} from './StrictReactDOMEvents'; export type StrictReactDOMTextAreaProps = Readonly<{ ...StrictReactDOMProps, @@ -17,12 +22,12 @@ export type StrictReactDOMTextAreaProps = Readonly<{ maxLength?: ?number, minLength?: ?number, name?: ?string, - onBeforeInput?: $FlowFixMe, - onChange?: $FlowFixMe, - onInput?: $FlowFixMe, - onInvalid?: $FlowFixMe, - onSelect?: $FlowFixMe, - onSelectionChange?: $FlowFixMe, + onBeforeInput?: StrictOpaqueEventHandler, + onChange?: (event: StrictChangeEvent) => void, + onInput?: (event: StrictInputEvent) => void, + onInvalid?: StrictOpaqueEventHandler, + onSelect?: StrictOpaqueEventHandler, + onSelectionChange?: StrictOpaqueEventHandler, placeholder?: ?Stringish, readOnly?: ?boolean, required?: ?boolean, diff --git a/packages/react-strict-dom/src/web/index.js b/packages/react-strict-dom/src/web/index.js index 36fe2c9f..dc5274b7 100644 --- a/packages/react-strict-dom/src/web/index.js +++ b/packages/react-strict-dom/src/web/index.js @@ -24,5 +24,14 @@ type Styles = StyleXStyles; type StylesWithout = StyleXStylesWithout; export type { StaticStyles, StyleTheme, StyleVars, Styles, StylesWithout }; +export type { + StrictChangeEvent, + StrictClickEvent, + StrictImageErrorEvent, + StrictImageLoadEvent, + StrictInputEvent, + StrictKeyEvent, + StrictOpaqueEventHandler +} from '../types/StrictReactDOMEvents'; export { css, html }; From 07b3884d12d19932296b7bdc1cac80a7950f10bf Mon Sep 17 00:00:00 2001 From: Yamin Yassin Date: Mon, 15 Jun 2026 12:23:30 +0100 Subject: [PATCH 2/4] Clear out the $FlowFixMe in the native renderer 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. --- .../react-strict-dom/src/native/compat.js | 43 +- .../src/native/css/CSSLengthUnitValue.js | 25 +- .../react-strict-dom/src/native/css/index.js | 2 +- .../src/native/modules/TextString.js | 2 + .../modules/createStrictDOMComponent.js | 213 +++++----- .../modules/createStrictDOMImageComponent.js | 147 +++---- .../modules/createStrictDOMTextComponent.js | 133 ++++--- .../createStrictDOMTextInputComponent.js | 371 +++++++++--------- .../src/native/modules/shallowEqual.js | 12 +- .../src/native/modules/useNativeProps.js | 174 ++++---- .../src/native/modules/useStrictDOMElement.js | 84 ++-- .../src/native/modules/useStyleProps.js | 39 +- .../src/types/renderer.native.js | 25 +- 13 files changed, 675 insertions(+), 595 deletions(-) diff --git a/packages/react-strict-dom/src/native/compat.js b/packages/react-strict-dom/src/native/compat.js index a0a820ac..dc7f1fb3 100644 --- a/packages/react-strict-dom/src/native/compat.js +++ b/packages/react-strict-dom/src/native/compat.js @@ -8,6 +8,10 @@ */ import type { StrictProps } from '../types/StrictProps'; +import type { StrictReactDOMProps } from '../types/StrictReactDOMProps'; +import type { StrictReactDOMImageProps } from '../types/StrictReactDOMImageProps'; +import type { StrictReactDOMInputProps } from '../types/StrictReactDOMInputProps'; +import type { StrictReactDOMTextAreaProps } from '../types/StrictReactDOMTextAreaProps'; import * as React from 'react'; @@ -45,14 +49,38 @@ type StrictPropsOnlyCompat = { children: (nativeProps: T) => React.Node }; -const StrictText = createStrictText('span', defaultProps) as $FlowFixMe; -const StrictInput = createStrictTextInput('input', defaultProps) as $FlowFixMe; -const StrictTextArea = createStrictTextInput( +const StrictText: component( + ref?: React.RefSetter, + ...StrictReactDOMProps +) = createStrictText( + 'span', + defaultProps +); +const StrictInput: component( + ref?: React.RefSetter, + ...StrictReactDOMInputProps +) = createStrictTextInput( + 'input', + defaultProps +); +const StrictTextArea: component( + ref?: React.RefSetter, + ...StrictReactDOMTextAreaProps +) = createStrictTextInput( 'textarea', defaultProps -) as $FlowFixMe; -const StrictImage = createStrictImage('img', defaultProps) as $FlowFixMe; -const Strict = createStrict('div', defaultProps) as $FlowFixMe; +); +const StrictImage: component( + ref?: React.RefSetter, + ...StrictReactDOMImageProps +) = createStrictImage( + 'img', + defaultProps +); +const Strict: component( + ref?: React.RefSetter, + ...StrictReactDOMProps +) = createStrict('div', defaultProps); component Native(...htmlProps: StrictPropsOnlyCompat) { const { as, ...rest } = htmlProps; @@ -61,7 +89,8 @@ component Native(...htmlProps: StrictPropsOnlyCompat) { ' requires the "children" prop to be a function.' ); } - let Component = Strict; + // Untyped: dispatches across components with divergent prop types. + let Component = Strict as $FlowFixMe; if (as === 'img') { Component = StrictImage; } else if (as === 'input') { diff --git a/packages/react-strict-dom/src/native/css/CSSLengthUnitValue.js b/packages/react-strict-dom/src/native/css/CSSLengthUnitValue.js index 86a9b823..eef69fcf 100644 --- a/packages/react-strict-dom/src/native/css/CSSLengthUnitValue.js +++ b/packages/react-strict-dom/src/native/css/CSSLengthUnitValue.js @@ -9,10 +9,22 @@ import { warnMsg } from '../../shared/logUtils'; -const LENGTH_REGEX = /^(-?[0-9]*[.]?[0-9]+)(em|px|rem|vh|vmax|vmin|vw)$/; - type CSSLengthUnitType = 'em' | 'px' | 'rem' | 'vh' | 'vmax' | 'vmin' | 'vw'; +const CSS_LENGTH_UNITS: ReadonlyArray = [ + 'em', + 'px', + 'rem', + 'vh', + 'vmax', + 'vmin', + 'vw' +]; + +const LENGTH_REGEX = new RegExp( + `^(-?[0-9]*[.]?[0-9]+)(${CSS_LENGTH_UNITS.join('|')})$` +); + type ResolvePixelValueOptions = Readonly<{ fontScale: number | void, inheritedFontSize: ?number, @@ -36,8 +48,15 @@ export class CSSLengthUnitValue { memoizedValues.set(input, null); return null; } + const value = match[1]; - const unit: $FlowFixMe = match[2]; + const rawUnit = match[2]; + const unit = CSS_LENGTH_UNITS.find((u) => u === rawUnit); + if (unit == null) { + memoizedValues.set(input, null); + return null; + } + const parsedFloat: number = parseFloat(value); const cssLengthUnitValue = new CSSLengthUnitValue(parsedFloat, unit); memoizedValues.set(input, cssLengthUnitValue); diff --git a/packages/react-strict-dom/src/native/css/index.js b/packages/react-strict-dom/src/native/css/index.js index dc2b7dfe..bc90fe09 100644 --- a/packages/react-strict-dom/src/native/css/index.js +++ b/packages/react-strict-dom/src/native/css/index.js @@ -301,7 +301,7 @@ function resolveStyle( export function props( this: ResolveStyleOptions, - ...style: ReadonlyArray + ...style: ReadonlyArray ): ReactNativeProps { const options = this; diff --git a/packages/react-strict-dom/src/native/modules/TextString.js b/packages/react-strict-dom/src/native/modules/TextString.js index 9e8ef04a..9fd2f196 100644 --- a/packages/react-strict-dom/src/native/modules/TextString.js +++ b/packages/react-strict-dom/src/native/modules/TextString.js @@ -32,6 +32,8 @@ export function TextString(props: Props): React.Node { }); return ( + // strict-dom's wide ReactNativeProps spreads onto RN's exact TextProps; + // harmless extras are ignored at runtime. // $FlowFixMe[incompatible-type] ); diff --git a/packages/react-strict-dom/src/native/modules/createStrictDOMComponent.js b/packages/react-strict-dom/src/native/modules/createStrictDOMComponent.js index 419eca47..3290355c 100644 --- a/packages/react-strict-dom/src/native/modules/createStrictDOMComponent.js +++ b/packages/react-strict-dom/src/native/modules/createStrictDOMComponent.js @@ -7,7 +7,11 @@ * @flow strict-local */ -import type { ReactNativeProps } from '../../types/renderer.native'; +import type { CallbackRef } from '../../types/react'; +import type { + HostInstance, + ReactNativeProps +} from '../../types/renderer.native'; import type { StrictProps as StrictPropsOriginal } from '../../types/StrictProps'; import * as React from 'react'; @@ -30,6 +34,99 @@ const AnimatedPressable = ReactNative.Animated.createAnimatedComponent( ReactNative.Pressable ); +// Plain (non-hook) helper so it can mutate the caller-owned `nativeProps` +// without a defensive copy (a hook may not mutate its own return value). +function applyViewProps( + nativeProps: ReactNativeProps, + tagName: string, + isPressable: boolean, + disabled: boolean, + elementRef: CallbackRef, + displayInsideValue: 'flow' | 'flex' +): 'flow' | 'flex' { + // Tag-specific props + + if (tagName === 'button') { + nativeProps.role ??= 'button'; + } else if (tagName === 'header') { + nativeProps.role ??= 'header'; + } else if (tagName === 'li') { + nativeProps.role ??= 'listitem'; + } else if (tagName === 'ol' || tagName === 'ul') { + nativeProps.role ??= 'list'; + } + + // Component-specific props + + if (isPressable && disabled) { + nativeProps.disabled = true; + nativeProps.focusable = false; + } + + nativeProps.ref = elementRef; + + // Workaround: React Native doesn't support raw text children of View + // Sometimes we can auto-fix this + if (typeof nativeProps.children === 'string') { + nativeProps.children = ; + } + + // Polyfill for default of "display:block" + // which implies "displayInside:flow" + let nextDisplayInsideValue: 'flow' | 'flex' = 'flow'; + const displayValue = nativeProps.style.display; + + if (__DEV__) { + const nativeStyle = nativeProps.style; + if (displayInsideValue !== 'flex') { + // Error message if the element is not a flex child but tries to use flex + ['flex', 'flexBasis', 'flexGrow', 'flexShrink'].forEach((styleProp) => { + const value = nativeStyle[styleProp]; + if (value != null) { + errorMsg( + `"display:flex" is required on the parent for "${styleProp}" to have an effect.` + ); + } + }); + // Error message if the element is not a flex child but tries to use + // zIndex without non-static position + if (nativeStyle.zIndex != null && nativeStyle.position === 'static') { + errorMsg( + '"position:static" prevents "zIndex" from having an effect. Try setting "position" to something other than "static".' + ); + } + } + } + + if (displayValue === 'flex') { + nextDisplayInsideValue = 'flex'; + nativeProps.style.alignContent ??= 'stretch'; + nativeProps.style.alignItems ??= 'stretch'; + nativeProps.style.flexBasis ??= 'auto'; + nativeProps.style.flexDirection ??= 'row'; + nativeProps.style.flexShrink ??= 1; + nativeProps.style.flexWrap ??= 'nowrap'; + nativeProps.style.justifyContent ??= 'flex-start'; + } else if (displayValue === 'block' && displayInsideValue === 'flow') { + // Force the block emulation styles + nextDisplayInsideValue = 'flow'; + nativeProps.style.alignItems = 'stretch'; + nativeProps.style.display = 'flex'; + nativeProps.style.flexBasis = 'auto'; + nativeProps.style.flexDirection = 'column'; + nativeProps.style.flexShrink = 0; + nativeProps.style.flexWrap = 'nowrap'; + nativeProps.style.justifyContent = 'flex-start'; + } + + if (displayInsideValue === 'flex') { + // flex child should not shrink by default + nativeProps.style.flexShrink ??= 1; + } + + return nextDisplayInsideValue; +} + export function createStrictDOMComponent( tagName: string, defaultProps?: P @@ -49,6 +146,7 @@ export function createStrictDOMComponent( : ReactNative.ViewNativeComponent; const elementRef = useStrictDOMElement(ref, { tagName }); const hasTextAncestor = React.useContext(ReactNative.TextAncestorContext); + const displayInsideValue = useDisplayInside(); /** * Resolve global HTML and style props @@ -71,111 +169,14 @@ export function createStrictDOMComponent( NativeComponent = ReactNative.Pressable; } - // Tag-specific props - - if (tagName === 'button') { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.role ??= 'button'; - } else if (tagName === 'header') { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.role ??= 'header'; - } else if (tagName === 'li') { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.role ??= 'listitem'; - } else if (tagName === 'ol' || tagName === 'ul') { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.role ??= 'list'; - } - - // Component-specific props - - if (NativeComponent === ReactNative.Pressable) { - if (props.disabled === true) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.disabled = true; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.focusable = false; - } - } - - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.ref = elementRef; - - // Workaround: React Native doesn't support raw text children of View - // Sometimes we can auto-fix this - if (typeof nativeProps.children === 'string') { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.children = ; - } - - // Polyfill for default of "display:block" - // which implies "displayInside:flow" - let nextDisplayInsideValue: 'flow' | 'flex' = 'flow'; - const displayInsideValue = useDisplayInside(); - const displayValue = nativeProps.style.display; - - if (__DEV__) { - const nativeStyle = nativeProps.style; - if (displayInsideValue !== 'flex') { - // Error message if the element is not a flex child but tries to use flex - ['flex', 'flexBasis', 'flexGrow', 'flexShrink'].forEach((styleProp) => { - const value = nativeStyle[styleProp]; - if (value != null) { - errorMsg( - `"display:flex" is required on the parent for "${styleProp}" to have an effect.` - ); - } - }); - // Error message if the element is not a flex child but tries to use - // zIndex without non-static position - if (nativeStyle.zIndex != null && nativeStyle.position === 'static') { - errorMsg( - '"position:static" prevents "zIndex" from having an effect. Try setting "position" to something other than "static".' - ); - } - } - } - - if (displayValue === 'flex') { - nextDisplayInsideValue = 'flex'; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style.alignContent ??= 'stretch'; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style.alignItems ??= 'stretch'; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style.flexBasis ??= 'auto'; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style.flexDirection ??= 'row'; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style.flexShrink ??= 1; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style.flexWrap ??= 'nowrap'; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style.justifyContent ??= 'flex-start'; - } else if (displayValue === 'block' && displayInsideValue === 'flow') { - // Force the block emulation styles - nextDisplayInsideValue = 'flow'; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style.alignItems = 'stretch'; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style.display = 'flex'; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style.flexBasis = 'auto'; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style.flexDirection = 'column'; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style.flexShrink = 0; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style.flexWrap = 'nowrap'; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style.justifyContent = 'flex-start'; - } - - if (displayInsideValue === 'flex') { - // flex child should not shrink by default - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style.flexShrink ??= 1; - } + const nextDisplayInsideValue = applyViewProps( + nativeProps, + tagName, + NativeComponent === ReactNative.Pressable, + props.disabled === true, + elementRef, + displayInsideValue + ); // Use Animated components if necessary if (nativeProps.animated === true) { diff --git a/packages/react-strict-dom/src/native/modules/createStrictDOMImageComponent.js b/packages/react-strict-dom/src/native/modules/createStrictDOMImageComponent.js index 8dd285a5..fc0fbbbf 100644 --- a/packages/react-strict-dom/src/native/modules/createStrictDOMImageComponent.js +++ b/packages/react-strict-dom/src/native/modules/createStrictDOMImageComponent.js @@ -7,6 +7,11 @@ * @flow strict-local */ +import type { CallbackRef } from '../../types/react'; +import type { + HostInstance, + ReactNativeProps +} from '../../types/renderer.native'; import type { StrictReactDOMImageProps } from '../../types/StrictReactDOMImageProps'; import * as React from 'react'; @@ -16,9 +21,76 @@ import { useNativeProps } from './useNativeProps'; import { useStrictDOMElement } from './useStrictDOMElement'; import * as css from '../css'; +// Plain (non-hook) helper so it can mutate the caller-owned `nativeProps` +// without a defensive copy (a hook may not mutate its own return value). +function applyImageProps( + nativeProps: ReactNativeProps, + props: StrictReactDOMImageProps, + elementRef: CallbackRef +): void { + const { + alt, + crossOrigin, + height, + onError, + onLoad, + referrerPolicy, + src, + srcSet, + width + } = props; + + // Tag-specific props + + if (alt != null) { + nativeProps.alt = alt; + } + if (crossOrigin != null) { + nativeProps.crossOrigin = crossOrigin; + } + if (height != null) { + nativeProps.height = height; + } + if (onError != null) { + nativeProps.onError = function () { + onError({ + type: 'error' + }); + }; + } + if (onLoad != null) { + nativeProps.onLoad = function (e) { + const { source } = e.nativeEvent; + onLoad({ + target: { + naturalHeight: source?.height, + naturalWidth: source?.width + }, + type: 'load' + }); + }; + } + if (referrerPolicy != null) { + nativeProps.referrerPolicy = referrerPolicy; + } + if (src != null) { + nativeProps.src = src; + } + if (srcSet != null) { + nativeProps.srcSet = srcSet; + } + if (width != null) { + nativeProps.width = width; + } + + // Component-specific props + + nativeProps.ref = elementRef; +} + export function createStrictDOMImageComponent< - P extends StrictReactDOMImageProps, - T + T, + P extends StrictReactDOMImageProps >( tagName: string, _defaultProps?: P @@ -29,17 +101,7 @@ export function createStrictDOMImageComponent< | typeof ReactNative.Animated.Image = ReactNative.Image; const elementRef = useStrictDOMElement(ref, { tagName }); - const { - alt, - crossOrigin, - height, - onError, - onLoad, - referrerPolicy, - src, - srcSet, - width - } = props; + const { height, width } = props; /** * Resolve global HTML and style props @@ -58,62 +120,7 @@ export function createStrictDOMImageComponent< withTextStyle: false }); - // Tag-specific props - - if (alt != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.alt = alt; - } - if (crossOrigin != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.crossOrigin = crossOrigin; - } - if (height != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.height = height; - } - if (onError != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.onError = function () { - onError({ - type: 'error' - }); - }; - } - if (onLoad != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.onLoad = function (e) { - const { source } = e.nativeEvent; - onLoad({ - target: { - naturalHeight: source?.height, - naturalWidth: source?.width - }, - type: 'load' - }); - }; - } - if (referrerPolicy != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.referrerPolicy = referrerPolicy; - } - if (src != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.src = src; - } - if (srcSet != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.srcSet = srcSet; - } - if (width != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.width = width; - } - - // Component-specific props - - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.ref = elementRef; + applyImageProps(nativeProps, props, elementRef); // Use Animated components if necessary if (nativeProps.animated === true) { @@ -124,7 +131,7 @@ export function createStrictDOMImageComponent< typeof props.children === 'function' ? ( props.children(nativeProps) ) : ( - // strict-dom's wide ReactNativeProps spreads onto RN 0.83's exact + // strict-dom's wide ReactNativeProps spreads onto RN's exact // ImageProps; harmless extras are ignored at runtime. // $FlowFixMe[incompatible-type] // $FlowFixMe[incompatible-use] diff --git a/packages/react-strict-dom/src/native/modules/createStrictDOMTextComponent.js b/packages/react-strict-dom/src/native/modules/createStrictDOMTextComponent.js index 30deca61..569160f8 100644 --- a/packages/react-strict-dom/src/native/modules/createStrictDOMTextComponent.js +++ b/packages/react-strict-dom/src/native/modules/createStrictDOMTextComponent.js @@ -7,7 +7,11 @@ * @flow strict-local */ -import type { ReactNativeProps } from '../../types/renderer.native'; +import type { CallbackRef } from '../../types/react'; +import type { + HostInstance, + ReactNativeProps +} from '../../types/renderer.native'; import type { StrictProps as StrictPropsOriginal } from '../../types/StrictProps'; import * as React from 'react'; @@ -28,6 +32,66 @@ function hasElementChildren(children: unknown): boolean { return children != null && typeof children !== 'string'; } +// Plain (non-hook) helper so it can mutate the caller-owned `nativeProps` +// without a defensive copy (a hook may not mutate its own return value). +function applyTextProps( + nativeProps: ReactNativeProps, + props: StrictProps, + tagName: string, + elementRef: CallbackRef +): void { + const { href, label } = props; + + // Tag-specific props + + if (tagName === 'a') { + nativeProps.role ??= 'link'; + if (href != null) { + nativeProps.onPress = function (e) { + if (__DEV__) { + errorMsg(' "href" handling is not implemented in React Native.'); + } + }; + } + } else if (tagName === 'br') { + nativeProps.children = '\n'; + } else if ( + tagName === 'h1' || + tagName === 'h2' || + tagName === 'h3' || + tagName === 'h4' || + tagName === 'h5' || + tagName === 'h6' + ) { + nativeProps.role ??= 'heading'; + } else if (tagName === 'option') { + nativeProps.children = label; + } + + // Component-specific props + + nativeProps.ref = elementRef; + + // Workaround: Android doesn't support ellipsis truncation if Text is selectable + // See #136 + const disableUserSelect = + ReactNative.Platform.OS === 'android' && + nativeProps.numberOfLines != null && + nativeProps.style.userSelect !== 'none'; + + // $FlowExpectedError[unsafe-object-assign] + nativeProps.style = Object.assign( + nativeProps.style, + disableUserSelect ? { userSelect: 'none' } : null + ); + + // Native components historically clip text. Opt into web-style default of + // visible overflow by default + if (nativeProps.style.overflow == null) { + nativeProps.style.overflow = 'visible'; + } +} + export function createStrictDOMTextComponent( tagName: string, defaultProps?: P @@ -38,8 +102,6 @@ export function createStrictDOMTextComponent( | typeof ReactNative.Animated.Text = ReactNative.Text; const elementRef = useStrictDOMElement(ref, { tagName }); - const { href, label } = props; - /** * Resolve global HTML and style props */ @@ -49,73 +111,14 @@ export function createStrictDOMTextComponent( props, { provideInheritableStyle: - tagName !== 'br' || - // $FlowFixMe[invalid-compare] - tagName !== 'option' || + (tagName !== 'br' && tagName !== 'option') || hasElementChildren(props.children), withInheritedStyle: true, withTextStyle: true } ); - // Tag-specific props - - if (tagName === 'a') { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.role ??= 'link'; - if (href != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.onPress = function (e) { - if (__DEV__) { - errorMsg(' "href" handling is not implemented in React Native.'); - } - }; - } - } else if (tagName === 'br') { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.children = '\n'; - } else if ( - tagName === 'h1' || - tagName === 'h2' || - tagName === 'h3' || - tagName === 'h4' || - tagName === 'h5' || - tagName === 'h6' - ) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.role ??= 'heading'; - } else if (tagName === 'option') { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.children = label; - } - - // Component-specific props - - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.ref = elementRef; - - // Workaround: Android doesn't support ellipsis truncation if Text is selectable - // See #136 - const disableUserSelect = - ReactNative.Platform.OS === 'android' && - nativeProps.numberOfLines != null && - nativeProps.style.userSelect !== 'none'; - - // $FlowExpectedError[unsafe-object-assign] - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style = Object.assign( - nativeProps.style, - disableUserSelect ? { userSelect: 'none' } : null - ); - - // Native components historically clip text. Opt into web-style default of - // visible overflow by default - if (nativeProps.style?.overflow == null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style = nativeProps.style ?? {}; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.style.overflow = 'visible'; - } + applyTextProps(nativeProps, props, tagName, elementRef); // Use Animated components if necessary if (nativeProps.animated === true) { @@ -130,6 +133,8 @@ export function createStrictDOMTextComponent( typeof props.children === 'function' ? ( props.children(nativeProps) ) : ( + // strict-dom's wide ReactNativeProps spreads onto RN's exact Text + // props; harmless extras are ignored at runtime. // $FlowFixMe[incompatible-type] ); diff --git a/packages/react-strict-dom/src/native/modules/createStrictDOMTextInputComponent.js b/packages/react-strict-dom/src/native/modules/createStrictDOMTextInputComponent.js index 9cc4f74c..0446fa36 100644 --- a/packages/react-strict-dom/src/native/modules/createStrictDOMTextInputComponent.js +++ b/packages/react-strict-dom/src/native/modules/createStrictDOMTextInputComponent.js @@ -7,6 +7,11 @@ * @flow strict-local */ +import type { CallbackRef } from '../../types/react'; +import type { + HostInstance, + ReactNativeProps +} from '../../types/renderer.native'; import type { StrictReactDOMInputProps } from '../../types/StrictReactDOMInputProps'; import type { StrictReactDOMTextAreaProps } from '../../types/StrictReactDOMTextAreaProps'; @@ -22,25 +27,192 @@ const AnimatedTextInput = ReactNative.Animated.createAnimatedComponent( ReactNative.TextInput ); -// $FlowFixMe[unclear-type] -type Node = any; +// Selection-cache polyfill view: `_selectionStart` / `_selectionEnd` are +// strict-dom-internal fields, not part of the public host instance type. +type SelectionCacheNode = { + _selectionStart?: number, + _selectionEnd?: number, + ... +}; type StrictInputProps = StrictReactDOMInputProps | StrictReactDOMTextAreaProps; // Helper to update cached selection state for selectionStart/End polyfill function updateCachedSelection( - node: ?Node, + node: ?HostInstance, selection: ?{ start: number, end: number } ) { if (node != null && selection != null) { - node._selectionStart = selection.start; - node._selectionEnd = selection.end; + // $FlowFixMe[class-object-subtyping] - write polyfill-only cache fields. + const view: SelectionCacheNode = node; + view._selectionStart = selection.start; + view._selectionEnd = selection.end; } } +// Plain (non-hook) helper so it can mutate the caller-owned `nativeProps` +// without a defensive copy (a hook may not mutate its own return value). +function applyTextInputProps( + nativeProps: ReactNativeProps, + props: StrictInputProps, + tagName: string, + mergedRef: CallbackRef, + cacheSelection: (selection: ?{ start: number, end: number }) => void +): void { + const { + autoCapitalize, + autoComplete, + defaultValue, + disabled, + enterKeyHint, + inputMode, + maxLength, + onChange, + onInput, + onKeyDown, + onSelectionChange, + placeholder, + readOnly, + rows, + spellCheck, + type, + value + } = props; + + // Tag-specific props + + if (tagName === 'input') { + let _inputMode = inputMode; + if (type === 'email') { + _inputMode = 'email'; + } + if (type === 'search') { + _inputMode = 'search'; + } + if (type === 'tel') { + _inputMode = 'tel'; + } + if (type === 'url') { + _inputMode = 'url'; + } + if (type === 'number') { + _inputMode = 'numeric'; + } + if (_inputMode != null) { + nativeProps.inputMode = _inputMode; + } + if (type === 'password') { + nativeProps.secureTextEntry = true; + } + if (type === 'checkbox' || type === 'date' || type === 'radio') { + if (__DEV__) { + errorMsg( + ` is not implemented in React Native.` + ); + } + } + } else if (tagName === 'textarea') { + nativeProps.multiline = true; + if (rows != null) { + nativeProps.numberOfLines = rows; + } + } + + // Component-specific props + + if (autoCapitalize != null) { + nativeProps.autoCapitalize = autoCapitalize; + } + if (autoComplete != null) { + nativeProps.autoComplete = autoComplete; + } + if (defaultValue != null) { + nativeProps.defaultValue = defaultValue; + } + if (disabled === true) { + // polyfill disabled elements + nativeProps.disabled = true; + nativeProps.editable = false; + nativeProps.focusable = false; + } + if (enterKeyHint != null) { + nativeProps.enterKeyHint = enterKeyHint; + } + if (maxLength != null) { + nativeProps.maxLength = maxLength; + } + if (onChange != null || onInput != null) { + nativeProps.onChange = function (e) { + const { text, selection } = e.nativeEvent; + // Update cached selection state immediately to ensure sync with onChange + cacheSelection(selection); + if (onInput != null) { + onInput({ + target: { + value: text + }, + type: 'input' + }); + } + if (onChange != null) { + onChange({ + target: { + value: text + }, + type: 'change' + }); + } + }; + } + if (onKeyDown != null) { + nativeProps.onKeyPress = function (e) { + const { key } = e.nativeEvent; + // Filter out bad iOS keypress data on submit + if ( + key === 'Backspace' || + (tagName === 'textarea' && key === 'Enter') || + key.length === 1 + ) { + onKeyDown({ + key, + type: 'keydown' + }); + } + }; + nativeProps.onSubmitEditing = function (e) { + onKeyDown({ + key: 'Enter', + type: 'keydown' + }); + }; + } + // Part of polyfill for selectionStart/End + nativeProps.onSelectionChange = function (e) { + const { selection } = e.nativeEvent; + cacheSelection(selection); + if (onSelectionChange != null) { + onSelectionChange(e); + } + }; + if (placeholder != null) { + nativeProps.placeholder = placeholder; + } + if (readOnly != null) { + nativeProps.editable = !readOnly; + } + if (spellCheck != null) { + nativeProps.spellCheck = spellCheck; + } + if (value != null && typeof value === 'string') { + nativeProps.value = value; + } + + nativeProps.ref = mergedRef; +} + export function createStrictDOMTextInputComponent< - P extends StrictInputProps, - T + T, + P extends StrictInputProps >( tagName: string, defaultProps?: P @@ -49,29 +221,9 @@ export function createStrictDOMTextInputComponent< let NativeComponent: | typeof ReactNative.TextInput | typeof AnimatedTextInput = ReactNative.TextInput; - const nodeRef = React.useRef(null); + const nodeRef = React.useRef(null); const elementRef = useStrictDOMElement(ref, { tagName }); - const { - autoCapitalize, - autoComplete, - defaultValue, - disabled, - enterKeyHint, - inputMode, - maxLength, - onChange, - onInput, - onKeyDown, - onSelectionChange, - placeholder, - readOnly, - rows, - spellCheck, - type, - value - } = props; - /** * Resolve global HTML and style props */ @@ -82,162 +234,23 @@ export function createStrictDOMTextInputComponent< withTextStyle: true }); - // Tag-specific props - - if (tagName === 'input') { - let _inputMode = inputMode; - if (type === 'email') { - _inputMode = 'email'; - } - if (type === 'search') { - _inputMode = 'search'; - } - if (type === 'tel') { - _inputMode = 'tel'; - } - if (type === 'url') { - _inputMode = 'url'; - } - if (type === 'number') { - _inputMode = 'numeric'; - } - if (_inputMode != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.inputMode = _inputMode; - } - if (type === 'password') { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.secureTextEntry = true; - } - if (type === 'checkbox' || type === 'date' || type === 'radio') { - if (__DEV__) { - errorMsg( - ` is not implemented in React Native.` - ); - } - } - } else if (tagName === 'textarea') { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.multiline = true; - if (rows != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.numberOfLines = rows; - } - } - - // Component-specific props - - if (autoCapitalize != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.autoCapitalize = autoCapitalize; - } - if (autoComplete != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.autoComplete = autoComplete; - } - if (defaultValue != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.defaultValue = defaultValue; - } - if (disabled === true) { - // polyfill disabled elements - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.disabled = true; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.editable = false; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.focusable = false; - } - if (enterKeyHint != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.enterKeyHint = enterKeyHint; - } - if (maxLength != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.maxLength = maxLength; - } - if (onChange != null || onInput != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.onChange = function (e) { - const { text, selection } = e.nativeEvent; - // Update cached selection state immediately to ensure sync with onChange - updateCachedSelection(nodeRef.current, selection); - if (onInput != null) { - onInput({ - target: { - value: text - }, - type: 'input' - }); - } - if (onChange != null) { - onChange({ - target: { - value: text - }, - type: 'change' - }); - } - }; - } - if (onKeyDown != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.onKeyPress = function (e) { - const { key } = e.nativeEvent; - // Filter out bad iOS keypress data on submit - if ( - key === 'Backspace' || - (tagName === 'textarea' && key === 'Enter') || - key.length === 1 - ) { - onKeyDown({ - key, - type: 'keydown' - }); - } - }; - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.onSubmitEditing = function (e) { - onKeyDown({ - key: 'Enter', - type: 'keydown' - }); - }; - } - // Part of polyfill for selectionStart/End - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.onSelectionChange = function (e) { - const { selection } = e.nativeEvent; - updateCachedSelection(nodeRef.current, selection); - if (onSelectionChange != null) { - onSelectionChange(e); - } - }; - if (placeholder != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.placeholder = placeholder; - } - if (readOnly != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.editable = !readOnly; - } - if (spellCheck != null) { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.spellCheck = spellCheck; - } - if (value != null && typeof value === 'string') { - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.value = value; - } - - // $FlowFixMe[react-rule-hook-mutation] - nativeProps.ref = React.useMemo( + const mergedRef = React.useMemo( () => mergeRefs((node) => { nodeRef.current = node; }, elementRef), [elementRef] ); + // Reads nodeRef lazily so the ref object never enters the plain props + // builder, which would trip react-rule-unsafe-ref. + const cacheSelection = React.useCallback( + (selection: ?{ start: number, end: number }) => { + updateCachedSelection(nodeRef.current, selection); + }, + [] + ); + + applyTextInputProps(nativeProps, props, tagName, mergedRef, cacheSelection); // Use Animated components if necessary if (nativeProps.animated === true) { @@ -248,7 +261,7 @@ export function createStrictDOMTextInputComponent< typeof props.children === 'function' ? ( props.children(nativeProps) ) : ( - // strict-dom's wide ReactNativeProps spreads onto RN 0.83's exact + // strict-dom's wide ReactNativeProps spreads onto RN's exact // TextInputProps; harmless extras are ignored at runtime. // $FlowFixMe[incompatible-type] // $FlowFixMe[incompatible-use] diff --git a/packages/react-strict-dom/src/native/modules/shallowEqual.js b/packages/react-strict-dom/src/native/modules/shallowEqual.js index d98283c8..1bfc913b 100644 --- a/packages/react-strict-dom/src/native/modules/shallowEqual.js +++ b/packages/react-strict-dom/src/native/modules/shallowEqual.js @@ -11,9 +11,6 @@ 'use strict'; -// $FlowFixMe[method-unbinding] added when improving typing for this parameters -const hasOwnProperty = Object.prototype.hasOwnProperty; - /** * Performs equality by iterating through keys on an object and returning false * when any key has values which are not strictly equal between the arguments. @@ -40,13 +37,12 @@ export function shallowEqual(objA: unknown, objB: unknown): boolean { return false; } + const a: Readonly<{ [string]: unknown }> = objA; + const b: Readonly<{ [string]: unknown }> = objB; + // Test for A's keys different from B. for (let i = 0; i < keysA.length; i++) { - if ( - !hasOwnProperty.call(objB, keysA[i]) || - // $FlowFixMe[incompatible-use] - !Object.is(objA[keysA[i]], objB[keysA[i]]) - ) { + if (!Object.hasOwn(b, keysA[i]) || !Object.is(a[keysA[i]], b[keysA[i]])) { return false; } } diff --git a/packages/react-strict-dom/src/native/modules/useNativeProps.js b/packages/react-strict-dom/src/native/modules/useNativeProps.js index b185cbee..9da9af28 100644 --- a/packages/react-strict-dom/src/native/modules/useNativeProps.js +++ b/packages/react-strict-dom/src/native/modules/useNativeProps.js @@ -8,7 +8,7 @@ */ import type { CustomProperties } from '../../types/styles'; -import type { ReactNativeProps } from '../../types/renderer.native'; +import type { PressEvent, ReactNativeProps } from '../../types/renderer.native'; import type { StrictProps as StrictPropsOriginal } from '../../types/StrictProps'; import type { Style } from '../../types/styles'; @@ -47,55 +47,32 @@ function validateStrictProps(props: StrictProps) { } /** - * Utility to merge event handlers + * Utility to merge event handlers. `a` and `b` receive different event shapes at + * runtime but the merged handler forwards the same event to both for side effects. */ -type EventHandler = - | ReactNativeProps['onBlur'] - | ReactNativeProps['onFocus'] - | ReactNativeProps['onMouseEnter'] - | ReactNativeProps['onMouseLeave'] - | ReactNativeProps['onPointerCancel'] - | ReactNativeProps['onPointerDown'] - | ReactNativeProps['onPointerEnter'] - | ReactNativeProps['onPointerUp'] - | ReactNativeProps['onPointerLeave']; - -// $FlowFixMe[incompatible-type] -function combineEventHandlers(a: EventHandler, b: EventHandler): $FlowFixMe { +function combineEventHandlers( + a: ?(event: A) => void, + b: ?(event: B) => void +): ?(event: B) => void { if (a == null) { return b; - } else { - return (e) => { - const returnA = typeof a === 'function' ? a(e) : null; - const returnB = typeof b === 'function' ? b(e) : null; - return returnB || returnA; - }; } + return (e: B) => { + if (typeof a === 'function') { + a(e as $FlowFixMe); + } + if (typeof b === 'function') { + b(e); + } + }; } -/** - * Produces the relevant React Native props to implement the global HTML props. - */ -type OptionsType = {| - provideInheritableStyle: boolean, - withTextStyle: boolean, - withInheritedStyle: boolean -|}; -type ReturnType = {| - customProperties: ?CustomProperties, +// Plain (non-hook) helper so it can mutate the caller-owned `nativeProps` (built +// fresh each render by `useStyleProps`) without a defensive copy. +function applyHtmlProps( nativeProps: ReactNativeProps, - inheritableStyle: ?Style -|}; - -export function useNativeProps( - defaultProps: ?StrictProps, - props: StrictProps, - options: OptionsType -): ReturnType { - if (__DEV__) { - validateStrictProps(props); - } - + props: StrictProps +): void { const { 'aria-busy': ariaBusy, 'aria-checked': ariaChecked, @@ -115,8 +92,6 @@ export function useNativeProps( 'aria-valuetext': ariaValueText, children, 'data-testid': dataTestID, - dir, - //disabled, hidden, id, onBlur, @@ -144,32 +119,12 @@ export function useNativeProps( onTouchMove, onTouchStart, role, - style, tabIndex } = props; - /** - * Resolve style props - */ - - const renderStyle = [defaultProps?.style ?? null, style]; - - const [extractedStyle, customPropertiesFromThemes] = - extractStyleThemes(renderStyle); - const customProperties = useCustomProperties(customPropertiesFromThemes); - - const { nativeProps, inheritableStyle } = useStyleProps(extractedStyle, { - customProperties, - provideInheritableStyle: options.provideInheritableStyle, - withTextStyle: options.withTextStyle, - withInheritedStyle: options.withInheritedStyle, - writingDirection: dir - }); - const displayValue = nativeProps.style.display; // 'hidden' polyfill (only if "display" is not set) if (displayValue == null && hidden && hidden !== 'until-found') { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.style.display = 'none'; } @@ -178,46 +133,36 @@ export function useNativeProps( */ if (typeof children !== 'function') { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.children = children; } if (ariaHidden != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.accessibilityElementsHidden = ariaHidden; if (ariaHidden === true) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.importantForAccessibility = 'no-hide-descendants'; } } if (ariaLabel != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.accessibilityLabel = ariaLabel; } if (ariaLabelledBy != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.accessibilityLabelledBy = ariaLabelledBy?.split(/\s*,\s*/g); } if (ariaLive != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.accessibilityLiveRegion = ariaLive === 'off' ? 'none' : ariaLive; } if (ariaModal != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.accessibilityViewIsModal = ariaModal; } if (ariaPosInSet != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.accessibilityPosInSet = ariaPosInSet; } const ariaRole = role; if (ariaRole) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.role = ariaRole; } if (ariaSetSize != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.accessibilitySetSize = ariaSetSize; } if ( @@ -227,7 +172,6 @@ export function useNativeProps( ariaExpanded != null || ariaSelected != null ) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.accessibilityState = { busy: ariaBusy, checked: ariaChecked, @@ -242,7 +186,6 @@ export function useNativeProps( ariaValueNow != null || ariaValueText != null ) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.accessibilityValue = { max: ariaValueMax, min: ariaValueMin, @@ -251,29 +194,24 @@ export function useNativeProps( }; } if (dataTestID != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.testID = dataTestID; } if (id != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.nativeID = id; } if (tabIndex != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.focusable = !tabIndex; } // Events if (onBlur != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onBlur = combineEventHandlers(nativeProps.onBlur, onBlur); } // TODO: remove once PointerEvent onClick is available if (onClick != null) { - // $FlowFixMe[react-rule-hook-mutation] - // $FlowFixMe[missing-local-annot] - nativeProps.onPress = function ({ nativeEvent }) { + nativeProps.onPress = function (e: PressEvent) { + const { nativeEvent } = e; const event: unknown = nativeEvent; let altKey = false; let ctrlKey = false; @@ -328,114 +266,138 @@ export function useNativeProps( }; } if (onFocus != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onFocus = combineEventHandlers(nativeProps.onFocus, onFocus); } if (onGotPointerCapture != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onGotPointerCapture = onGotPointerCapture; } if (onLostPointerCapture != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onLostPointerCapture = onLostPointerCapture; } if (onMouseDown != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onMouseDown = onMouseDown; } if (onMouseEnter != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onMouseEnter = combineEventHandlers( nativeProps.onMouseEnter, onMouseEnter ); } if (onMouseLeave != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onMouseLeave = combineEventHandlers( nativeProps.onMouseLeave, onMouseLeave ); } if (onMouseOut != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onMouseOut = onMouseOut; } if (onMouseOver != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onMouseOver = onMouseOver; } if (onMouseUp != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onMouseUp = onMouseUp; } if (onPointerCancel != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onPointerCancel = combineEventHandlers( nativeProps.onPointerCancel, onPointerCancel ); } if (onPointerDown != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onPointerDown = combineEventHandlers( nativeProps.onPointerDown, onPointerDown ); } if (onPointerEnter != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onPointerEnter = combineEventHandlers( nativeProps.onPointerEnter, onPointerEnter ); } if (onPointerLeave != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onPointerLeave = combineEventHandlers( nativeProps.onPointerLeave, onPointerLeave ); } if (onPointerMove != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onPointerMove = onPointerMove; } if (onPointerOut != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onPointerOut = onPointerOut; } if (onPointerOver != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onPointerOver = onPointerOver; } if (onPointerUp != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onPointerUp = combineEventHandlers( nativeProps.onPointerUp, onPointerUp ); } if (onScroll != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onScroll = onScroll; } if (onTouchCancel != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onTouchCancel = onTouchCancel; } if (onTouchEnd != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onTouchEnd = onTouchEnd; } if (onTouchMove != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onTouchMove = onTouchMove; } if (onTouchStart != null) { - // $FlowFixMe[react-rule-hook-mutation] nativeProps.onTouchStart = onTouchStart; } +} + +/** + * Produces the relevant React Native props to implement the global HTML props. + */ +type OptionsType = {| + provideInheritableStyle: boolean, + withTextStyle: boolean, + withInheritedStyle: boolean +|}; +type ReturnType = {| + customProperties: ?CustomProperties, + nativeProps: ReactNativeProps, + inheritableStyle: ?Style +|}; + +export function useNativeProps( + defaultProps: ?StrictProps, + props: StrictProps, + options: OptionsType +): ReturnType { + if (__DEV__) { + validateStrictProps(props); + } + + const { dir, style } = props; + + /** + * Resolve style props + */ + + const renderStyle = [defaultProps?.style ?? null, style]; + + const [extractedStyle, customPropertiesFromThemes] = + extractStyleThemes(renderStyle); + const customProperties = useCustomProperties(customPropertiesFromThemes); + + const { nativeProps, inheritableStyle } = useStyleProps(extractedStyle, { + customProperties, + provideInheritableStyle: options.provideInheritableStyle, + withTextStyle: options.withTextStyle, + withInheritedStyle: options.withInheritedStyle, + writingDirection: dir + }); + + applyHtmlProps(nativeProps, props); return { customProperties: diff --git a/packages/react-strict-dom/src/native/modules/useStrictDOMElement.js b/packages/react-strict-dom/src/native/modules/useStrictDOMElement.js index 2cf38264..a8a6c7bd 100644 --- a/packages/react-strict-dom/src/native/modules/useStrictDOMElement.js +++ b/packages/react-strict-dom/src/native/modules/useStrictDOMElement.js @@ -8,6 +8,7 @@ */ import type { CallbackRef } from '../../types/react'; +import type { HostInstance } from '../../types/renderer.native'; import * as React from 'react'; @@ -18,10 +19,35 @@ type Options = { tagName: string }; -// $FlowFixMe[unclear-type] -type Node = any; +// Polyfill members shared by both views below; declared once. +type StrictRefCommonPolyfills = { + complete?: boolean, + setSelectionRange?: (start: number, end: number) => void, + selectionStart?: number, + selectionEnd?: number +}; + +// Read view of the strict-dom polyfill members carried by the RN host node but +// not part of its public `HostInstance` type. +type StrictRefPolyfills = { + ...StrictRefCommonPolyfills, + setSelection?: (start: number, end: number) => void, + _selectionStart?: number, + _selectionEnd?: number, + ... +}; + +// Writable augmentation target: inherits the host node via the prototype chain +// and adds these members via `defineProperty`. +type StrictRefTarget = { + ...StrictRefCommonPolyfills, + nodeName?: string, + getBoundingClientRect?: () => DOMRect, + ... +}; -const memoizedStrictRefs: WeakMap = new WeakMap(); +const memoizedStrictRefs: WeakMap = + new WeakMap(); const lengthPropertySet: ReadonlySet = new Set([ 'clientHeight', @@ -47,18 +73,21 @@ const lengthPropertySet: ReadonlySet = new Set([ * Descendants are not wrapped; values read after traversal are not scaled. */ function getOrCreateStrictRef( - node: Node, + node: HostInstance, tagName: string, viewportScale: number -): Node { +): StrictRefTarget { const ref = memoizedStrictRefs.get(node); if (ref != null) { return ref; } - const strictRef: Node = Object.create(node); + // `Object.create(node)` is typed as the read-only host node, not the writable + // augmentation target. + const strictRef: StrictRefTarget = Object.create(node) as $FlowFixMe; + // $FlowFixMe[class-object-subtyping] - read the host node's polyfill members. + const nodeInternals: StrictRefPolyfills = node; - // $FlowFixMe[prop-missing] Object.defineProperty(strictRef, 'nodeName', { value: tagName.toUpperCase(), configurable: true @@ -67,8 +96,7 @@ function getOrCreateStrictRef( if (viewportScale !== 1) { const scale = (n: number) => n / viewportScale; - if (typeof node.getBoundingClientRect === 'function') { - // $FlowFixMe[prop-missing] + if ('getBoundingClientRect' in node) { Object.defineProperty(strictRef, 'getBoundingClientRect', { value: () => { const rect = node.getBoundingClientRect(); @@ -85,9 +113,9 @@ function getOrCreateStrictRef( for (const prop of lengthPropertySet) { if (prop in node) { - // $FlowFixMe[prop-missing] Object.defineProperty(strictRef, prop, { get() { + // $FlowFixMe[prop-missing] - dynamic length-property read off the RN host node. const value = node[prop]; return typeof value === 'number' ? scale(value) : value; }, @@ -98,39 +126,35 @@ function getOrCreateStrictRef( } if (tagName === 'img') { - // $FlowFixMe[prop-missing] Object.defineProperty(strictRef, 'complete', { get() { - return node.complete ?? false; + return nodeInternals.complete ?? false; }, configurable: true }); } else if (tagName === 'input' || tagName === 'textarea') { - if (node.setSelectionRange == null) { - // $FlowFixMe[prop-missing] + if (nodeInternals.setSelectionRange == null) { Object.defineProperty(strictRef, 'setSelectionRange', { value: (a: number, b: number) => { - node.setSelection(a, b); - node._selectionStart = a; - node._selectionEnd = b; + nodeInternals.setSelection?.(a, b); + nodeInternals._selectionStart = a; + nodeInternals._selectionEnd = b; }, configurable: true }); } - if (node.selectionStart == null) { - // $FlowFixMe[prop-missing] + if (nodeInternals.selectionStart == null) { Object.defineProperty(strictRef, 'selectionStart', { get() { - return node._selectionStart ?? 0; + return nodeInternals._selectionStart ?? 0; }, configurable: true }); } - if (node.selectionEnd == null) { - // $FlowFixMe[prop-missing] + if (nodeInternals.selectionEnd == null) { Object.defineProperty(strictRef, 'selectionEnd', { get() { - return node._selectionEnd ?? 0; + return nodeInternals._selectionEnd ?? 0; }, configurable: true }); @@ -142,26 +166,28 @@ function getOrCreateStrictRef( } export function useStrictDOMElement( - ref: React.RefSetter, + ref: React.RefSetter, { tagName }: Options -): CallbackRef { +): CallbackRef { const { scale: viewportScale } = useViewportScale(); - return useElementCallback( + return useElementCallback( React.useCallback( - // $FlowFixMe[unclear-type] - (node: any) => { + (node: HostInstance) => { if (ref == null) return undefined; const strictRef = getOrCreateStrictRef(node, tagName, viewportScale); if (typeof ref === 'function') { + // Public ref type is the DOM element `T` (web/native parity); at + // runtime we hand over the RN-backed strict wrapper. // $FlowFixMe[incompatible-type] - Flow does not understand ref cleanup. - const cleanup: void | (() => void) = ref(strictRef); + const cleanup: void | (() => void) = ref(strictRef as $FlowFixMe); return typeof cleanup === 'function' ? cleanup : () => { ref(null); }; } + // $FlowFixMe[incompatible-type] - see the ref-callback note above. ref.current = strictRef; return () => { ref.current = null; diff --git a/packages/react-strict-dom/src/native/modules/useStyleProps.js b/packages/react-strict-dom/src/native/modules/useStyleProps.js index 698be171..95bb42e9 100644 --- a/packages/react-strict-dom/src/native/modules/useStyleProps.js +++ b/packages/react-strict-dom/src/native/modules/useStyleProps.js @@ -8,8 +8,11 @@ */ import type { CustomProperties, Style } from '../../types/styles'; -import type { ReactNativeProps } from '../../types/renderer.native'; -import type { ReactNativeStyle } from '../../types/renderer.native'; +import type { + ReactNativeProps, + ReactNativeStyle, + ReactNativeStyleValue +} from '../../types/renderer.native'; import * as css from '../css'; import * as ReactNative from '../react-native'; @@ -20,7 +23,7 @@ import { usePseudoStates } from './usePseudoStates'; import { useStyleTransition } from './useStyleTransition'; import { useViewportScale } from './ContextViewportScale'; -const inheritedProperties = [ +const inheritedProperties: ReadonlyArray = [ 'color', 'cursor', 'fontFamily', @@ -40,7 +43,18 @@ const inheritedProperties = [ 'writingDirection' ]; -const eventHandlerNames = [ +type EventHandlerName = + | 'onBlur' + | 'onFocus' + | 'onMouseEnter' + | 'onMouseLeave' + | 'onPointerCancel' + | 'onPointerDown' + | 'onPointerEnter' + | 'onPointerLeave' + | 'onPointerUp'; + +const eventHandlerNames: ReadonlyArray = [ 'onBlur', 'onFocus', 'onMouseEnter', @@ -139,15 +153,13 @@ export function useStyleProps( viewportScale, viewportWidth: width }, - flatStyle as $FlowFixMe + flatStyle ); if (handlers != null) { for (const handler of eventHandlerNames) { - // $FlowFixMe[invalid-computed-prop] const handlerValue = handlers[handler]; if (handlerValue != null) { - // $FlowFixMe[prop-missing] styleProps[handler] = handlerValue; } } @@ -170,7 +182,7 @@ export function useStyleProps( } // Create inherited values lookup for performance - const inheritedValues = { + const inheritedValues: Readonly<{ [string]: ?ReactNativeStyleValue }> = { color: inheritedColor, cursor: inheritedCursor, fontFamily: inheritedFontFamily, @@ -190,13 +202,12 @@ export function useStyleProps( writingDirection: inheritedWritingDirection }; - const inheritableStyle = {} as $FlowFixMe; - const viewStyle = {} as $FlowFixMe; + const inheritableStyle: ReactNativeStyle = {}; + const viewStyle: ReactNativeStyle = {}; let hasInheritableStyle = false; for (const key of inheritedProperties) { const value = styleProps.style[key]; - // $FlowFixMe[invalid-computed-prop] const inheritedValue = inheritedValues[key]; let val = value; @@ -217,7 +228,7 @@ export function useStyleProps( // Copy non-inherited properties to viewStyle if (hasInheritableStyle) { for (const key in styleProps.style) { - if (!inheritedValues.hasOwnProperty(key)) { + if (!Object.hasOwn(inheritedValues, key)) { viewStyle[key] = styleProps.style[key]; } } @@ -234,9 +245,11 @@ export function useStyleProps( return { nativeProps: styleProps, + // Cast the owned, mutable RN-style object to StyleX's read-only `Style` + // type expected by the inherited-style subsystem. inheritableStyle: hasInheritableStyle && provideInheritableStyle === true - ? inheritableStyle + ? (inheritableStyle as $FlowFixMe) : null }; } diff --git a/packages/react-strict-dom/src/types/renderer.native.js b/packages/react-strict-dom/src/types/renderer.native.js index 703cc602..4f91a652 100644 --- a/packages/react-strict-dom/src/types/renderer.native.js +++ b/packages/react-strict-dom/src/types/renderer.native.js @@ -32,6 +32,9 @@ import type { // $FlowFixMe[nonstrict-import] ViewProps } from 'react-native/Libraries/Components/View/ViewPropTypes'; +// $FlowFixMe[nonstrict-import] +import type { HostInstance } from 'react-native'; +import type { CallbackRef } from './react'; type ReactNativeProps = { accessible?: ViewProps['accessible'], @@ -83,13 +86,15 @@ type ReactNativeProps = { onKeyPress?: TextInputProps['onKeyPress'], onLoad?: ImageProps['onLoad'], onLostPointerCapture?: ViewProps['onLostPointerCapture'], - onMouseDown?: $FlowFixMe, - onMouseEnter?: $FlowFixMe, - onMouseLeave?: $FlowFixMe, - onMouseMove?: $FlowFixMe, - onMouseOut?: $FlowFixMe, - onMouseOver?: $FlowFixMe, - onMouseUp?: $FlowFixMe, + // Mouse events are not part of RN's ViewProps; strict-dom forwards them as + // opaque handlers (the runtime event shape is platform-defined). + onMouseDown?: ?(event: unknown) => void, + onMouseEnter?: ?(event: unknown) => void, + onMouseLeave?: ?(event: unknown) => void, + onMouseMove?: ?(event: unknown) => void, + onMouseOut?: ?(event: unknown) => void, + onMouseOver?: ?(event: unknown) => void, + onMouseUp?: ?(event: unknown) => void, onPointerCancel?: ViewProps['onPointerCancel'], onPointerDown?: ViewProps['onPointerDown'], onPointerEnter?: ViewProps['onPointerEnter'], @@ -99,7 +104,7 @@ type ReactNativeProps = { onPointerOver?: ViewProps['onPointerOver'], onPointerUp?: ViewProps['onPointerUp'], onPress?: ?(event: PressEvent) => void, - onScroll?: $FlowFixMe, + onScroll?: ?(event: unknown) => void, onSelectionChange?: TextInputProps['onSelectionChange'], onSubmitEditing?: TextInputProps['onSubmitEditing'], onTouchCancel?: ViewProps['onTouchCancel'], @@ -109,7 +114,7 @@ type ReactNativeProps = { placeholder?: TextInputProps['placeholder'], placeholderTextColor?: TextInputProps['placeholderTextColor'], pointerEvents?: ViewProps['pointerEvents'], - ref?: $FlowFixMe, + ref?: CallbackRef, referrerPolicy?: ImageProps['referrerPolicy'], renderToHardwareTextureAndroid?: ViewProps['renderToHardwareTextureAndroid'], role?: ?string, @@ -149,6 +154,8 @@ type ReactNativeStyle = { [string]: ?ReactNativeStyleValue }; export type { CompositeAnimation, + HostInstance, + PressEvent, ReactNativeProps, ReactNativeStyle, ReactNativeStyleValue, From ee52885304b7c9c508d371caeaacef1ab51ae194 Mon Sep 17 00:00:00 2001 From: Yamin Yassin Date: Mon, 15 Jun 2026 12:23:41 +0100 Subject: [PATCH 3/4] Drop the $FlowFixMe in the web runtime 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. --- .../web/modules/createStrictDOMComponent.js | 23 +++++------- packages/react-strict-dom/src/web/runtime.js | 36 ------------------- 2 files changed, 8 insertions(+), 51 deletions(-) diff --git a/packages/react-strict-dom/src/web/modules/createStrictDOMComponent.js b/packages/react-strict-dom/src/web/modules/createStrictDOMComponent.js index 78e86020..88976b43 100644 --- a/packages/react-strict-dom/src/web/modules/createStrictDOMComponent.js +++ b/packages/react-strict-dom/src/web/modules/createStrictDOMComponent.js @@ -7,8 +7,6 @@ * @flow strict */ -import type { CompiledStyles } from '@stylexjs/stylex'; -import type { ReactDOMStyleProps } from '../../types/renderer.web'; import type { StrictProps } from '../../types/StrictProps'; import * as React from 'react'; @@ -16,7 +14,10 @@ import { errorMsg } from '../../shared/logUtils'; import { isPropAllowed } from '../../shared/isPropAllowed'; import { merge } from '../css/merge'; -// $FlowFixMe[unclear-type] +// Compiled-style shape for the debug-style object `stylex.create` doesn't generate. +type DebugCompiledStyle = Readonly<{ $$css: true, [string]: string }>; + +// $FlowFixMe[unclear-type] precise typing would block the in-place `delete` of invalid keys. function validateStrictProps(props: any) { Object.keys(props).forEach((key) => { const isValid = isPropAllowed(key); @@ -31,11 +32,9 @@ export function createStrictDOMComponent( TagName: string, defaultStyle: StrictProps['style'] ): component(ref?: React.RefSetter, ...P) { - // NOTE: `debug-style` is not generated by `stylex.create` - // so it needs a type-cast - const debugStyle: CompiledStyles = { + const debugStyle: DebugCompiledStyle = { $$css: true, - 'debug::name': `html-${TagName}` as $FlowFixMe + 'debug::name': `html-${TagName}` }; component Component(ref?: React.RefSetter, ...props: P) { @@ -50,8 +49,7 @@ export function createStrictDOMComponent( hostProps.htmlFor = htmlFor; } if (props.role != null) { - // "presentation" synonym has wider browser support - // $FlowFixMe[incompatible-type] + // $FlowFixMe[incompatible-type] "presentation" synonym has wider browser support hostProps.role = props.role === 'none' ? 'presentation' : props.role; } if (TagName === 'button') { @@ -63,12 +61,7 @@ export function createStrictDOMComponent( /** * get host style props */ - // $FlowFixMe[incompatible-type] - const hostStyleProps: ReactDOMStyleProps = merge([ - debugStyle, - defaultStyle, - style - ]); + const hostStyleProps = merge([debugStyle, defaultStyle, style]); /** * Construct tree diff --git a/packages/react-strict-dom/src/web/runtime.js b/packages/react-strict-dom/src/web/runtime.js index e7b24009..c1b08bd2 100644 --- a/packages/react-strict-dom/src/web/runtime.js +++ b/packages/react-strict-dom/src/web/runtime.js @@ -81,88 +81,52 @@ const styles = stylex.create({ } }); -// $FlowFixMe[incompatible-type] const a: StrictReactDOMPropsStyle = styles.inline; -// $FlowFixMe[incompatible-type] const article: StrictReactDOMPropsStyle = styles.block; -// $FlowFixMe[incompatible-type] const aside: StrictReactDOMPropsStyle = styles.block; -// $FlowFixMe[incompatible-type] const b: StrictReactDOMPropsStyle = styles.inline; -// $FlowFixMe[incompatible-type] const bdi: StrictReactDOMPropsStyle = styles.inline; -// $FlowFixMe[incompatible-type] const bdo: StrictReactDOMPropsStyle = styles.inline; -// $FlowFixMe[incompatible-type] const blockquote: StrictReactDOMPropsStyle = styles.block; const br: StrictReactDOMPropsStyle = null; -// $FlowFixMe[incompatible-type] const button: StrictReactDOMPropsStyle = [styles.inlineblock, styles.button]; -// $FlowFixMe[incompatible-type] const code: StrictReactDOMPropsStyle = [styles.inline, styles.codePre]; const del: StrictReactDOMPropsStyle = null; -// $FlowFixMe[incompatible-type] const div: StrictReactDOMPropsStyle = styles.block; -// $FlowFixMe[incompatible-type] const em: StrictReactDOMPropsStyle = styles.inline; -// $FlowFixMe[incompatible-type] const fieldset: StrictReactDOMPropsStyle = styles.block; -// $FlowFixMe[incompatible-type] const footer: StrictReactDOMPropsStyle = styles.block; -// $FlowFixMe[incompatible-type] const form: StrictReactDOMPropsStyle = styles.block; -// $FlowFixMe[incompatible-type] const heading: StrictReactDOMPropsStyle = [styles.block, styles.heading]; -// $FlowFixMe[incompatible-type] const header: StrictReactDOMPropsStyle = styles.block; -// $FlowFixMe[incompatible-type] const hr: StrictReactDOMPropsStyle = [styles.block, styles.hr]; -// $FlowFixMe[incompatible-type] const i: StrictReactDOMPropsStyle = styles.inline; -// $FlowFixMe[incompatible-type] const img: StrictReactDOMPropsStyle = styles.img; -// $FlowFixMe[incompatible-type] const input: StrictReactDOMPropsStyle = [styles.inlineblock, styles.input]; const ins: StrictReactDOMPropsStyle = null; const kbd: StrictReactDOMPropsStyle = null; -// $FlowFixMe[incompatible-type] const label: StrictReactDOMPropsStyle = styles.inline; -// $FlowFixMe[incompatible-type] const li: StrictReactDOMPropsStyle = styles.block; -// $FlowFixMe[incompatible-type] const main: StrictReactDOMPropsStyle = styles.block; -// $FlowFixMe[incompatible-type] const mark: StrictReactDOMPropsStyle = styles.inline; -// $FlowFixMe[incompatible-type] const nav: StrictReactDOMPropsStyle = styles.block; -// $FlowFixMe[incompatible-type] const ol: StrictReactDOMPropsStyle = [styles.list, styles.block]; const optgroup: StrictReactDOMPropsStyle = null; const option: StrictReactDOMPropsStyle = null; -// $FlowFixMe[incompatible-type] const p: StrictReactDOMPropsStyle = styles.block; -// $FlowFixMe[incompatible-type] const pre: StrictReactDOMPropsStyle = [styles.block, styles.codePre]; const s: StrictReactDOMPropsStyle = null; -// $FlowFixMe[incompatible-type] const section: StrictReactDOMPropsStyle = styles.block; -// $FlowFixMe[incompatible-type] const select: StrictReactDOMPropsStyle = styles.inlineblock; -// $FlowFixMe[incompatible-type] const span: StrictReactDOMPropsStyle = styles.inline; -// $FlowFixMe[incompatible-type] const strong: StrictReactDOMPropsStyle = [styles.inline, styles.strong]; -// $FlowFixMe[incompatible-type] const sub: StrictReactDOMPropsStyle = styles.inline; -// $FlowFixMe[incompatible-type] const sup: StrictReactDOMPropsStyle = styles.inline; -// $FlowFixMe[incompatible-type] const textarea: StrictReactDOMPropsStyle = [ styles.inlineblock, styles.textarea ]; const u: StrictReactDOMPropsStyle = null; -// $FlowFixMe[incompatible-type] const ul: StrictReactDOMPropsStyle = [styles.list, styles.block]; export const defaultStyles = { From d1c714d75db1b63c42d61f7952d6e543777a4d3b Mon Sep 17 00:00:00 2001 From: Yamin Yassin Date: Mon, 15 Jun 2026 12:23:42 +0100 Subject: [PATCH 4/4] Add Flow tests for the tightened prop and ref types 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. --- .../types/flowtests/expected-errors.js.flow | 49 +++++++++++++++ .../types/flowtests/html-types-match.js.flow | 5 ++ .../types/flowtests/refs-and-props.js.flow | 63 +++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 packages/react-strict-dom/src/types/flowtests/expected-errors.js.flow create mode 100644 packages/react-strict-dom/src/types/flowtests/refs-and-props.js.flow diff --git a/packages/react-strict-dom/src/types/flowtests/expected-errors.js.flow b/packages/react-strict-dom/src/types/flowtests/expected-errors.js.flow new file mode 100644 index 00000000..c6ce3a34 --- /dev/null +++ b/packages/react-strict-dom/src/types/flowtests/expected-errors.js.flow @@ -0,0 +1,49 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * Type-only NEGATIVE assertions. Each `$FlowExpectedError` is expected to fire, + * proving the corresponding API is genuinely typed (not `any` / `$FlowFixMe`). + * + * @flow strict + */ + +import * as React from 'react'; + +import * as html from '../../native/html'; + +import type { StrictReactDOMProps } from '../StrictReactDOMProps'; +import type { StrictChangeEvent } from '../StrictReactDOMEvents'; + +// `role` must be a valid AriaRole, not an arbitrary number. +// $FlowExpectedError[incompatible-type] +export const badRole: StrictReactDOMProps['role'] = 42; + +// The onChange payload `target.value` is a string, not a number. +declare var changeEvent: StrictChangeEvent; +// $FlowExpectedError[incompatible-type] +export const wrongValueType: number = changeEvent.target.value; + +// A ref to the wrong DOM element type is rejected (refs are not `any`). +declare var inputRef: React.RefSetter; +// $FlowExpectedError[incompatible-type] +export const wrongRef: React.RefSetter = inputRef; + +// `tabIndex` only accepts 0 or -1. +// $FlowExpectedError[incompatible-type] +export const badTabIndex: StrictReactDOMProps['tabIndex'] = 5; + +// An html component rejects a ref of the wrong DOM element type. +declare var divElementRef: React.RefSetter; +export const inputRejectsWrongRef: React.Node = ( + // $FlowExpectedError[incompatible-type] + +); + +// Pass-through handlers deliver an opaque event; a concrete param is rejected. +export const opaqueHandlerRejectsConcreteParam: React.Node = ( + // $FlowExpectedError[incompatible-type] + {}} /> +); diff --git a/packages/react-strict-dom/src/types/flowtests/html-types-match.js.flow b/packages/react-strict-dom/src/types/flowtests/html-types-match.js.flow index 8c7cbc44..201ed640 100644 --- a/packages/react-strict-dom/src/types/flowtests/html-types-match.js.flow +++ b/packages/react-strict-dom/src/types/flowtests/html-types-match.js.flow @@ -177,6 +177,11 @@ declare var main_web: WebHTML['main']; declare var main_native: NativeHTML['main']; (main_native as WebHTML['main']); +declare var mark_web: WebHTML['mark']; +(mark_web as NativeHTML['mark']); +declare var mark_native: NativeHTML['mark']; +(mark_native as WebHTML['mark']); + declare var nav_web: WebHTML['nav']; (nav_web as NativeHTML['nav']); declare var nav_native: NativeHTML['nav']; diff --git a/packages/react-strict-dom/src/types/flowtests/refs-and-props.js.flow b/packages/react-strict-dom/src/types/flowtests/refs-and-props.js.flow new file mode 100644 index 00000000..67c3180a --- /dev/null +++ b/packages/react-strict-dom/src/types/flowtests/refs-and-props.js.flow @@ -0,0 +1,63 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * Type-only POSITIVE assertions for the strict-dom public API. These guard + * against regressions in the component ref typing and the event-payload typing. + * + * @flow strict + */ + +import * as React from 'react'; + +import * as html from '../../native/html'; + +import type { StrictReactDOMProps } from '../StrictReactDOMProps'; +import type { StrictReactDOMInputProps } from '../StrictReactDOMInputProps'; +import type { + StrictChangeEvent, + StrictImageLoadEvent, + StrictKeyEvent +} from '../StrictReactDOMEvents'; + +// The public ref type is the DOM element type, not `any`. (web<->native parity +// is enforced in html-types-match.js.flow.) +declare var divRef: React.RefSetter; +export const acceptsDomRef: React.RefSetter = divRef; + +// An html component accepts a ref of its DOM element type. +declare var inputElementRef: React.RefSetter; +export const inputAcceptsItsRef: React.Node = ( + +); + +// A pass-through handler with no/opaque param is accepted. +export const acceptsOpaqueHandler: React.Node = ( + {}} /> +); + +// Re-synthesized event payloads are concretely typed. +declare var changeEvent: StrictChangeEvent; +export const changeValue: string = changeEvent.target.value; +export const changeType: 'change' = changeEvent.type; + +declare var loadEvent: StrictImageLoadEvent; +export const naturalWidth: ?number = loadEvent.target.naturalWidth; + +declare var keyEvent: StrictKeyEvent; +export const keyName: string = keyEvent.key; + +// Valid global + input prop objects compile. +export const validProps: StrictReactDOMProps = { + id: 'x', + role: 'button', + tabIndex: 0 +}; + +export const validInputProps: StrictReactDOMInputProps = { + value: 'hello', + disabled: true, + type: 'text' +};