From 355b6f055303531944b678949b44d4396b441d60 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Thu, 22 May 2025 14:45:21 -0400 Subject: [PATCH 1/9] chore(hv-element): create component for element rendering (#1169) Improving performance by creating a new `hv-element` component as a replacement for Render.renderElement. This allows memoizing the options and props of the component to reduce unneeded re-renders. [Asana](https://app.asana.com/1/47184964732898/project/1204008699308084/task/1210344460236268?focus=true) --- src/components/hv-list/index.tsx | 15 ++- src/components/hv-section-list/index.tsx | 34 ++--- src/core/components/hv-element/index.tsx | 165 +++++++++++++++++++++++ src/core/components/hv-route/index.tsx | 23 ++-- src/core/components/hv-screen/index.js | 23 ++-- src/core/hyper-ref/index.tsx | 18 ++- 6 files changed, 225 insertions(+), 53 deletions(-) create mode 100644 src/core/components/hv-element/index.tsx diff --git a/src/components/hv-list/index.tsx b/src/components/hv-list/index.tsx index 81ab96398..e58f7a794 100644 --- a/src/components/hv-list/index.tsx +++ b/src/components/hv-list/index.tsx @@ -3,7 +3,6 @@ import * as Dom from 'hyperview/src/services/dom'; import * as Keyboard from 'hyperview/src/services/keyboard'; import * as Logging from 'hyperview/src/services/logging'; import * as Namespaces from 'hyperview/src/services/namespaces'; -import * as Render from 'hyperview/src/services/render'; import type { DOMString, HvComponentOnUpdate, @@ -20,6 +19,7 @@ import { createTestProps, getAncestorByTagName } from 'hyperview/src/services'; import { DOMParser } from '@instawork/xmldom'; import type { ElementRef } from 'react'; import { FlatList } from 'hyperview/src/core/components/scroll'; +import HvElement from 'hyperview/src/core/components/hv-element'; import { LOCAL_NAME } from 'hyperview/src/types'; export default class HvList extends PureComponent { @@ -265,12 +265,13 @@ export default class HvList extends PureComponent { removeClippedSubviews={false} // eslint-disable-next-line @typescript-eslint/no-explicit-any renderItem={({ item }: any) => - item && - Render.renderElement( - item, - this.props.stylesheets, - this.onUpdate, - this.props.options, + item && ( + ) } scrollIndicatorInsets={scrollIndicatorInsets} diff --git a/src/components/hv-section-list/index.tsx b/src/components/hv-section-list/index.tsx index d1323e330..9a6edb935 100644 --- a/src/components/hv-section-list/index.tsx +++ b/src/components/hv-section-list/index.tsx @@ -3,7 +3,6 @@ import * as Dom from 'hyperview/src/services/dom'; import * as Keyboard from 'hyperview/src/services/keyboard'; import * as Logging from 'hyperview/src/services/logging'; import * as Namespaces from 'hyperview/src/services/namespaces'; -import * as Render from 'hyperview/src/services/render'; import type { DOMString, HvComponentOnUpdate, @@ -20,6 +19,7 @@ import type { ScrollParams, State } from './types'; import { createTestProps, getAncestorByTagName } from 'hyperview/src/services'; import { DOMParser } from '@instawork/xmldom'; import type { ElementRef } from 'react'; +import HvElement from 'hyperview/src/core/components/hv-element'; import { SectionList } from 'hyperview/src/core/components/scroll'; const getSectionIndex = ( @@ -376,23 +376,23 @@ export default class HvSectionList extends PureComponent< } removeClippedSubviews={false} // eslint-disable-next-line @typescript-eslint/no-explicit-any - renderItem={({ item }: any): any => - Render.renderElement( - item, - this.props.stylesheets, - this.onUpdate, - this.props.options, - ) - } + renderItem={({ item }: any): any => ( + + )} // eslint-disable-next-line @typescript-eslint/no-explicit-any - renderSectionHeader={({ section: { title } }: any): any => - Render.renderElement( - title, - this.props.stylesheets, - this.onUpdate, - this.props.options, - ) - } + renderSectionHeader={({ section: { title } }: any): any => ( + + )} scrollIndicatorInsets={scrollIndicatorInsets} sections={sections} stickySectionHeadersEnabled={this.getStickySectionHeadersEnabled()} diff --git a/src/core/components/hv-element/index.tsx b/src/core/components/hv-element/index.tsx new file mode 100644 index 000000000..c542c8241 --- /dev/null +++ b/src/core/components/hv-element/index.tsx @@ -0,0 +1,165 @@ +import * as InlineContext from 'hyperview/src/services/inline-context'; +import * as Logging from 'hyperview/src/services/logging'; +import * as Namespaces from 'hyperview/src/services/namespaces'; +import { LOCAL_NAME, NODE_TYPE } from 'hyperview/src/types'; +import React, { useMemo } from 'react'; +import type { HvComponentProps } from 'hyperview/src/types'; + +export default (props: HvComponentProps): JSX.Element | null | string => { + if (!props.element) { + return null; + } + + if (props.element.nodeType === NODE_TYPE.ELEMENT_NODE) { + // Hidden elements don't get rendered + if (props.element.getAttribute('hide') === 'true') { + return null; + } + } + + if (props.element.nodeType === NODE_TYPE.COMMENT_NODE) { + // XML comments don't get rendered. + return null; + } + + if ( + props.element.nodeType === NODE_TYPE.ELEMENT_NODE && + props.element.namespaceURI === Namespaces.HYPERVIEW + ) { + switch (props.element.localName) { + case LOCAL_NAME.BEHAVIOR: + case LOCAL_NAME.MODIFIER: + case LOCAL_NAME.STYLES: + case LOCAL_NAME.STYLE: + // Non-UI elements don't get rendered + return null; + default: + break; + } + } + + const nodeType = useMemo(() => { + return props.element.nodeType; + }, [props.element]); + + const localName = useMemo(() => { + return props.element.localName; + }, [props.element]); + + const namespaceURI = useMemo(() => { + return props.element.namespaceURI; + }, [props.element]); + + const formattingContext = useMemo(() => { + let { inlineFormattingContext } = props.options; + if ( + !props.options.preformatted && + !inlineFormattingContext && + nodeType === NODE_TYPE.ELEMENT_NODE && + localName === LOCAL_NAME.TEXT + ) { + inlineFormattingContext = InlineContext.formatter(props.element); + } + return inlineFormattingContext; + }, [localName, nodeType, props.element, props.options]); + + const componentProps = useMemo(() => { + return { + element: props.element, + onUpdate: props.onUpdate, + options: { + ...props.options, + inlineFormattingContext: formattingContext, + }, + stylesheets: props.stylesheets, + }; + }, [ + formattingContext, + props.element, + props.onUpdate, + props.options, + props.stylesheets, + ]); + + const Component = useMemo(() => { + if (nodeType === NODE_TYPE.ELEMENT_NODE && namespaceURI && localName) { + return props.options.componentRegistry?.getComponent( + namespaceURI, + localName, + ); + } + return undefined; + }, [localName, namespaceURI, nodeType, props.options.componentRegistry]); + + if (nodeType === NODE_TYPE.ELEMENT_NODE) { + if (!namespaceURI) { + Logging.warn( + '`namespaceURI` missing for node:', + props.element.toString(), + ); + return null; + } + if (!localName) { + Logging.warn('`localName` missing for node:', props.element.toString()); + return null; + } + + if (Component) { + // Prepare props for the component + + // Conditionally render the component with a key if it exists, to avoid + // warnings with current React versions, when the key attribute is set + // using the spread operator. + const key = props.element.getAttribute('key'); + + if (key) { + // eslint-disable-next-line react/jsx-props-no-spreading + return ; + } + return ; // eslint-disable-line react/jsx-props-no-spreading + } + + // No component registered for the namespace/local name. + // Warn in case this was an unintended mistake. + Logging.warn( + `No component registered for tag <${localName}> (namespace: ${namespaceURI})`, + ); + } + + if (nodeType === NODE_TYPE.TEXT_NODE) { + // Render non-empty text nodes, when wrapped inside a element + if (props.element.nodeValue) { + if ( + ((props.element.parentNode as Element)?.namespaceURI === + Namespaces.HYPERVIEW && + (props.element.parentNode as Element)?.localName === + LOCAL_NAME.TEXT) || + (props.element.parentNode as Element)?.namespaceURI !== + Namespaces.HYPERVIEW + ) { + if (props.options.preformatted) { + return props.element.nodeValue; + } + // When inline formatting context exists, lookup formatted value using node's index. + if (formattingContext) { + const index = formattingContext[0].indexOf(props.element); + return formattingContext[1][index]; + } + + // Other strings might be whitespaces in non text elements, which we ignore + // However we raise a warning when the string isn't just composed of whitespaces. + const trimmedValue = props.element.nodeValue.trim(); + if (trimmedValue.length > 0) { + Logging.warn( + `Text string "${trimmedValue}" must be rendered within a element`, + ); + } + } + } + } + + if (nodeType === NODE_TYPE.CDATA_SECTION_NODE) { + return props.element.nodeValue; + } + return null; +}; diff --git a/src/core/components/hv-route/index.tsx b/src/core/components/hv-route/index.tsx index 447caf25d..87a210f94 100644 --- a/src/core/components/hv-route/index.tsx +++ b/src/core/components/hv-route/index.tsx @@ -4,7 +4,6 @@ import * as Helpers from 'hyperview/src/services/dom/helpers'; import * as Namespaces from 'hyperview/src/services/namespaces'; import * as NavigationContext from 'hyperview/src/contexts/navigation'; import * as NavigatorService from 'hyperview/src/services/navigator'; -import * as Render from 'hyperview/src/services/render'; import * as Stylesheets from 'hyperview/src/services/stylesheets'; import * as Types from './types'; import * as UrlService from 'hyperview/src/services/url'; @@ -13,12 +12,8 @@ import { BackBehaviorProvider, } from 'hyperview/src/contexts/back-behaviors'; import HvDoc, { StateContext } from 'hyperview/src/core/components/hv-doc'; -import React, { - JSXElementConstructor, - PureComponent, - useContext, - useMemo, -} from 'react'; +import React, { PureComponent, useContext, useMemo } from 'react'; +import HvElement from 'hyperview/src/core/components/hv-element'; import HvNavigator from 'hyperview/src/core/components/hv-navigator'; import HvScreen from 'hyperview/src/core/components/hv-screen'; import { LOCAL_NAME } from 'hyperview/src/types'; @@ -102,12 +97,14 @@ class HvRouteInner extends PureComponent { const styleSheet = Stylesheets.createStylesheets( (preloadElement as unknown) as Document, ); - const component: - | string - | React.ReactElement> - | null = Render.renderElement(body, styleSheet, () => noop, { - componentRegistry: this.componentRegistry, - }); + const component = ( + + ); if (component) { return ( ); } if (!screenElement) { diff --git a/src/core/hyper-ref/index.tsx b/src/core/hyper-ref/index.tsx index a272b1996..c45a80855 100644 --- a/src/core/hyper-ref/index.tsx +++ b/src/core/hyper-ref/index.tsx @@ -22,6 +22,7 @@ import type { PressHandlers, PressPropName, Props, State } from './types'; import React, { PureComponent } from 'react'; import { RefreshControl, Text, TouchableOpacity } from 'react-native'; import { BackBehaviorContext } from 'hyperview/src/contexts/back-behaviors'; +import HvElement from 'hyperview/src/core/components/hv-element'; import { PRESS_TRIGGERS_PROP_NAMES } from './types'; import { ScrollView } from 'hyperview/src/core/components/scroll'; import VisibilityDetectingView from 'hyperview/src/VisibilityDetectingView'; @@ -422,13 +423,18 @@ export default class HyperRef extends PureComponent { render() { // Render the component based on the XML element. Depending on the applied behaviors, // this component will be wrapped with others to provide the necessary interaction. - const children = Render.renderElement( - this.props.element, - this.props.stylesheets, - this.props.onUpdate, - { ...this.props.options, pressed: this.state.pressed, skipHref: true }, + const children = ( + ); - const { ScrollableView, TouchableView, VisibilityView } = this; return ( From d85b564cea77507a53bac63674a38cddf07a1a75 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Wed, 28 May 2025 16:00:50 -0400 Subject: [PATCH 2/9] chore(render): simplify render.renderElement (#1171) Consolidate the logic between `src/services/render` and the new `hv-element` component. - Created a new utility to abstract the `null` render logic and logging - Update render service to use logic and then return the component - Update `hv-element` to use utility for logic and logging - Export `hv-element` for access outside Hyperview - Confirmed external use of `Hyperview.renderElement` with new logic still working (using existing nav-back demo) - Update `nav-back` demo to use component instead of function [Asana](https://app.asana.com/1/47184964732898/project/1204008699308084/task/1210368849843731?focus=true) --- demo/src/Components/NavBack/NavBack.tsx | 17 +-- src/core/components/hv-element/index.tsx | 123 ++++++--------------- src/core/components/hv-root/index.tsx | 3 + src/core/utils.ts | 99 +++++++++++++++++ src/services/render/index.tsx | 132 +++-------------------- 5 files changed, 159 insertions(+), 215 deletions(-) diff --git a/demo/src/Components/NavBack/NavBack.tsx b/demo/src/Components/NavBack/NavBack.tsx index 1b251e797..915063c40 100644 --- a/demo/src/Components/NavBack/NavBack.tsx +++ b/demo/src/Components/NavBack/NavBack.tsx @@ -1,8 +1,8 @@ +import React, { useContext } from 'react'; import type { HvComponentProps } from 'hyperview'; import Hyperview from 'hyperview'; import { NavigationContext } from '@react-navigation/native'; import { findElements } from '../../Helpers'; -import { useContext } from 'react'; export const namespaceURI = 'https://hyperview.org/navigation'; @@ -25,12 +25,15 @@ const NavBack = (props: HvComponentProps) => { if (!element) { return null; } - return (Hyperview.renderElement( - element, - props.stylesheets, - props.onUpdate, - props.options, - ) as unknown) as JSX.Element; + + return ( + + ); }; NavBack.namespaceURI = namespaceURI; diff --git a/src/core/components/hv-element/index.tsx b/src/core/components/hv-element/index.tsx index c542c8241..55d5497de 100644 --- a/src/core/components/hv-element/index.tsx +++ b/src/core/components/hv-element/index.tsx @@ -4,113 +4,58 @@ import * as Namespaces from 'hyperview/src/services/namespaces'; import { LOCAL_NAME, NODE_TYPE } from 'hyperview/src/types'; import React, { useMemo } from 'react'; import type { HvComponentProps } from 'hyperview/src/types'; +import { isRenderableElement } from 'hyperview/src/core/utils'; export default (props: HvComponentProps): JSX.Element | null | string => { - if (!props.element) { - return null; - } - - if (props.element.nodeType === NODE_TYPE.ELEMENT_NODE) { - // Hidden elements don't get rendered - if (props.element.getAttribute('hide') === 'true') { - return null; - } - } - - if (props.element.nodeType === NODE_TYPE.COMMENT_NODE) { - // XML comments don't get rendered. - return null; - } - - if ( - props.element.nodeType === NODE_TYPE.ELEMENT_NODE && - props.element.namespaceURI === Namespaces.HYPERVIEW - ) { - switch (props.element.localName) { - case LOCAL_NAME.BEHAVIOR: - case LOCAL_NAME.MODIFIER: - case LOCAL_NAME.STYLES: - case LOCAL_NAME.STYLE: - // Non-UI elements don't get rendered - return null; - default: - break; - } - } - - const nodeType = useMemo(() => { - return props.element.nodeType; - }, [props.element]); - - const localName = useMemo(() => { - return props.element.localName; - }, [props.element]); - - const namespaceURI = useMemo(() => { - return props.element.namespaceURI; - }, [props.element]); + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; + const { localName, namespaceURI, nodeType } = element; + const { componentRegistry, inlineFormattingContext, preformatted } = options; const formattingContext = useMemo(() => { - let { inlineFormattingContext } = props.options; if ( - !props.options.preformatted && + !preformatted && !inlineFormattingContext && nodeType === NODE_TYPE.ELEMENT_NODE && localName === LOCAL_NAME.TEXT ) { - inlineFormattingContext = InlineContext.formatter(props.element); + return InlineContext.formatter(element); } return inlineFormattingContext; - }, [localName, nodeType, props.element, props.options]); + }, [element, inlineFormattingContext, localName, nodeType, preformatted]); const componentProps = useMemo(() => { return { - element: props.element, - onUpdate: props.onUpdate, + element, + onUpdate, options: { - ...props.options, + ...options, inlineFormattingContext: formattingContext, }, - stylesheets: props.stylesheets, + stylesheets, }; - }, [ - formattingContext, - props.element, - props.onUpdate, - props.options, - props.stylesheets, - ]); + }, [element, formattingContext, onUpdate, options, stylesheets]); const Component = useMemo(() => { if (nodeType === NODE_TYPE.ELEMENT_NODE && namespaceURI && localName) { - return props.options.componentRegistry?.getComponent( - namespaceURI, - localName, - ); + return componentRegistry?.getComponent(namespaceURI, localName); } return undefined; - }, [localName, namespaceURI, nodeType, props.options.componentRegistry]); + }, [localName, namespaceURI, nodeType, componentRegistry]); - if (nodeType === NODE_TYPE.ELEMENT_NODE) { - if (!namespaceURI) { - Logging.warn( - '`namespaceURI` missing for node:', - props.element.toString(), - ); - return null; - } - if (!localName) { - Logging.warn('`localName` missing for node:', props.element.toString()); - return null; - } + // Check if the element is renderable before rendering the component + if (!isRenderableElement(element, options, formattingContext)) { + return null; + } + if (nodeType === NODE_TYPE.ELEMENT_NODE) { if (Component) { // Prepare props for the component // Conditionally render the component with a key if it exists, to avoid // warnings with current React versions, when the key attribute is set // using the spread operator. - const key = props.element.getAttribute('key'); + const key = element.getAttribute('key'); if (key) { // eslint-disable-next-line react/jsx-props-no-spreading @@ -118,37 +63,29 @@ export default (props: HvComponentProps): JSX.Element | null | string => { } return ; // eslint-disable-line react/jsx-props-no-spreading } - - // No component registered for the namespace/local name. - // Warn in case this was an unintended mistake. - Logging.warn( - `No component registered for tag <${localName}> (namespace: ${namespaceURI})`, - ); } if (nodeType === NODE_TYPE.TEXT_NODE) { // Render non-empty text nodes, when wrapped inside a element - if (props.element.nodeValue) { + if (element.nodeValue) { if ( - ((props.element.parentNode as Element)?.namespaceURI === + ((element.parentNode as Element)?.namespaceURI === Namespaces.HYPERVIEW && - (props.element.parentNode as Element)?.localName === - LOCAL_NAME.TEXT) || - (props.element.parentNode as Element)?.namespaceURI !== - Namespaces.HYPERVIEW + (element.parentNode as Element)?.localName === LOCAL_NAME.TEXT) || + (element.parentNode as Element)?.namespaceURI !== Namespaces.HYPERVIEW ) { - if (props.options.preformatted) { - return props.element.nodeValue; + if (preformatted) { + return element.nodeValue; } // When inline formatting context exists, lookup formatted value using node's index. if (formattingContext) { - const index = formattingContext[0].indexOf(props.element); + const index = formattingContext[0].indexOf(element); return formattingContext[1][index]; } // Other strings might be whitespaces in non text elements, which we ignore // However we raise a warning when the string isn't just composed of whitespaces. - const trimmedValue = props.element.nodeValue.trim(); + const trimmedValue = element.nodeValue.trim(); if (trimmedValue.length > 0) { Logging.warn( `Text string "${trimmedValue}" must be rendered within a element`, @@ -159,7 +96,7 @@ export default (props: HvComponentProps): JSX.Element | null | string => { } if (nodeType === NODE_TYPE.CDATA_SECTION_NODE) { - return props.element.nodeValue; + return element.nodeValue; } return null; }; diff --git a/src/core/components/hv-root/index.tsx b/src/core/components/hv-root/index.tsx index bef922895..c5c43eec3 100644 --- a/src/core/components/hv-root/index.tsx +++ b/src/core/components/hv-root/index.tsx @@ -25,6 +25,7 @@ import { UpdateAction, } from 'hyperview/src/types'; import React, { PureComponent } from 'react'; +import HvElement from 'hyperview/src/core/components/hv-element'; import HvRoute from 'hyperview/src/core/components/hv-route'; import { Linking } from 'react-native'; import { XNetworkRetryAction } from 'hyperview/src/services/dom/types'; @@ -43,6 +44,8 @@ export default class Hyperview extends PureComponent { static renderElement = Render.renderElement; + static HvElement = HvElement; + behaviorRegistry: BehaviorRegistry; componentRegistry: Components.Registry; diff --git a/src/core/utils.ts b/src/core/utils.ts index 3dd007333..c05a1549f 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,3 +1,7 @@ +import * as Logging from 'hyperview/src/services/logging'; +import * as Namespaces from 'hyperview/src/services/namespaces'; +import { HvComponentOptions, LOCAL_NAME, NODE_TYPE } from 'hyperview/src/types'; + /** * Provides a random UUID string. * @returns {string} @@ -17,3 +21,98 @@ export const uuid = (): string => { export const uuidNumber = (): number => { return parseInt(uuid().replace(/-/g, ''), 16); }; + +/** + * Checks if an element should be rendered based on the element type and options + * Logging warnings for unexpected conditions + * @param {Element} element + * @param {HvComponentOptions} options + * @param {InlineContext} inlineFormattingContext + * @returns {boolean} + */ +export const isRenderableElement = ( + element: Element, + options: HvComponentOptions, + inlineFormattingContext: [Node[], string[]] | null | undefined, +): boolean => { + if (!element) { + return false; + } + if ( + element.nodeType === NODE_TYPE.ELEMENT_NODE && + element.getAttribute('hide') === 'true' + ) { + // Hidden elements don't get rendered + return false; + } + if (element.nodeType === NODE_TYPE.COMMENT_NODE) { + // XML comments don't get rendered. + return false; + } + if ( + element.nodeType === NODE_TYPE.ELEMENT_NODE && + element.namespaceURI === Namespaces.HYPERVIEW + ) { + switch (element.localName) { + case LOCAL_NAME.BEHAVIOR: + case LOCAL_NAME.MODIFIER: + case LOCAL_NAME.STYLES: + case LOCAL_NAME.STYLE: + // Non-UI elements don't get rendered + return false; + default: + break; + } + } + + if (element.nodeType === NODE_TYPE.ELEMENT_NODE) { + if (!element.namespaceURI) { + Logging.warn('`namespaceURI` missing for node:', element.toString()); + return false; + } + if (!element.localName) { + Logging.warn('`localName` missing for node:', element.toString()); + return false; + } + + if ( + options.componentRegistry?.getComponent( + element.namespaceURI, + element.localName, + ) + ) { + // Has a component registered for the namespace/local name. + return true; + } + // No component registered for the namespace/local name. + // Warn in case this was an unintended mistake. + Logging.warn( + `No component registered for tag <${element.localName}> (namespace: ${element.namespaceURI})`, + ); + } + + if (element.nodeType === NODE_TYPE.TEXT_NODE) { + // Render non-empty text nodes, when wrapped inside a element + if (element.nodeValue) { + if ( + ((element.parentNode as Element)?.namespaceURI === + Namespaces.HYPERVIEW && + (element.parentNode as Element)?.localName === LOCAL_NAME.TEXT) || + (element.parentNode as Element)?.namespaceURI !== Namespaces.HYPERVIEW + ) { + if (options.preformatted) { + return true; + } + // When inline formatting context exists, lookup formatted value using node's index. + if (inlineFormattingContext) { + return true; + } + } + } + } + + if (element.nodeType === NODE_TYPE.CDATA_SECTION_NODE) { + return true; + } + return false; +}; diff --git a/src/services/render/index.tsx b/src/services/render/index.tsx index 8d1729128..4dc5d51a6 100644 --- a/src/services/render/index.tsx +++ b/src/services/render/index.tsx @@ -1,15 +1,14 @@ import * as InlineContext from 'hyperview/src/services/inline-context'; -import * as Logging from 'hyperview/src/services/logging'; -import * as Namespaces from 'hyperview/src/services/namespaces'; import type { - HvComponent, HvComponentOnUpdate, HvComponentOptions, HvComponentProps, StyleSheets, } from 'hyperview/src/types'; import { LOCAL_NAME, NODE_TYPE } from 'hyperview/src/types'; +import HvElement from 'hyperview/src/core/components/hv-element'; import React from 'react'; +import { isRenderableElement } from 'hyperview/src/core/utils'; export const renderElement = ( element: Element | null | undefined, @@ -20,125 +19,28 @@ export const renderElement = ( if (!element) { return null; } - if (element.nodeType === NODE_TYPE.ELEMENT_NODE) { - // Hidden elements don't get rendered - if (element.getAttribute('hide') === 'true') { - return null; - } - } - if (element.nodeType === NODE_TYPE.COMMENT_NODE) { - // XML comments don't get rendered. - return null; - } - if ( - element.nodeType === NODE_TYPE.ELEMENT_NODE && - element.namespaceURI === Namespaces.HYPERVIEW - ) { - switch (element.localName) { - case LOCAL_NAME.BEHAVIOR: - case LOCAL_NAME.MODIFIER: - case LOCAL_NAME.STYLES: - case LOCAL_NAME.STYLE: - // Non-UI elements don't get rendered - return null; - default: - break; - } - } - // Initialize inline formatting context for elements when not already defined - let { inlineFormattingContext } = options; - if ( + const inlineFormattingContext = !options.preformatted && - !inlineFormattingContext && + !options.inlineFormattingContext && element.nodeType === NODE_TYPE.ELEMENT_NODE && element.localName === LOCAL_NAME.TEXT - ) { - inlineFormattingContext = InlineContext.formatter(element); - } - - if (element.nodeType === NODE_TYPE.ELEMENT_NODE) { - if (!element.namespaceURI) { - Logging.warn('`namespaceURI` missing for node:', element.toString()); - return null; - } - if (!element.localName) { - Logging.warn('`localName` missing for node:', element.toString()); - return null; - } - - const Component: - | HvComponent - | undefined = options.componentRegistry?.getComponent( - element.namespaceURI, - element.localName, - ); - - if (Component) { - // Prepare props for the component - const props = { - element, - onUpdate, - options: { - ...options, - inlineFormattingContext, - }, - stylesheets, - }; - - // Conditionally render the component with a key if it exists, to avoid - // warnings with current React versions, when the key attribute is set - // using the spread operator. - const key = element.getAttribute('key'); - - if (key) { - // eslint-disable-next-line react/jsx-props-no-spreading - return ; - } - return ; // eslint-disable-line react/jsx-props-no-spreading - } - - // No component registered for the namespace/local name. - // Warn in case this was an unintended mistake. - Logging.warn( - `No component registered for tag <${element.localName}> (namespace: ${element.namespaceURI})`, - ); - } - - if (element.nodeType === NODE_TYPE.TEXT_NODE) { - // Render non-empty text nodes, when wrapped inside a element - if (element.nodeValue) { - if ( - ((element.parentNode as Element)?.namespaceURI === - Namespaces.HYPERVIEW && - (element.parentNode as Element)?.localName === LOCAL_NAME.TEXT) || - (element.parentNode as Element)?.namespaceURI !== Namespaces.HYPERVIEW - ) { - if (options.preformatted) { - return element.nodeValue; - } - // When inline formatting context exists, lookup formatted value using node's index. - if (inlineFormattingContext) { - const index = inlineFormattingContext[0].indexOf(element); - return inlineFormattingContext[1][index]; - } + ? InlineContext.formatter(element) + : options.inlineFormattingContext; - // Other strings might be whitespaces in non text elements, which we ignore - // However we raise a warning when the string isn't just composed of whitespaces. - const trimmedValue = element.nodeValue.trim(); - if (trimmedValue.length > 0) { - Logging.warn( - `Text string "${trimmedValue}" must be rendered within a element`, - ); - } - } - } + // Check if the element is renderable before rendering the component + if (!isRenderableElement(element, options, inlineFormattingContext)) { + return null; } - if (element.nodeType === NODE_TYPE.CDATA_SECTION_NODE) { - return element.nodeValue; - } - return null; + return ( + + ); }; export const renderChildren = ( From 688517ffb4973d3d16659edd5022ebc9f590873c Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Wed, 28 May 2025 16:54:15 -0400 Subject: [PATCH 3/9] chore(render): Replace renderChildren with component (#1173) Creating a component which can replace the use of the render service `renderChildren`. Provides a mapping of child nodes to `` components. - [Created the new component](https://github.com/Instawork/hyperview/commit/0ece9f6502dba35eade51d8097aee9df1ac439b7) - [Switched four element types from ts to tsx](https://github.com/Instawork/hyperview/commit/dd518da6aa03fe22a3b71d587d7c5971015ed108) - Replaced `React.createElement` implementations with declarative instances [using the new component to render children](https://github.com/Instawork/hyperview/commit/7c73c9bd2f3f45f3ee83ab77c3a9730d4f60928b) - Updated the sticky header logic to use the rendered children and [added a verification of a valid `getAttribute`](https://github.com/Instawork/hyperview/commit/65d8b20ece3b2dba561752ab8911eb666faa8046) [Asana](https://app.asana.com/1/47184964732898/project/1204008699308084/task/1210344460236290?focus=true) --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1210344460236290 --- .../hv-option/{index.ts => index.tsx} | 39 ++++++----- .../{index.ts => index.tsx} | 37 ++++++----- .../hv-select-single/{index.ts => index.tsx} | 37 ++++++----- .../hv-text/{index.ts => index.tsx} | 57 +++++++++------- src/components/hv-view/index.tsx | 65 ++++++++++--------- src/core/components/hv-children/index.tsx | 18 +++++ .../keyboard-aware-scroll-view/types.ts | 1 + src/core/hyper-ref/index.tsx | 12 ++-- src/services/render/index.tsx | 23 +++++++ 9 files changed, 176 insertions(+), 113 deletions(-) rename src/components/hv-option/{index.ts => index.tsx} (80%) rename src/components/hv-select-multiple/{index.ts => index.tsx} (84%) rename src/components/hv-select-single/{index.ts => index.tsx} (84%) rename src/components/hv-text/{index.ts => index.tsx} (52%) create mode 100644 src/core/components/hv-children/index.tsx diff --git a/src/components/hv-option/index.ts b/src/components/hv-option/index.tsx similarity index 80% rename from src/components/hv-option/index.ts rename to src/components/hv-option/index.tsx index efa64c0fb..60efb8ee9 100644 --- a/src/components/hv-option/index.ts +++ b/src/components/hv-option/index.tsx @@ -1,12 +1,9 @@ import * as Behaviors from 'hyperview/src/services/behaviors'; import * as Namespaces from 'hyperview/src/services/namespaces'; -import * as Render from 'hyperview/src/services/render'; -import type { - HvComponentOnUpdate, - HvComponentProps, -} from 'hyperview/src/types'; import React, { PureComponent } from 'react'; import { TouchableWithoutFeedback, View } from 'react-native'; +import HvChildren from 'hyperview/src/core/components/hv-children'; +import type { HvComponentProps } from 'hyperview/src/types'; import { LOCAL_NAME } from 'hyperview/src/types'; import type { State } from './types'; import { createEventHandler } from 'hyperview/src/core/hyper-ref'; @@ -56,6 +53,7 @@ export default class HvOption extends PureComponent { this.props.stylesheets, newOptions, ); + const { key, ...otherProps } = props; // Option renders as an outer TouchableWithoutFeedback view and inner view. // The outer view handles presses, the inner view handles styling. @@ -82,19 +80,24 @@ export default class HvOption extends PureComponent { outerProps.style = { flex: props.style.flex }; } - return React.createElement( - TouchableWithoutFeedback, - outerProps, - React.createElement( - View, - props, - ...Render.renderChildren( - this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, - newOptions, - ), - ), + return ( + + + + + ); } } diff --git a/src/components/hv-select-multiple/index.ts b/src/components/hv-select-multiple/index.tsx similarity index 84% rename from src/components/hv-select-multiple/index.ts rename to src/components/hv-select-multiple/index.tsx index 27243e924..0b19269f5 100644 --- a/src/components/hv-select-multiple/index.ts +++ b/src/components/hv-select-multiple/index.tsx @@ -1,11 +1,7 @@ import * as Namespaces from 'hyperview/src/services/namespaces'; -import * as Render from 'hyperview/src/services/render'; -import type { - DOMString, - HvComponentOnUpdate, - HvComponentProps, -} from 'hyperview/src/types'; +import type { DOMString, HvComponentProps } from 'hyperview/src/types'; import React, { PureComponent } from 'react'; +import HvChildren from 'hyperview/src/core/components/hv-children'; import { LOCAL_NAME } from 'hyperview/src/types'; import { View } from 'react-native'; import { createProps } from 'hyperview/src/services'; @@ -98,18 +94,23 @@ export default class HvSelectMultiple extends PureComponent { const props = createProps(this.props.element, this.props.stylesheets, { ...this.props.options, }); - return React.createElement( - View, - props, - ...Render.renderChildren( - this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, - { - ...this.props.options, - onToggle: this.onToggle, - }, - ), + const { key, ...otherProps } = props; + return ( + + + ); } } diff --git a/src/components/hv-select-single/index.ts b/src/components/hv-select-single/index.tsx similarity index 84% rename from src/components/hv-select-single/index.ts rename to src/components/hv-select-single/index.tsx index 19c7bc4b1..51f043216 100644 --- a/src/components/hv-select-single/index.ts +++ b/src/components/hv-select-single/index.tsx @@ -1,11 +1,7 @@ import * as Namespaces from 'hyperview/src/services/namespaces'; -import * as Render from 'hyperview/src/services/render'; -import type { - DOMString, - HvComponentOnUpdate, - HvComponentProps, -} from 'hyperview/src/types'; +import type { DOMString, HvComponentProps } from 'hyperview/src/types'; import React, { PureComponent } from 'react'; +import HvChildren from 'hyperview/src/core/components/hv-children'; import { LOCAL_NAME } from 'hyperview/src/types'; import { View } from 'react-native'; import { createProps } from 'hyperview/src/services'; @@ -93,18 +89,23 @@ export default class HvSelectSingle extends PureComponent { const props = createProps(this.props.element, this.props.stylesheets, { ...this.props.options, }); - return React.createElement( - View, - props, - ...Render.renderChildren( - this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, - { - ...this.props.options, - onSelect: this.onSelect, - }, - ), + const { key, ...otherProps } = props; + return ( + + + ); } } diff --git a/src/components/hv-text/index.ts b/src/components/hv-text/index.tsx similarity index 52% rename from src/components/hv-text/index.ts rename to src/components/hv-text/index.tsx index 3a10deaed..cfe1da3b1 100644 --- a/src/components/hv-text/index.ts +++ b/src/components/hv-text/index.tsx @@ -1,10 +1,10 @@ import * as Namespaces from 'hyperview/src/services/namespaces'; -import * as Render from 'hyperview/src/services/render'; import type { HvComponentOnUpdate, HvComponentProps, } from 'hyperview/src/types'; import React, { PureComponent } from 'react'; +import HvChildren from 'hyperview/src/core/components/hv-children'; import { LOCAL_NAME } from 'hyperview/src/types'; import { Text } from 'react-native'; import { addHref } from 'hyperview/src/core/hyper-ref'; @@ -15,36 +15,47 @@ export default class HvText extends PureComponent { static localName = LOCAL_NAME.TEXT; - render() { - const { skipHref } = this.props.options || {}; + Component = () => { const props = createProps( this.props.element, this.props.stylesheets, this.props.options, ); - const component = React.createElement( - Text, - props, - ...Render.renderChildren( + const { key, ...otherProps } = props; + return ( + + + + ); + }; + + render() { + const { Component } = this; + const { skipHref } = this.props.options || {}; + + return skipHref ? ( + + ) : ( + addHref( + , this.props.element, this.props.stylesheets, this.props.onUpdate as HvComponentOnUpdate, - { - ...this.props.options, - preformatted: - this.props.element.getAttribute('preformatted') === 'true', - }, - ), + this.props.options, + ) ); - - return skipHref - ? component - : addHref( - component, - this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, - this.props.options, - ); } } diff --git a/src/components/hv-view/index.tsx b/src/components/hv-view/index.tsx index 142c5e070..10586c444 100644 --- a/src/components/hv-view/index.tsx +++ b/src/components/hv-view/index.tsx @@ -112,7 +112,7 @@ export default class HvView extends PureComponent { (acc, element, index) => { if ( typeof element !== 'string' && - element?.props.element?.getAttribute('sticky') === 'true' + element?.props.element?.getAttribute?.('sticky') === 'true' ) { return [...acc, index]; } @@ -173,10 +173,9 @@ export default class HvView extends PureComponent { } } - const children = Render.renderChildren( + const children = Render.buildChildArray( this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, + this.props.onUpdate, { ...this.props.options, ...(scrollable && hasInputFields @@ -189,47 +188,51 @@ export default class HvView extends PureComponent { } : {}), }, + this.props.stylesheets, ); /* eslint-disable react/jsx-props-no-spreading */ if (scrollable) { if (hasInputFields) { - return React.createElement( - KeyboardAwareScrollView, - { - element: this.props.element, - ...this.getCommonProps(), - ...this.getScrollViewProps(children), - ...this.getKeyboardAwareScrollViewProps(inputFieldRefs), - }, - ...children, + return ( + + {children} + ); } - return React.createElement( - ScrollView, - { - element: this.props.element, - ...this.getCommonProps(), - ...this.getScrollViewProps(children), - }, - ...children, + return ( + + {children} + ); } if (!keyboardAvoiding && safeArea) { - return React.createElement( - SafeAreaView, - this.getCommonProps(), - ...children, - ); + return {children}; } if (keyboardAvoiding) { - return React.createElement( - KeyboardAvoidingView, - { ...this.getCommonProps(), behavior: 'position' }, - ...children, + return ( + + {children} + ); } - return React.createElement(View, this.getCommonProps(), ...children); + return ( + + {children} + + ); /* eslint-enable react/jsx-props-no-spreading */ }; diff --git a/src/core/components/hv-children/index.tsx b/src/core/components/hv-children/index.tsx new file mode 100644 index 000000000..b05b7e23f --- /dev/null +++ b/src/core/components/hv-children/index.tsx @@ -0,0 +1,18 @@ +import * as Render from 'hyperview/src/services/render'; +import type { HvComponentProps } from 'hyperview/src/types'; + +/** + * Returns the children of an element as an array of HvElement components + * @param {HvComponentProps} props - The props of the component + * @returns {Array} - The array of children + */ +export default ( + props: HvComponentProps, +): Array => { + return Render.buildChildArray( + props.element, + props.onUpdate, + props.options, + props.stylesheets, + ); +}; diff --git a/src/core/components/keyboard-aware-scroll-view/types.ts b/src/core/components/keyboard-aware-scroll-view/types.ts index bf95b5e11..b21c98ffb 100644 --- a/src/core/components/keyboard-aware-scroll-view/types.ts +++ b/src/core/components/keyboard-aware-scroll-view/types.ts @@ -1,6 +1,7 @@ import { ScrollView, TextInput } from 'react-native'; export type Props = { + children?: React.ReactNode; getTextInputRefs?: () => Array | null | undefined; onScroll?: ScrollView['props']['onScroll']; scrollToBottomOnKBShow?: boolean; diff --git a/src/core/hyper-ref/index.tsx b/src/core/hyper-ref/index.tsx index c45a80855..3f2a345f6 100644 --- a/src/core/hyper-ref/index.tsx +++ b/src/core/hyper-ref/index.tsx @@ -3,7 +3,6 @@ import * as Dom from 'hyperview/src/services/dom'; import * as Events from 'hyperview/src/services/events'; import * as Logging from 'hyperview/src/services/logging'; import * as Namespaces from 'hyperview/src/services/namespaces'; -import * as Render from 'hyperview/src/services/render'; import { BEHAVIOR_ATTRIBUTES, LOCAL_NAME, @@ -466,9 +465,12 @@ export const addHref = ( return component; } - return React.createElement( - HyperRef, - { element, onUpdate, options, stylesheets }, - ...Render.renderChildren(element, stylesheets, onUpdate, options), + return ( + ); }; diff --git a/src/services/render/index.tsx b/src/services/render/index.tsx index 4dc5d51a6..c8cfb12db 100644 --- a/src/services/render/index.tsx +++ b/src/services/render/index.tsx @@ -82,3 +82,26 @@ export const renderChildNodes = ( } return children; }; + +/** + * Converts an element's childNodes into an array of HvElement components. + * @returns An array of HvElement components. + */ +export const buildChildArray = ( + element: Element, + onUpdate: HvComponentOnUpdate, + options: HvComponentOptions, + stylesheets: StyleSheets, +): Array | null | string> => { + if (!element || !element.childNodes) { + return []; + } + return Array.from(element.childNodes).map(node => ( + + )); +}; From 4e83b63c21b93653f27f41ce7ac7b589af30deac Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Wed, 28 May 2025 19:07:03 -0400 Subject: [PATCH 4/9] chore(render): remove demo changes (#1174) Removing changes made to demo until the next HV release. --- demo/src/Components/NavBack/NavBack.tsx | 17 +++++++---------- src/core/components/hv-root/index.tsx | 3 --- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/demo/src/Components/NavBack/NavBack.tsx b/demo/src/Components/NavBack/NavBack.tsx index 915063c40..1b251e797 100644 --- a/demo/src/Components/NavBack/NavBack.tsx +++ b/demo/src/Components/NavBack/NavBack.tsx @@ -1,8 +1,8 @@ -import React, { useContext } from 'react'; import type { HvComponentProps } from 'hyperview'; import Hyperview from 'hyperview'; import { NavigationContext } from '@react-navigation/native'; import { findElements } from '../../Helpers'; +import { useContext } from 'react'; export const namespaceURI = 'https://hyperview.org/navigation'; @@ -25,15 +25,12 @@ const NavBack = (props: HvComponentProps) => { if (!element) { return null; } - - return ( - - ); + return (Hyperview.renderElement( + element, + props.stylesheets, + props.onUpdate, + props.options, + ) as unknown) as JSX.Element; }; NavBack.namespaceURI = namespaceURI; diff --git a/src/core/components/hv-root/index.tsx b/src/core/components/hv-root/index.tsx index c5c43eec3..bef922895 100644 --- a/src/core/components/hv-root/index.tsx +++ b/src/core/components/hv-root/index.tsx @@ -25,7 +25,6 @@ import { UpdateAction, } from 'hyperview/src/types'; import React, { PureComponent } from 'react'; -import HvElement from 'hyperview/src/core/components/hv-element'; import HvRoute from 'hyperview/src/core/components/hv-route'; import { Linking } from 'react-native'; import { XNetworkRetryAction } from 'hyperview/src/services/dom/types'; @@ -44,8 +43,6 @@ export default class Hyperview extends PureComponent { static renderElement = Render.renderElement; - static HvElement = HvElement; - behaviorRegistry: BehaviorRegistry; componentRegistry: Components.Registry; From 8a038fa3421e3bc9c90a50e0ae5a65d070cbaed5 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Fri, 30 May 2025 12:09:33 -0400 Subject: [PATCH 5/9] chore(render): revert view changes (#1176) The updated code was causing an increase in "Each child in a list should have a unique key prop" warnings. It was determined that the legacy code used spreading over the child array which causes React to not issue the warning. [See documentation](https://react.dev/reference/react/createElement#:~:text=You%20should%20only,they%20never%20reorder.). This code temporarily reverts the various components which were throwing the warnings in the Hyperview demo. They should be restored at a later time to restore the intended functionality. - [Added a key to the \](https://github.com/Instawork/hyperview/pull/1176/commits/a6406bc66411b55125102888987c6be44dc31a03) - [Reverted use of declarative syntax where needed](https://github.com/Instawork/hyperview/pull/1176/commits/ec8e044dfb9bdf8054fe305d347c791d5f1530c6) Relates to [Asana](https://app.asana.com/1/47184964732898/project/1205761271270033/task/1210424257505845?focus=true) --- src/components/hv-option/index.tsx | 35 ++++++------ src/components/hv-select-single/index.tsx | 33 ++++++------ src/components/hv-text/index.tsx | 35 ++++++------ src/components/hv-view/index.tsx | 65 ++++++++++++----------- src/services/render/index.tsx | 43 ++++++++++++--- 5 files changed, 116 insertions(+), 95 deletions(-) diff --git a/src/components/hv-option/index.tsx b/src/components/hv-option/index.tsx index 60efb8ee9..a1df9a0d6 100644 --- a/src/components/hv-option/index.tsx +++ b/src/components/hv-option/index.tsx @@ -1,8 +1,8 @@ import * as Behaviors from 'hyperview/src/services/behaviors'; import * as Namespaces from 'hyperview/src/services/namespaces'; +import * as Render from 'hyperview/src/services/render'; import React, { PureComponent } from 'react'; import { TouchableWithoutFeedback, View } from 'react-native'; -import HvChildren from 'hyperview/src/core/components/hv-children'; import type { HvComponentProps } from 'hyperview/src/types'; import { LOCAL_NAME } from 'hyperview/src/types'; import type { State } from './types'; @@ -53,7 +53,6 @@ export default class HvOption extends PureComponent { this.props.stylesheets, newOptions, ); - const { key, ...otherProps } = props; // Option renders as an outer TouchableWithoutFeedback view and inner view. // The outer view handles presses, the inner view handles styling. @@ -80,24 +79,20 @@ export default class HvOption extends PureComponent { outerProps.style = { flex: props.style.flex }; } - return ( - - - - - + // TODO: Replace with + return React.createElement( + TouchableWithoutFeedback, + outerProps, + React.createElement( + View, + props, + ...Render.buildChildArray( + this.props.element, + this.props.onUpdate, + newOptions, + this.props.stylesheets, + ), + ), ); } } diff --git a/src/components/hv-select-single/index.tsx b/src/components/hv-select-single/index.tsx index 51f043216..1161a962d 100644 --- a/src/components/hv-select-single/index.tsx +++ b/src/components/hv-select-single/index.tsx @@ -1,7 +1,7 @@ import * as Namespaces from 'hyperview/src/services/namespaces'; +import * as Render from 'hyperview/src/services/render'; import type { DOMString, HvComponentProps } from 'hyperview/src/types'; import React, { PureComponent } from 'react'; -import HvChildren from 'hyperview/src/core/components/hv-children'; import { LOCAL_NAME } from 'hyperview/src/types'; import { View } from 'react-native'; import { createProps } from 'hyperview/src/services'; @@ -89,23 +89,20 @@ export default class HvSelectSingle extends PureComponent { const props = createProps(this.props.element, this.props.stylesheets, { ...this.props.options, }); - const { key, ...otherProps } = props; - return ( - - - + + // TODO: Replace with + return React.createElement( + View, + props, + ...Render.buildChildArray( + this.props.element, + this.props.onUpdate, + { + ...this.props.options, + onSelect: this.onSelect, + }, + this.props.stylesheets, + ), ); } } diff --git a/src/components/hv-text/index.tsx b/src/components/hv-text/index.tsx index cfe1da3b1..ac26a5a03 100644 --- a/src/components/hv-text/index.tsx +++ b/src/components/hv-text/index.tsx @@ -1,10 +1,10 @@ import * as Namespaces from 'hyperview/src/services/namespaces'; +import * as Render from 'hyperview/src/services/render'; import type { HvComponentOnUpdate, HvComponentProps, } from 'hyperview/src/types'; import React, { PureComponent } from 'react'; -import HvChildren from 'hyperview/src/core/components/hv-children'; import { LOCAL_NAME } from 'hyperview/src/types'; import { Text } from 'react-native'; import { addHref } from 'hyperview/src/core/hyper-ref'; @@ -21,24 +21,21 @@ export default class HvText extends PureComponent { this.props.stylesheets, this.props.options, ); - const { key, ...otherProps } = props; - return ( - - - + + // TODO: Replace with + return React.createElement( + Text, + props, + ...Render.buildChildArray( + this.props.element, + this.props.onUpdate, + { + ...this.props.options, + preformatted: + this.props.element.getAttribute('preformatted') === 'true', + }, + this.props.stylesheets, + ), ); }; diff --git a/src/components/hv-view/index.tsx b/src/components/hv-view/index.tsx index 10586c444..31aa4775b 100644 --- a/src/components/hv-view/index.tsx +++ b/src/components/hv-view/index.tsx @@ -194,45 +194,50 @@ export default class HvView extends PureComponent { /* eslint-disable react/jsx-props-no-spreading */ if (scrollable) { if (hasInputFields) { - return ( - - {children} - + // TODO: Replace with + return React.createElement( + KeyboardAwareScrollView, + { + element: this.props.element, + ...this.getCommonProps(), + ...this.getScrollViewProps(children), + ...this.getKeyboardAwareScrollViewProps(inputFieldRefs), + }, + ...children, ); } - return ( - - {children} - + // TODO: Replace with + return React.createElement( + ScrollView, + { + element: this.props.element, + ...this.getCommonProps(), + ...this.getScrollViewProps(children), + }, + ...children, ); } if (!keyboardAvoiding && safeArea) { - return {children}; + // TODO: Replace with + return React.createElement( + SafeAreaView, + this.getCommonProps(), + ...children, + ); } if (keyboardAvoiding) { - return ( - - {children} - + // TODO: Replace with + return React.createElement( + KeyboardAvoidingView, + { + ...this.getCommonProps(), + behavior: 'position', + }, + ...children, ); } - return ( - - {children} - - ); + // TODO: Replace with + return React.createElement(View, this.getCommonProps(), ...children); /* eslint-enable react/jsx-props-no-spreading */ }; diff --git a/src/services/render/index.tsx b/src/services/render/index.tsx index c8cfb12db..6aa5dc319 100644 --- a/src/services/render/index.tsx +++ b/src/services/render/index.tsx @@ -33,6 +33,18 @@ export const renderElement = ( return null; } + const key = element.getAttribute?.('key'); + if (key && key !== '') { + return ( + + ); + } return ( ( - - )); + return Array.from(element.childNodes).map(node => { + const nodeElement = node as Element; + const key = nodeElement?.getAttribute?.('key'); + if (key && key !== '') { + return ( + + ); + } + return ( + + ); + }); }; From 71e9dc0bf7674dbdb5b5ceb6bd60206922e9162f Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Fri, 30 May 2025 13:55:53 -0400 Subject: [PATCH 6/9] chore: convert class components to functional --- src/components/hv-picker-field/index.ios.tsx | 218 +++++++++-------- src/components/hv-picker-field/index.tsx | 236 +++++++++---------- src/components/hv-switch/index.ts | 114 ++++----- src/components/hv-view/index.tsx | 158 ++++++------- 4 files changed, 354 insertions(+), 372 deletions(-) diff --git a/src/components/hv-picker-field/index.ios.tsx b/src/components/hv-picker-field/index.ios.tsx index d768201cc..6a29fa051 100644 --- a/src/components/hv-picker-field/index.ios.tsx +++ b/src/components/hv-picker-field/index.ios.tsx @@ -5,7 +5,6 @@ import type { HvComponentProps, StyleSheet, } from 'hyperview/src/types'; -import React, { PureComponent } from 'react'; import { createStyleProp, createTestProps, @@ -15,6 +14,7 @@ import Field from './field'; import { LOCAL_NAME } from 'hyperview/src/types'; import Modal from 'hyperview/src/core/components/modal'; import Picker from 'hyperview/src/core/components/picker'; +import React from 'react'; import { View } from 'react-native'; /** @@ -23,18 +23,10 @@ import { View } from 'react-native'; * - On Android, the system picker is rendered inline on the screen. Pressing the picker * opens a system dialog. */ -export default class HvPickerField extends PureComponent { - static namespaceURI = Namespaces.HYPERVIEW; - - static localName = LOCAL_NAME.PICKER_FIELD; - - static getFormInputValues = (element: Element): Array<[string, string]> => { - return getNameValueFormInputValues(element); - }; - - getPickerInitialValue = (): string => { - const value = this.getValue(); - const pickerItems: Element[] = this.getPickerItems(); +const HvPickerField = (props: HvComponentProps) => { + const getPickerInitialValue = (): string => { + const value = getValue(); + const pickerItems: Element[] = getPickerItems(); const valueExists = pickerItems.some( item => item.getAttribute('value') === value, ); @@ -49,14 +41,14 @@ export default class HvPickerField extends PureComponent { /** * Returns a string representing the value in the field. */ - getValue = (): string => this.props.element.getAttribute('value') || ''; + const getValue = (): string => props.element.getAttribute('value') || ''; /** * Gets the label from the picker items for the given value. * If the value doesn't have a picker item, returns null. */ - getLabelForValue = (value: DOMString): string | null | undefined => { - const pickerItemElements: HTMLCollectionOf = this.props.element.getElementsByTagNameNS( + const getLabelForValue = (value: DOMString): string | null | undefined => { + const pickerItemElements: HTMLCollectionOf = props.element.getElementsByTagNameNS( Namespaces.HYPERVIEW, LOCAL_NAME.PICKER_ITEM, ); @@ -81,12 +73,12 @@ export default class HvPickerField extends PureComponent { /** * Returns a string representing the value in the picker. */ - getPickerValue = (): string => - this.props.element.getAttribute('picker-value') || ''; + const getPickerValue = (): string => + props.element.getAttribute('picker-value') || ''; - getPickerItems = (): Element[] => + const getPickerItems = (): Element[] => Array.from( - this.props.element.getElementsByTagNameNS( + props.element.getElementsByTagNameNS( Namespaces.HYPERVIEW, LOCAL_NAME.PICKER_ITEM, ), @@ -96,125 +88,129 @@ export default class HvPickerField extends PureComponent { * Shows the picker, defaulting to the field's value. * If the field is not set, use the first value in the picker. */ - onFieldPress = () => { - const newElement = this.props.element.cloneNode(true) as Element; + const onFieldPress = () => { + const newElement = props.element.cloneNode(true) as Element; newElement.setAttribute('focused', 'true'); - newElement.setAttribute('picker-value', this.getPickerInitialValue()); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); - Behaviors.trigger('focus', newElement, this.props.onUpdate); + newElement.setAttribute('picker-value', getPickerInitialValue()); + props.onUpdate(null, 'swap', props.element, { newElement }); + Behaviors.trigger('focus', newElement, props.onUpdate); }; /** * Hides the picker without applying the chosen value. */ - onCancel = () => { - const newElement = this.props.element.cloneNode(true) as Element; + const onCancel = () => { + const newElement = props.element.cloneNode(true) as Element; newElement.setAttribute('focused', 'false'); newElement.removeAttribute('picker-value'); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); - Behaviors.trigger('blur', newElement, this.props.onUpdate); + props.onUpdate(null, 'swap', props.element, { newElement }); + Behaviors.trigger('blur', newElement, props.onUpdate); }; /** * Hides the picker and applies the chosen value to the field. */ - onDone = () => { - const pickerValue = this.getPickerValue(); - const value = this.getValue(); - const newElement = this.props.element.cloneNode(true) as Element; + const onDone = () => { + const pickerValue = getPickerValue(); + const value = getValue(); + const newElement = props.element.cloneNode(true) as Element; newElement.setAttribute('value', pickerValue); newElement.removeAttribute('picker-value'); newElement.setAttribute('focused', 'false'); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); + props.onUpdate(null, 'swap', props.element, { newElement }); const hasChanged = value !== pickerValue; if (hasChanged) { - Behaviors.trigger('change', newElement, this.props.onUpdate); + Behaviors.trigger('change', newElement, props.onUpdate); } - Behaviors.trigger('blur', newElement, this.props.onUpdate); + Behaviors.trigger('blur', newElement, props.onUpdate); }; /** * Updates the picker value while keeping the picker open. */ - setPickerValue = (value: string) => { - const newElement = this.props.element.cloneNode(true) as Element; + const setPickerValue = (value: string) => { + const newElement = props.element.cloneNode(true) as Element; newElement.setAttribute('picker-value', value); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); + props.onUpdate(null, 'swap', props.element, { newElement }); }; /** * Returns true if the field is focused (and picker is showing). */ - isFocused = (): boolean => - this.props.element.getAttribute('focused') === 'true'; - - render() { - const style: Array = createStyleProp( - this.props.element, - this.props.stylesheets, - { - ...this.props.options, - styleAttr: 'field-text-style', - }, - ); - const { testID, accessibilityLabel } = createTestProps(this.props.element); - const value: DOMString | null | undefined = this.props.element.getAttribute( - 'value', - ); - const placeholderTextColor: - | DOMString - | null - | undefined = this.props.element.getAttribute('placeholderTextColor'); - if ([undefined, null, ''].includes(value) && placeholderTextColor) { - style.push({ color: placeholderTextColor }); - } - - // Gets all of the elements. All picker item elements - // with a value and label are turned into options for the picker. - const children = this.getPickerItems() - .filter(Boolean) - .map((item: Element) => { - const l: DOMString | null | undefined = item.getAttribute('label'); - const v: DOMString | null | undefined = item.getAttribute('value'); - if (!l || typeof v !== 'string') { - return null; - } - return ; - }); - - const focused = this.props.element.getAttribute('focused') === 'true'; - - return ( - - {focused ? ( - - - - {children} - - - - ) : null} - - ); + const isFocused = (): boolean => + props.element.getAttribute('focused') === 'true'; + + const style: Array = createStyleProp( + props.element, + props.stylesheets, + { + ...props.options, + styleAttr: 'field-text-style', + }, + ); + const { testID, accessibilityLabel } = createTestProps(props.element); + const value: DOMString | null | undefined = props.element.getAttribute( + 'value', + ); + const placeholderTextColor: + | DOMString + | null + | undefined = props.element.getAttribute('placeholderTextColor'); + if ([undefined, null, ''].includes(value) && placeholderTextColor) { + style.push({ color: placeholderTextColor }); } -} + + // Gets all of the elements. All picker item elements + // with a value and label are turned into options for the picker. + const children = getPickerItems() + .filter(Boolean) + .map((item: Element) => { + const l: DOMString | null | undefined = item.getAttribute('label'); + const v: DOMString | null | undefined = item.getAttribute('value'); + if (!l || typeof v !== 'string') { + return null; + } + return ; + }); + + const focused = isFocused(); + + return ( + + {focused ? ( + + + + {children} + + + + ) : null} + + ); +}; + +HvPickerField.namespaceURI = Namespaces.HYPERVIEW; +HvPickerField.localName = LOCAL_NAME.PICKER_FIELD; +HvPickerField.getFormInputValues = getNameValueFormInputValues; + +export default HvPickerField; diff --git a/src/components/hv-picker-field/index.tsx b/src/components/hv-picker-field/index.tsx index c2e4d0bb4..8e36240a0 100644 --- a/src/components/hv-picker-field/index.tsx +++ b/src/components/hv-picker-field/index.tsx @@ -5,7 +5,6 @@ import type { HvComponentProps, StyleSheet, } from 'hyperview/src/types'; -import React, { PureComponent } from 'react'; import { createStyleProp, createTestProps, @@ -13,6 +12,7 @@ import { } from 'hyperview/src/services'; import { LOCAL_NAME } from 'hyperview/src/types'; import Picker from 'hyperview/src/core/components/picker'; +import React from 'react'; import { View } from 'react-native'; /** @@ -21,168 +21,164 @@ import { View } from 'react-native'; * - On Android, the system picker is rendered inline on the screen. Pressing the picker * opens a system dialog. */ -export default class HvPickerField extends PureComponent { - static namespaceURI = Namespaces.HYPERVIEW; - - static localName = LOCAL_NAME.PICKER_FIELD; - - static getFormInputValues = (element: Element): Array<[string, string]> => { - return getNameValueFormInputValues(element); - }; - +const HvPickerField = (props: HvComponentProps) => { /** * Returns a string representing the value in the field. */ - getValue = (): string => this.props.element.getAttribute('value') || ''; + const getValue = (): string => props.element.getAttribute('value') || ''; /** * Returns a string representing the value in the picker. */ - getPickerValue = (): string => this.props.element.getAttribute('value') || ''; + const getPickerValue = (): string => + props.element.getAttribute('value') || ''; - getPickerItems = (): Element[] => + const getPickerItems = (): Element[] => Array.from( - this.props.element.getElementsByTagNameNS( + props.element.getElementsByTagNameNS( Namespaces.HYPERVIEW, LOCAL_NAME.PICKER_ITEM, ), ); - onFocus = () => { - const newElement = this.props.element.cloneNode(true) as Element; + const onFocus = () => { + const newElement = props.element.cloneNode(true) as Element; newElement.setAttribute('focused', 'true'); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); - Behaviors.trigger('focus', newElement, this.props.onUpdate); + props.onUpdate(null, 'swap', props.element, { newElement }); + Behaviors.trigger('focus', newElement, props.onUpdate); }; - onBlur = () => { - const newElement = this.props.element.cloneNode(true) as Element; + const onBlur = () => { + const newElement = props.element.cloneNode(true) as Element; newElement.setAttribute('focused', 'false'); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); - Behaviors.trigger('blur', newElement, this.props.onUpdate); + props.onUpdate(null, 'swap', props.element, { newElement }); + Behaviors.trigger('blur', newElement, props.onUpdate); }; /** * Hides the picker without applying the chosen value. */ - onCancel = () => { - const newElement = this.props.element.cloneNode(true) as Element; + const onCancel = () => { + const newElement = props.element.cloneNode(true) as Element; newElement.setAttribute('focused', 'false'); newElement.removeAttribute('picker-value'); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); + props.onUpdate(null, 'swap', props.element, { newElement }); }; /** * Hides the picker and applies the chosen value to the field. */ - onDone = (newValue?: string) => { - const pickerValue = - newValue !== undefined ? newValue : this.getPickerValue(); - const value = this.getValue(); - const newElement = this.props.element.cloneNode(true) as Element; + const onDone = (newValue?: string) => { + const pickerValue = newValue !== undefined ? newValue : getPickerValue(); + const value = getValue(); + const newElement = props.element.cloneNode(true) as Element; newElement.setAttribute('value', pickerValue); newElement.removeAttribute('picker-value'); newElement.setAttribute('focused', 'false'); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); + props.onUpdate(null, 'swap', props.element, { newElement }); const hasChanged = value !== pickerValue; if (hasChanged) { - Behaviors.trigger('change', newElement, this.props.onUpdate); + Behaviors.trigger('change', newElement, props.onUpdate); } }; - render() { - const onChange = (value: string | null | undefined) => { - if (value === undefined) { - this.onCancel(); - } else { - this.onDone(value || ''); - } - }; - - const style: Array = createStyleProp( - this.props.element, - this.props.stylesheets, - { - ...this.props.options, - styleAttr: 'field-text-style', - }, - ); - const { testID, accessibilityLabel } = createTestProps(this.props.element); - const value: DOMString | null | undefined = this.props.element.getAttribute( - 'value', - ); - const placeholderTextColor: - | DOMString - | null - | undefined = this.props.element.getAttribute('placeholderTextColor'); - if ([undefined, null, ''].includes(value) && placeholderTextColor) { - style.push({ color: placeholderTextColor }); + const onChange = (value: string | null | undefined) => { + if (value === undefined) { + onCancel(); + } else { + onDone(value || ''); } + }; - const fieldStyle: Array = createStyleProp( - this.props.element, - this.props.stylesheets, - { - ...this.props.options, - styleAttr: 'field-style', - }, - ); + const style: Array = createStyleProp( + props.element, + props.stylesheets, + { + ...props.options, + styleAttr: 'field-text-style', + }, + ); + const { testID, accessibilityLabel } = createTestProps(props.element); + const value: DOMString | null | undefined = props.element.getAttribute( + 'value', + ); + const placeholderTextColor: + | DOMString + | null + | undefined = props.element.getAttribute('placeholderTextColor'); + if ([undefined, null, ''].includes(value) && placeholderTextColor) { + style.push({ color: placeholderTextColor }); + } - // Gets all of the elements. All picker item elements - // with a value and label are turned into options for the picker. - const items = this.getPickerItems(); - const children = items.filter(Boolean).map((item: Element) => { - const l: DOMString | null | undefined = item.getAttribute('label'); - const v: DOMString | null | undefined = item.getAttribute('value'); - if (!l || typeof v !== 'string') { - return null; - } - const enabled = ['', 'true', null].includes(item.getAttribute('enabled')); - return ( - - ); - }); - - // If there are no items, or the first item has a value, - // we need to add an empty option that acts as a placeholder. - if (items.length > 0 && items[0].getAttribute('value') !== '') { - children.unshift( - , - ); + const fieldStyle: Array = createStyleProp( + props.element, + props.stylesheets, + { + ...props.options, + styleAttr: 'field-style', + }, + ); + + // Gets all of the elements. All picker item elements + // with a value and label are turned into options for the picker. + const items = getPickerItems(); + const children = items.filter(Boolean).map((item: Element) => { + const l: DOMString | null | undefined = item.getAttribute('label'); + const v: DOMString | null | undefined = item.getAttribute('value'); + if (!l || typeof v !== 'string') { + return null; } - + const enabled = ['', 'true', null].includes(item.getAttribute('enabled')); return ( - - - {children} - - + + ); + }); + + // If there are no items, or the first item has a value, + // we need to add an empty option that acts as a placeholder. + if (items.length > 0 && items[0].getAttribute('value') !== '') { + children.unshift( + , ); } -} + + return ( + + + {children} + + + ); +}; + +HvPickerField.namespaceURI = Namespaces.HYPERVIEW; +HvPickerField.localName = LOCAL_NAME.PICKER_FIELD; +HvPickerField.getFormInputValues = getNameValueFormInputValues; + +export default HvPickerField; diff --git a/src/components/hv-switch/index.ts b/src/components/hv-switch/index.ts index 048ff7249..1a3b30467 100644 --- a/src/components/hv-switch/index.ts +++ b/src/components/hv-switch/index.ts @@ -1,7 +1,6 @@ import * as Behaviors from 'hyperview/src/services/behaviors'; import * as Namespaces from 'hyperview/src/services/namespaces'; import { Platform, StyleSheet, Switch } from 'react-native'; -import React, { PureComponent } from 'react'; import { createStyleProp, getNameValueFormInputValues, @@ -9,6 +8,7 @@ import { import type { ColorValue } from './style-sheet'; import type { HvComponentProps } from 'hyperview/src/types'; import { LOCAL_NAME } from 'hyperview/src/types'; +import React from 'react'; import normalizeColor from './style-sheet'; /* eslint no-bitwise: ["error", { "allow": [">>", "&"] }] */ @@ -31,72 +31,62 @@ function darkenColor(color: ColorValue, percent: number): ColorValue { return `#${newRgb}${A}`; } -export default class HvSwitch extends PureComponent { - static namespaceURI = Namespaces.HYPERVIEW; +const HvSwitch = (props: HvComponentProps) => { + if (props.element.getAttribute('hide') === 'true') { + return null; + } - static localName = LOCAL_NAME.SWITCH; + const unselectedStyle = StyleSheet.flatten( + createStyleProp(props.element, props.stylesheets, { + selected: false, + }), + ); + const selectedStyle = StyleSheet.flatten( + createStyleProp(props.element, props.stylesheets, { + selected: true, + }), + ); - static getFormInputValues = (element: Element): Array<[string, string]> => { - return getNameValueFormInputValues(element); + const p = { + ios_backgroundColor: unselectedStyle + ? unselectedStyle.backgroundColor + : null, + onChange: () => { + const newElement = props.element.cloneNode(true) as Element; + Behaviors.trigger('change', newElement, props.onUpdate); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onValueChange: (value: any) => { + const newElement = props.element.cloneNode(true) as Element; + newElement.setAttribute('value', value ? 'on' : 'off'); + props.onUpdate(null, 'swap', props.element, { newElement }); + }, + // iOS thumbColor default + thumbColor: unselectedStyle?.color || selectedStyle?.color, + trackColor: { + false: unselectedStyle ? unselectedStyle.backgroundColor : null, + true: selectedStyle ? selectedStyle.backgroundColor : null, + }, + value: props.element.getAttribute('value') === 'on', }; - render() { - if (this.props.element.getAttribute('hide') === 'true') { - return null; - } - - const unselectedStyle = StyleSheet.flatten( - createStyleProp(this.props.element, this.props.stylesheets, { - selected: false, - }), - ); - const selectedStyle = StyleSheet.flatten( - createStyleProp(this.props.element, this.props.stylesheets, { - selected: true, - }), - ); + // android thumbColor default + if (Platform.OS === 'android' && !p.thumbColor && p.trackColor.true) { + p.thumbColor = p.value ? darkenColor(p.trackColor.true, 0.3) : '#FFFFFF'; + } - const props = { - ios_backgroundColor: unselectedStyle - ? unselectedStyle.backgroundColor - : null, - onChange: () => { - const newElement = this.props.element.cloneNode(true) as Element; - Behaviors.trigger('change', newElement, this.props.onUpdate); - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onValueChange: (value: any) => { - const newElement = this.props.element.cloneNode(true) as Element; - newElement.setAttribute('value', value ? 'on' : 'off'); - this.props.onUpdate(null, 'swap', this.props.element, { newElement }); - }, - // iOS thumbColor default - thumbColor: unselectedStyle?.color || selectedStyle?.color, - trackColor: { - false: unselectedStyle ? unselectedStyle.backgroundColor : null, - true: selectedStyle ? selectedStyle.backgroundColor : null, - }, - value: this.props.element.getAttribute('value') === 'on', - }; + // if thumbColors are explicitly specified, override defaults + if (p.value && selectedStyle?.color) { + p.thumbColor = selectedStyle.color; + } else if (!p.value && unselectedStyle?.color) { + p.thumbColor = unselectedStyle.color; + } - // android thumbColor default - if ( - Platform.OS === 'android' && - !props.thumbColor && - props.trackColor.true - ) { - props.thumbColor = props.value - ? darkenColor(props.trackColor.true, 0.3) - : '#FFFFFF'; - } + return React.createElement(Switch, p); +}; - // if thumbColors are explicitly specified, override defaults - if (props.value && selectedStyle?.color) { - props.thumbColor = selectedStyle.color; - } else if (!props.value && unselectedStyle?.color) { - props.thumbColor = unselectedStyle.color; - } +HvSwitch.namespaceURI = Namespaces.HYPERVIEW; +HvSwitch.localName = LOCAL_NAME.SWITCH; +HvSwitch.getFormInputValues = getNameValueFormInputValues; - return React.createElement(Switch, props); - } -} +export default HvSwitch; diff --git a/src/components/hv-view/index.tsx b/src/components/hv-view/index.tsx index 31aa4775b..e0d59594f 100644 --- a/src/components/hv-view/index.tsx +++ b/src/components/hv-view/index.tsx @@ -23,39 +23,26 @@ import { KeyboardAwareScrollView, ScrollView, } from 'hyperview/src/core/components/scroll'; -import React, { PureComponent } from 'react'; +import React, { useMemo } from 'react'; import { ATTRIBUTES } from './types'; import { LOCAL_NAME } from 'hyperview/src/types'; import { addHref } from 'hyperview/src/core/hyper-ref'; import { createStyleProp } from 'hyperview/src/services'; -export default class HvView extends PureComponent { - static namespaceURI = Namespaces.HYPERVIEW; - - static localName = LOCAL_NAME.VIEW; - - static localNameAliases = [ - LOCAL_NAME.BODY, - LOCAL_NAME.FORM, - LOCAL_NAME.HEADER, - LOCAL_NAME.ITEM, - LOCAL_NAME.ITEMS, - LOCAL_NAME.SECTION_TITLE, - ]; - - get attributes(): Attributes { +const HvView = (props: HvComponentProps) => { + const attributes = useMemo((): Attributes => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return Object.values(ATTRIBUTES).reduce>( - (attributes, name: string) => ({ - ...attributes, - [name]: this.props.element.getAttribute(name), + (acc, name: string) => ({ + ...acc, + [name]: props.element.getAttribute(name), }), {}, ); - } + }, [props.element]); - hasInputFields = (): boolean => { - const textFields = this.props.element.getElementsByTagNameNS( + const checkHasInputFields = (): boolean => { + const textFields = props.element.getElementsByTagNameNS( Namespaces.HYPERVIEW, 'text-field', ); @@ -63,17 +50,17 @@ export default class HvView extends PureComponent { return textFields.length > 0; }; - getCommonProps = (): CommonProps => { + const getCommonProps = (): CommonProps => { // TODO: fix type // createStyleProp returns an array of StyleSheet, // but it appears something wants a ViewStyle, which is not // not an array type. Does a type need to get fixed elsewhere? const style = (createStyleProp( - this.props.element, - this.props.stylesheets, - this.props.options, + props.element, + props.stylesheets, + props.options, ) as unknown) as ViewStyle; - const id = this.props.element.getAttribute('id'); + const id = props.element.getAttribute('id'); if (!id) { return { style }; } @@ -83,19 +70,17 @@ export default class HvView extends PureComponent { return { accessibilityLabel: id, style }; }; - getScrollViewProps = ( + const getScrollViewProps = ( children: Array | null | string>, ): ScrollViewProps => { const horizontal = - this.attributes[ATTRIBUTES.SCROLL_ORIENTATION] === 'horizontal'; + attributes[ATTRIBUTES.SCROLL_ORIENTATION] === 'horizontal'; const showScrollIndicator = - this.attributes[ATTRIBUTES.SHOWS_SCROLL_INDICATOR] !== 'false'; + attributes[ATTRIBUTES.SHOWS_SCROLL_INDICATOR] !== 'false'; - const contentContainerStyle = this.attributes[ - ATTRIBUTES.CONTENT_CONTAINER_STYLE - ] - ? createStyleProp(this.props.element, this.props.stylesheets, { - ...this.props.options, + const contentContainerStyle = attributes[ATTRIBUTES.CONTENT_CONTAINER_STYLE] + ? createStyleProp(props.element, props.stylesheets, { + ...props.options, styleAttr: ATTRIBUTES.CONTENT_CONTAINER_STYLE, }) : undefined; @@ -124,7 +109,7 @@ export default class HvView extends PureComponent { return { contentContainerStyle, horizontal, - keyboardDismissMode: Keyboard.getKeyboardDismissMode(this.props.element), + keyboardDismissMode: Keyboard.getKeyboardDismissMode(props.element), scrollIndicatorInsets, showsHorizontalScrollIndicator: horizontal && showScrollIndicator, showsVerticalScrollIndicator: !horizontal && showScrollIndicator, @@ -132,9 +117,9 @@ export default class HvView extends PureComponent { }; }; - getScrollToInputAdditionalOffsetProp = (): number => { + const getScrollToInputAdditionalOffsetProp = (): number => { const defaultOffset = 120; - const offsetStr = this.attributes[ATTRIBUTES.SCROLL_TO_INPUT_OFFSET]; + const offsetStr = attributes[ATTRIBUTES.SCROLL_TO_INPUT_OFFSET]; if (offsetStr) { const offset = parseInt(offsetStr, 10); return Number.isNaN(offset) ? 0 : defaultOffset; @@ -142,7 +127,7 @@ export default class HvView extends PureComponent { return defaultOffset; }; - getKeyboardAwareScrollViewProps = ( + const getKeyboardAwareScrollViewProps = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any inputFieldRefs: Array, ): KeyboardAwareScrollViewProps => ({ @@ -150,23 +135,22 @@ export default class HvView extends PureComponent { getTextInputRefs: () => inputFieldRefs, keyboardShouldPersistTaps: 'handled', scrollEventThrottle: 16, - scrollToInputAdditionalOffset: this.getScrollToInputAdditionalOffsetProp(), + scrollToInputAdditionalOffset: getScrollToInputAdditionalOffsetProp(), }); - Content = () => { + const Content = (p: HvComponentProps) => { /** * Useful when you want keyboard avoiding behavior in non-scrollable views. * Note: Android has built-in support for avoiding keyboard. */ const keyboardAvoiding = - this.attributes[ATTRIBUTES.AVOID_KEYBOARD] === 'true' && - Platform.OS === 'ios'; + attributes[ATTRIBUTES.AVOID_KEYBOARD] === 'true' && Platform.OS === 'ios'; - const hasInputFields = this.hasInputFields(); + const hasInputFields = checkHasInputFields(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const inputFieldRefs: Array = []; - const scrollable = this.attributes[ATTRIBUTES.SCROLL] === 'true'; - const safeArea = this.attributes[ATTRIBUTES.SAFE_AREA] === 'true'; + const scrollable = attributes[ATTRIBUTES.SCROLL] === 'true'; + const safeArea = attributes[ATTRIBUTES.SAFE_AREA] === 'true'; if (safeArea) { if (keyboardAvoiding || scrollable) { Logging.warn('safe-area is incompatible with scroll or avoid-keyboard'); @@ -174,10 +158,10 @@ export default class HvView extends PureComponent { } const children = Render.buildChildArray( - this.props.element, - this.props.onUpdate, + p.element, + p.onUpdate, { - ...this.props.options, + ...p.options, ...(scrollable && hasInputFields ? { registerInputHandler: ref => { @@ -188,7 +172,7 @@ export default class HvView extends PureComponent { } : {}), }, - this.props.stylesheets, + p.stylesheets, ); /* eslint-disable react/jsx-props-no-spreading */ @@ -198,10 +182,10 @@ export default class HvView extends PureComponent { return React.createElement( KeyboardAwareScrollView, { - element: this.props.element, - ...this.getCommonProps(), - ...this.getScrollViewProps(children), - ...this.getKeyboardAwareScrollViewProps(inputFieldRefs), + element: p.element, + ...getCommonProps(), + ...getScrollViewProps(children), + ...getKeyboardAwareScrollViewProps(inputFieldRefs), }, ...children, ); @@ -210,49 +194,65 @@ export default class HvView extends PureComponent { return React.createElement( ScrollView, { - element: this.props.element, - ...this.getCommonProps(), - ...this.getScrollViewProps(children), + element: p.element, + ...getCommonProps(), + ...getScrollViewProps(children), }, ...children, ); } if (!keyboardAvoiding && safeArea) { // TODO: Replace with - return React.createElement( - SafeAreaView, - this.getCommonProps(), - ...children, - ); + return React.createElement(SafeAreaView, getCommonProps(), ...children); } if (keyboardAvoiding) { // TODO: Replace with return React.createElement( KeyboardAvoidingView, { - ...this.getCommonProps(), + ...getCommonProps(), behavior: 'position', }, ...children, ); } // TODO: Replace with - return React.createElement(View, this.getCommonProps(), ...children); + return React.createElement(View, getCommonProps(), ...children); /* eslint-enable react/jsx-props-no-spreading */ }; - render() { - const { Content } = this; - return this.props.options?.skipHref ? ( - - ) : ( - addHref( - , - this.props.element, - this.props.stylesheets, - this.props.onUpdate as HvComponentOnUpdate, - this.props.options, - ) - ); - } -} + return props.options?.skipHref ? ( + + ) : ( + addHref( + , + props.element, + props.stylesheets, + props.onUpdate as HvComponentOnUpdate, + props.options, + ) + ); +}; + +HvView.namespaceURI = Namespaces.HYPERVIEW; +HvView.localName = LOCAL_NAME.VIEW; +HvView.localNameAliases = [ + LOCAL_NAME.BODY, + LOCAL_NAME.FORM, + LOCAL_NAME.HEADER, + LOCAL_NAME.ITEM, + LOCAL_NAME.ITEMS, + LOCAL_NAME.SECTION_TITLE, +]; + +export default HvView; From 0469bc4a0a0b482cc54790b7ab44e5c08921d3ca Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Fri, 30 May 2025 14:26:03 -0400 Subject: [PATCH 7/9] chore: memoize style creation --- src/components/hv-date-field/field/index.tsx | 20 ++++++---- .../hv-picker-field/field-label/index.tsx | 26 +++++++++---- src/components/hv-picker-field/index.ios.tsx | 16 ++++---- src/components/hv-picker-field/index.tsx | 30 +++++++-------- src/components/hv-switch/index.ts | 32 ++++++++++------ src/components/hv-view/index.tsx | 37 ++++++++++++------- src/core/components/modal/index.tsx | 30 ++++++++------- .../components/modal/modal-button/index.tsx | 18 ++++----- 8 files changed, 123 insertions(+), 86 deletions(-) diff --git a/src/components/hv-date-field/field/index.tsx b/src/components/hv-date-field/field/index.tsx index 24dabbf3e..bbd3c7d4e 100644 --- a/src/components/hv-date-field/field/index.tsx +++ b/src/components/hv-date-field/field/index.tsx @@ -1,5 +1,5 @@ import * as Contexts from 'hyperview/src/contexts'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { StyleSheet, TouchableWithoutFeedback, View } from 'react-native'; import { createProps, createStyleProp } from 'hyperview/src/services'; import FieldLabel from '../field-label'; @@ -22,13 +22,17 @@ export default (props: Props) => { styleAttr: 'field-style', }); - const labelStyle: StyleSheetType = StyleSheet.flatten( - createStyleProp(props.element, props.stylesheets, { - ...props.options, - focused: props.focused, - pressed, - styleAttr: 'field-text-style', - }), + const labelStyle: StyleSheetType = useMemo( + () => + StyleSheet.flatten( + createStyleProp(props.element, props.stylesheets, { + ...props.options, + focused: props.focused, + pressed, + styleAttr: 'field-text-style', + }), + ), + [props.element, props.stylesheets, props.options, props.focused, pressed], ); return ( diff --git a/src/components/hv-picker-field/field-label/index.tsx b/src/components/hv-picker-field/field-label/index.tsx index 1a79e670d..eb8dcddbd 100644 --- a/src/components/hv-picker-field/field-label/index.tsx +++ b/src/components/hv-picker-field/field-label/index.tsx @@ -1,7 +1,7 @@ import * as FontScale from 'hyperview/src/services/font-scale'; +import React, { useMemo } from 'react'; import { StyleSheet, Text } from 'react-native'; import type { Props } from './types'; -import React from 'react'; import type { StyleSheet as StyleSheetType } from 'hyperview/src/types'; import { createStyleProp } from 'hyperview/src/services'; @@ -14,13 +14,23 @@ export default (props: Props) => { const placeholderTextColor = props.element.getAttribute( 'placeholderTextColor', ); - const style: StyleSheetType = StyleSheet.flatten( - createStyleProp(props.element, props.stylesheets, { - ...props.options, - focused: props.focused, - pressed: props.pressed, - styleAttr: 'field-text-style', - }), + const style: StyleSheetType = useMemo( + () => + StyleSheet.flatten( + createStyleProp(props.element, props.stylesheets, { + ...props.options, + focused: props.focused, + pressed: props.pressed, + styleAttr: 'field-text-style', + }), + ), + [ + props.element, + props.stylesheets, + props.options, + props.focused, + props.pressed, + ], ); const labelStyles: Array = [style]; diff --git a/src/components/hv-picker-field/index.ios.tsx b/src/components/hv-picker-field/index.ios.tsx index 6a29fa051..3f75c250f 100644 --- a/src/components/hv-picker-field/index.ios.tsx +++ b/src/components/hv-picker-field/index.ios.tsx @@ -5,6 +5,7 @@ import type { HvComponentProps, StyleSheet, } from 'hyperview/src/types'; +import React, { useMemo } from 'react'; import { createStyleProp, createTestProps, @@ -14,7 +15,6 @@ import Field from './field'; import { LOCAL_NAME } from 'hyperview/src/types'; import Modal from 'hyperview/src/core/components/modal'; import Picker from 'hyperview/src/core/components/picker'; -import React from 'react'; import { View } from 'react-native'; /** @@ -140,13 +140,13 @@ const HvPickerField = (props: HvComponentProps) => { const isFocused = (): boolean => props.element.getAttribute('focused') === 'true'; - const style: Array = createStyleProp( - props.element, - props.stylesheets, - { - ...props.options, - styleAttr: 'field-text-style', - }, + const style: Array = useMemo( + () => + createStyleProp(props.element, props.stylesheets, { + ...props.options, + styleAttr: 'field-text-style', + }), + [props.element, props.stylesheets, props.options], ); const { testID, accessibilityLabel } = createTestProps(props.element); const value: DOMString | null | undefined = props.element.getAttribute( diff --git a/src/components/hv-picker-field/index.tsx b/src/components/hv-picker-field/index.tsx index 8e36240a0..cc5d9492f 100644 --- a/src/components/hv-picker-field/index.tsx +++ b/src/components/hv-picker-field/index.tsx @@ -5,6 +5,7 @@ import type { HvComponentProps, StyleSheet, } from 'hyperview/src/types'; +import React, { useMemo } from 'react'; import { createStyleProp, createTestProps, @@ -12,7 +13,6 @@ import { } from 'hyperview/src/services'; import { LOCAL_NAME } from 'hyperview/src/types'; import Picker from 'hyperview/src/core/components/picker'; -import React from 'react'; import { View } from 'react-native'; /** @@ -91,13 +91,13 @@ const HvPickerField = (props: HvComponentProps) => { } }; - const style: Array = createStyleProp( - props.element, - props.stylesheets, - { - ...props.options, - styleAttr: 'field-text-style', - }, + const style: Array = useMemo( + () => + createStyleProp(props.element, props.stylesheets, { + ...props.options, + styleAttr: 'field-text-style', + }), + [props.element, props.stylesheets, props.options], ); const { testID, accessibilityLabel } = createTestProps(props.element); const value: DOMString | null | undefined = props.element.getAttribute( @@ -111,13 +111,13 @@ const HvPickerField = (props: HvComponentProps) => { style.push({ color: placeholderTextColor }); } - const fieldStyle: Array = createStyleProp( - props.element, - props.stylesheets, - { - ...props.options, - styleAttr: 'field-style', - }, + const fieldStyle: Array = useMemo( + () => + createStyleProp(props.element, props.stylesheets, { + ...props.options, + styleAttr: 'field-style', + }), + [props.element, props.stylesheets, props.options], ); // Gets all of the elements. All picker item elements diff --git a/src/components/hv-switch/index.ts b/src/components/hv-switch/index.ts index 1a3b30467..d80db2d0a 100644 --- a/src/components/hv-switch/index.ts +++ b/src/components/hv-switch/index.ts @@ -1,6 +1,7 @@ import * as Behaviors from 'hyperview/src/services/behaviors'; import * as Namespaces from 'hyperview/src/services/namespaces'; import { Platform, StyleSheet, Switch } from 'react-native'; +import React, { useMemo } from 'react'; import { createStyleProp, getNameValueFormInputValues, @@ -8,7 +9,6 @@ import { import type { ColorValue } from './style-sheet'; import type { HvComponentProps } from 'hyperview/src/types'; import { LOCAL_NAME } from 'hyperview/src/types'; -import React from 'react'; import normalizeColor from './style-sheet'; /* eslint no-bitwise: ["error", { "allow": [">>", "&"] }] */ @@ -32,21 +32,29 @@ function darkenColor(color: ColorValue, percent: number): ColorValue { } const HvSwitch = (props: HvComponentProps) => { + const unselectedStyle = useMemo( + () => + StyleSheet.flatten( + createStyleProp(props.element, props.stylesheets, { + selected: false, + }), + ), + [props.element, props.stylesheets], + ); + const selectedStyle = useMemo( + () => + StyleSheet.flatten( + createStyleProp(props.element, props.stylesheets, { + selected: true, + }), + ), + [props.element, props.stylesheets], + ); + if (props.element.getAttribute('hide') === 'true') { return null; } - const unselectedStyle = StyleSheet.flatten( - createStyleProp(props.element, props.stylesheets, { - selected: false, - }), - ); - const selectedStyle = StyleSheet.flatten( - createStyleProp(props.element, props.stylesheets, { - selected: true, - }), - ); - const p = { ios_backgroundColor: unselectedStyle ? unselectedStyle.backgroundColor diff --git a/src/components/hv-view/index.tsx b/src/components/hv-view/index.tsx index e0d59594f..c2b4074ac 100644 --- a/src/components/hv-view/index.tsx +++ b/src/components/hv-view/index.tsx @@ -41,6 +41,29 @@ const HvView = (props: HvComponentProps) => { ); }, [props.element]); + // TODO: fix type + // createStyleProp returns an array of StyleSheet, + // but it appears something wants a ViewStyle, which is not + // not an array type. Does a type need to get fixed elsewhere? + const style = useMemo( + () => + (createStyleProp( + props.element, + props.stylesheets, + props.options, + ) as unknown) as ViewStyle, + [props.element, props.stylesheets, props.options], + ); + + const containerStyle = useMemo( + () => + createStyleProp(props.element, props.stylesheets, { + ...props.options, + styleAttr: ATTRIBUTES.CONTENT_CONTAINER_STYLE, + }), + [props.element, props.stylesheets, props.options], + ); + const checkHasInputFields = (): boolean => { const textFields = props.element.getElementsByTagNameNS( Namespaces.HYPERVIEW, @@ -51,15 +74,6 @@ const HvView = (props: HvComponentProps) => { }; const getCommonProps = (): CommonProps => { - // TODO: fix type - // createStyleProp returns an array of StyleSheet, - // but it appears something wants a ViewStyle, which is not - // not an array type. Does a type need to get fixed elsewhere? - const style = (createStyleProp( - props.element, - props.stylesheets, - props.options, - ) as unknown) as ViewStyle; const id = props.element.getAttribute('id'); if (!id) { return { style }; @@ -79,10 +93,7 @@ const HvView = (props: HvComponentProps) => { attributes[ATTRIBUTES.SHOWS_SCROLL_INDICATOR] !== 'false'; const contentContainerStyle = attributes[ATTRIBUTES.CONTENT_CONTAINER_STYLE] - ? createStyleProp(props.element, props.stylesheets, { - ...props.options, - styleAttr: ATTRIBUTES.CONTENT_CONTAINER_STYLE, - }) + ? containerStyle : undefined; // Fix scrollbar rendering issue in iOS 13+ diff --git a/src/core/components/modal/index.tsx b/src/core/components/modal/index.tsx index 822638e12..b5f796846 100644 --- a/src/core/components/modal/index.tsx +++ b/src/core/components/modal/index.tsx @@ -5,7 +5,7 @@ import { StyleSheet, View, } from 'react-native'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import ModalButton from './modal-button'; import Overlay from './overlay'; import type { Props } from './types'; @@ -30,24 +30,28 @@ export default (props: Props): JSX.Element => { const overlayOpacity = useRef(new Animated.Value(0)).current; const contentOpacity = useRef(new Animated.Value(0)).current; - const style: Array = createStyleProp( - props.element, - props.stylesheets, - { - ...props.options, - styleAttr: 'modal-style', - }, + const style: Array = useMemo( + () => + createStyleProp(props.element, props.stylesheets, { + ...props.options, + styleAttr: 'modal-style', + }), + [props.element, props.stylesheets, props.options], ); const cancelLabel: string = props.element.getAttribute('cancel-label') || 'Cancel'; const doneLabel: string = props.element.getAttribute('done-label') || 'Done'; - const overlayStyle = StyleSheet.flatten( - createStyleProp(props.element, props.stylesheets, { - ...props.options, - styleAttr: 'modal-overlay-style', - }), + const overlayStyle = useMemo( + () => + StyleSheet.flatten( + createStyleProp(props.element, props.stylesheets, { + ...props.options, + styleAttr: 'modal-overlay-style', + }), + ), + [props.element, props.stylesheets, props.options], ); const onLayout = (event: LayoutChangeEvent) => { diff --git a/src/core/components/modal/modal-button/index.tsx b/src/core/components/modal/modal-button/index.tsx index 3218bf598..4942b8c79 100644 --- a/src/core/components/modal/modal-button/index.tsx +++ b/src/core/components/modal/modal-button/index.tsx @@ -1,5 +1,5 @@ import * as FontScale from 'hyperview/src/services/font-scale'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Text, TouchableWithoutFeedback, View } from 'react-native'; import type { Props } from './types'; import type { StyleSheet } from 'hyperview/src/types'; @@ -11,14 +11,14 @@ import { createStyleProp } from 'hyperview/src/services'; export default (props: Props) => { const [pressed, setPressed] = useState(false); - const style: Array = createStyleProp( - props.element, - props.stylesheets, - { - ...props.options, - pressed, - styleAttr: 'modal-text-style', - }, + const style: Array = useMemo( + () => + createStyleProp(props.element, props.stylesheets, { + ...props.options, + pressed, + styleAttr: 'modal-text-style', + }), + [props.element, props.stylesheets, props.options, pressed], ); return ( From 679af8f7c2195fc4f413e680d1373ed7412e8f47 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Mon, 2 Jun 2025 17:29:48 -0400 Subject: [PATCH 8/9] chore: useCallback for functions --- src/components/hv-picker-field/index.ios.tsx | 179 ++++++----- src/components/hv-picker-field/index.tsx | 133 ++++---- src/components/hv-switch/index.ts | 23 +- src/components/hv-view/index.tsx | 317 ++++++++++--------- 4 files changed, 353 insertions(+), 299 deletions(-) diff --git a/src/components/hv-picker-field/index.ios.tsx b/src/components/hv-picker-field/index.ios.tsx index 3f75c250f..05ea5a19a 100644 --- a/src/components/hv-picker-field/index.ios.tsx +++ b/src/components/hv-picker-field/index.ios.tsx @@ -5,7 +5,7 @@ import type { HvComponentProps, StyleSheet, } from 'hyperview/src/types'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { createStyleProp, createTestProps, @@ -24,138 +24,155 @@ import { View } from 'react-native'; * opens a system dialog. */ const HvPickerField = (props: HvComponentProps) => { - const getPickerInitialValue = (): string => { - const value = getValue(); - const pickerItems: Element[] = getPickerItems(); - const valueExists = pickerItems.some( - item => item.getAttribute('value') === value, - ); - if (valueExists) { - return value; - } - return pickerItems.length > 0 - ? pickerItems[0].getAttribute('value') || '' - : ''; - }; + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; /** * Returns a string representing the value in the field. */ - const getValue = (): string => props.element.getAttribute('value') || ''; + const getValue = useCallback( + (): string => element.getAttribute('value') || '', + [element], + ); /** * Gets the label from the picker items for the given value. * If the value doesn't have a picker item, returns null. */ - const getLabelForValue = (value: DOMString): string | null | undefined => { - const pickerItemElements: HTMLCollectionOf = props.element.getElementsByTagNameNS( - Namespaces.HYPERVIEW, - LOCAL_NAME.PICKER_ITEM, - ); - - let item: Element | null | undefined = null; - for (let i = 0; i < pickerItemElements.length; i += 1) { - const pickerItemElement: - | Element - | null - | undefined = pickerItemElements.item(i); - if ( - pickerItemElement && - pickerItemElement.getAttribute('value') === value - ) { - item = pickerItemElement; - break; + const getLabelForValue = useCallback( + (value: DOMString): string | null | undefined => { + const pickerItemElements: HTMLCollectionOf = element.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + LOCAL_NAME.PICKER_ITEM, + ); + + let item: Element | null | undefined = null; + for (let i = 0; i < pickerItemElements.length; i += 1) { + const pickerItemElement: + | Element + | null + | undefined = pickerItemElements.item(i); + if ( + pickerItemElement && + pickerItemElement.getAttribute('value') === value + ) { + item = pickerItemElement; + break; + } } - } - return item ? item.getAttribute('label') : null; - }; + return item ? item.getAttribute('label') : null; + }, + [element], + ); /** * Returns a string representing the value in the picker. */ - const getPickerValue = (): string => - props.element.getAttribute('picker-value') || ''; + const getPickerValue = useCallback( + (): string => element.getAttribute('picker-value') || '', + [element], + ); - const getPickerItems = (): Element[] => - Array.from( - props.element.getElementsByTagNameNS( - Namespaces.HYPERVIEW, - LOCAL_NAME.PICKER_ITEM, + const getPickerItems = useCallback( + (): Element[] => + Array.from( + element.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + LOCAL_NAME.PICKER_ITEM, + ), ), + [element], + ); + + const getPickerInitialValue = useCallback((): string => { + const value = getValue(); + const pickerItems: Element[] = getPickerItems(); + const valueExists = pickerItems.some( + item => item.getAttribute('value') === value, ); + if (valueExists) { + return value; + } + return pickerItems.length > 0 + ? pickerItems[0].getAttribute('value') || '' + : ''; + }, [getPickerItems, getValue]); /** * Shows the picker, defaulting to the field's value. * If the field is not set, use the first value in the picker. */ - const onFieldPress = () => { - const newElement = props.element.cloneNode(true) as Element; + const onFieldPress = useCallback(() => { + const newElement = element.cloneNode(true) as Element; newElement.setAttribute('focused', 'true'); newElement.setAttribute('picker-value', getPickerInitialValue()); - props.onUpdate(null, 'swap', props.element, { newElement }); - Behaviors.trigger('focus', newElement, props.onUpdate); - }; + onUpdate(null, 'swap', element, { newElement }); + Behaviors.trigger('focus', newElement, onUpdate); + }, [element, getPickerInitialValue, onUpdate]); /** * Hides the picker without applying the chosen value. */ - const onCancel = () => { - const newElement = props.element.cloneNode(true) as Element; + const onCancel = useCallback(() => { + const newElement = element.cloneNode(true) as Element; newElement.setAttribute('focused', 'false'); newElement.removeAttribute('picker-value'); - props.onUpdate(null, 'swap', props.element, { newElement }); - Behaviors.trigger('blur', newElement, props.onUpdate); - }; + onUpdate(null, 'swap', element, { newElement }); + Behaviors.trigger('blur', newElement, onUpdate); + }, [element, onUpdate]); /** * Hides the picker and applies the chosen value to the field. */ - const onDone = () => { + const onDone = useCallback(() => { const pickerValue = getPickerValue(); const value = getValue(); - const newElement = props.element.cloneNode(true) as Element; + const newElement = element.cloneNode(true) as Element; newElement.setAttribute('value', pickerValue); newElement.removeAttribute('picker-value'); newElement.setAttribute('focused', 'false'); - props.onUpdate(null, 'swap', props.element, { newElement }); + onUpdate(null, 'swap', element, { newElement }); const hasChanged = value !== pickerValue; if (hasChanged) { - Behaviors.trigger('change', newElement, props.onUpdate); + Behaviors.trigger('change', newElement, onUpdate); } - Behaviors.trigger('blur', newElement, props.onUpdate); - }; + Behaviors.trigger('blur', newElement, onUpdate); + }, [element, onUpdate, getPickerValue, getValue]); /** * Updates the picker value while keeping the picker open. */ - const setPickerValue = (value: string) => { - const newElement = props.element.cloneNode(true) as Element; - newElement.setAttribute('picker-value', value); - props.onUpdate(null, 'swap', props.element, { newElement }); - }; + const setPickerValue = useCallback( + (value: string) => { + const newElement = element.cloneNode(true) as Element; + newElement.setAttribute('picker-value', value); + onUpdate(null, 'swap', element, { newElement }); + }, + [element, onUpdate], + ); /** * Returns true if the field is focused (and picker is showing). */ - const isFocused = (): boolean => - props.element.getAttribute('focused') === 'true'; + const isFocused = useCallback( + (): boolean => element.getAttribute('focused') === 'true', + [element], + ); const style: Array = useMemo( () => - createStyleProp(props.element, props.stylesheets, { - ...props.options, + createStyleProp(element, stylesheets, { + ...options, styleAttr: 'field-text-style', }), - [props.element, props.stylesheets, props.options], - ); - const { testID, accessibilityLabel } = createTestProps(props.element); - const value: DOMString | null | undefined = props.element.getAttribute( - 'value', + [element, stylesheets, options], ); + const { testID, accessibilityLabel } = createTestProps(element); + const value: DOMString | null | undefined = element.getAttribute('value'); const placeholderTextColor: | DOMString | null - | undefined = props.element.getAttribute('placeholderTextColor'); + | undefined = element.getAttribute('placeholderTextColor'); if ([undefined, null, ''].includes(value) && placeholderTextColor) { style.push({ color: placeholderTextColor }); } @@ -177,22 +194,22 @@ const HvPickerField = (props: HvComponentProps) => { return ( {focused ? ( { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; + /** * Returns a string representing the value in the field. */ - const getValue = (): string => props.element.getAttribute('value') || ''; + const getValue = useCallback( + (): string => element.getAttribute('value') || '', + [element], + ); /** * Returns a string representing the value in the picker. */ - const getPickerValue = (): string => - props.element.getAttribute('value') || ''; - - const getPickerItems = (): Element[] => - Array.from( - props.element.getElementsByTagNameNS( - Namespaces.HYPERVIEW, - LOCAL_NAME.PICKER_ITEM, + const getPickerValue = useCallback( + (): string => element.getAttribute('value') || '', + [element], + ); + + const getPickerItems = useCallback( + (): Element[] => + Array.from( + element.getElementsByTagNameNS( + Namespaces.HYPERVIEW, + LOCAL_NAME.PICKER_ITEM, + ), ), - ); + [element], + ); - const onFocus = () => { - const newElement = props.element.cloneNode(true) as Element; + const onFocus = useCallback(() => { + const newElement = element.cloneNode(true) as Element; newElement.setAttribute('focused', 'true'); - props.onUpdate(null, 'swap', props.element, { newElement }); - Behaviors.trigger('focus', newElement, props.onUpdate); - }; + onUpdate(null, 'swap', element, { newElement }); + Behaviors.trigger('focus', newElement, onUpdate); + }, [element, onUpdate]); - const onBlur = () => { - const newElement = props.element.cloneNode(true) as Element; + const onBlur = useCallback(() => { + const newElement = element.cloneNode(true) as Element; newElement.setAttribute('focused', 'false'); - props.onUpdate(null, 'swap', props.element, { newElement }); - Behaviors.trigger('blur', newElement, props.onUpdate); - }; + onUpdate(null, 'swap', element, { newElement }); + Behaviors.trigger('blur', newElement, onUpdate); + }, [element, onUpdate]); /** * Hides the picker without applying the chosen value. */ - const onCancel = () => { - const newElement = props.element.cloneNode(true) as Element; + const onCancel = useCallback(() => { + const newElement = element.cloneNode(true) as Element; newElement.setAttribute('focused', 'false'); newElement.removeAttribute('picker-value'); - props.onUpdate(null, 'swap', props.element, { newElement }); - }; + onUpdate(null, 'swap', element, { newElement }); + }, [element, onUpdate]); /** * Hides the picker and applies the chosen value to the field. */ - const onDone = (newValue?: string) => { - const pickerValue = newValue !== undefined ? newValue : getPickerValue(); - const value = getValue(); - const newElement = props.element.cloneNode(true) as Element; - newElement.setAttribute('value', pickerValue); - newElement.removeAttribute('picker-value'); - newElement.setAttribute('focused', 'false'); - props.onUpdate(null, 'swap', props.element, { newElement }); - - const hasChanged = value !== pickerValue; - if (hasChanged) { - Behaviors.trigger('change', newElement, props.onUpdate); - } - }; + const onDone = useCallback( + (newValue?: string) => { + const pickerValue = newValue !== undefined ? newValue : getPickerValue(); + const value = getValue(); + const newElement = element.cloneNode(true) as Element; + newElement.setAttribute('value', pickerValue); + newElement.removeAttribute('picker-value'); + newElement.setAttribute('focused', 'false'); + onUpdate(null, 'swap', element, { newElement }); + + const hasChanged = value !== pickerValue; + if (hasChanged) { + Behaviors.trigger('change', newElement, onUpdate); + } + }, + [element, onUpdate, getPickerValue, getValue], + ); - const onChange = (value: string | null | undefined) => { - if (value === undefined) { - onCancel(); - } else { - onDone(value || ''); - } - }; + const onChange = useCallback( + (value: string | null | undefined) => { + if (value === undefined) { + onCancel(); + } else { + onDone(value || ''); + } + }, + [onCancel, onDone], + ); const style: Array = useMemo( () => - createStyleProp(props.element, props.stylesheets, { - ...props.options, + createStyleProp(element, stylesheets, { + ...options, styleAttr: 'field-text-style', }), - [props.element, props.stylesheets, props.options], - ); - const { testID, accessibilityLabel } = createTestProps(props.element); - const value: DOMString | null | undefined = props.element.getAttribute( - 'value', + [element, stylesheets, options], ); + const { testID, accessibilityLabel } = createTestProps(element); + const value: DOMString | null | undefined = element.getAttribute('value'); const placeholderTextColor: | DOMString | null - | undefined = props.element.getAttribute('placeholderTextColor'); + | undefined = element.getAttribute('placeholderTextColor'); if ([undefined, null, ''].includes(value) && placeholderTextColor) { style.push({ color: placeholderTextColor }); } const fieldStyle: Array = useMemo( () => - createStyleProp(props.element, props.stylesheets, { - ...props.options, + createStyleProp(element, stylesheets, { + ...options, styleAttr: 'field-style', }), - [props.element, props.stylesheets, props.options], + [element, stylesheets, options], ); // Gets all of the elements. All picker item elements @@ -150,8 +165,8 @@ const HvPickerField = (props: HvComponentProps) => { // eslint-disable-next-line max-len // `enabled` needs to be true when the field is not focused, otherwise the the field will not be selectable // fix inspired by https://github.com/react-native-picker/picker/issues/95#issuecomment-935718568 - enabled={props.element.getAttribute('focused') !== 'true'} - label={props.element.getAttribute('placeholder') || undefined} + enabled={element.getAttribute('focused') !== 'true'} + label={element.getAttribute('placeholder') || undefined} style={{ fontSize: 16 }} value="" />, diff --git a/src/components/hv-switch/index.ts b/src/components/hv-switch/index.ts index d80db2d0a..f3b834888 100644 --- a/src/components/hv-switch/index.ts +++ b/src/components/hv-switch/index.ts @@ -32,26 +32,29 @@ function darkenColor(color: ColorValue, percent: number): ColorValue { } const HvSwitch = (props: HvComponentProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, stylesheets } = props; + const unselectedStyle = useMemo( () => StyleSheet.flatten( - createStyleProp(props.element, props.stylesheets, { + createStyleProp(element, stylesheets, { selected: false, }), ), - [props.element, props.stylesheets], + [element, stylesheets], ); const selectedStyle = useMemo( () => StyleSheet.flatten( - createStyleProp(props.element, props.stylesheets, { + createStyleProp(element, stylesheets, { selected: true, }), ), - [props.element, props.stylesheets], + [element, stylesheets], ); - if (props.element.getAttribute('hide') === 'true') { + if (element.getAttribute('hide') === 'true') { return null; } @@ -60,14 +63,14 @@ const HvSwitch = (props: HvComponentProps) => { ? unselectedStyle.backgroundColor : null, onChange: () => { - const newElement = props.element.cloneNode(true) as Element; - Behaviors.trigger('change', newElement, props.onUpdate); + const newElement = element.cloneNode(true) as Element; + Behaviors.trigger('change', newElement, onUpdate); }, // eslint-disable-next-line @typescript-eslint/no-explicit-any onValueChange: (value: any) => { - const newElement = props.element.cloneNode(true) as Element; + const newElement = element.cloneNode(true) as Element; newElement.setAttribute('value', value ? 'on' : 'off'); - props.onUpdate(null, 'swap', props.element, { newElement }); + onUpdate(null, 'swap', element, { newElement }); }, // iOS thumbColor default thumbColor: unselectedStyle?.color || selectedStyle?.color, @@ -75,7 +78,7 @@ const HvSwitch = (props: HvComponentProps) => { false: unselectedStyle ? unselectedStyle.backgroundColor : null, true: selectedStyle ? selectedStyle.backgroundColor : null, }, - value: props.element.getAttribute('value') === 'on', + value: element.getAttribute('value') === 'on', }; // android thumbColor default diff --git a/src/components/hv-view/index.tsx b/src/components/hv-view/index.tsx index c2b4074ac..1c031ce9f 100644 --- a/src/components/hv-view/index.tsx +++ b/src/components/hv-view/index.tsx @@ -23,23 +23,26 @@ import { KeyboardAwareScrollView, ScrollView, } from 'hyperview/src/core/components/scroll'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { ATTRIBUTES } from './types'; import { LOCAL_NAME } from 'hyperview/src/types'; import { addHref } from 'hyperview/src/core/hyper-ref'; import { createStyleProp } from 'hyperview/src/services'; const HvView = (props: HvComponentProps) => { + // eslint-disable-next-line react/destructuring-assignment + const { element, onUpdate, options, stylesheets } = props; + const attributes = useMemo((): Attributes => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return Object.values(ATTRIBUTES).reduce>( (acc, name: string) => ({ ...acc, - [name]: props.element.getAttribute(name), + [name]: element.getAttribute(name), }), {}, ); - }, [props.element]); + }, [element]); // TODO: fix type // createStyleProp returns an array of StyleSheet, @@ -47,34 +50,30 @@ const HvView = (props: HvComponentProps) => { // not an array type. Does a type need to get fixed elsewhere? const style = useMemo( () => - (createStyleProp( - props.element, - props.stylesheets, - props.options, - ) as unknown) as ViewStyle, - [props.element, props.stylesheets, props.options], + (createStyleProp(element, stylesheets, options) as unknown) as ViewStyle, + [element, stylesheets, options], ); const containerStyle = useMemo( () => - createStyleProp(props.element, props.stylesheets, { - ...props.options, + createStyleProp(element, stylesheets, { + ...options, styleAttr: ATTRIBUTES.CONTENT_CONTAINER_STYLE, }), - [props.element, props.stylesheets, props.options], + [element, stylesheets, options], ); - const checkHasInputFields = (): boolean => { - const textFields = props.element.getElementsByTagNameNS( + const checkHasInputFields = useCallback((): boolean => { + const textFields = element.getElementsByTagNameNS( Namespaces.HYPERVIEW, 'text-field', ); return textFields.length > 0; - }; + }, [element]); - const getCommonProps = (): CommonProps => { - const id = props.element.getAttribute('id'); + const getCommonProps = useCallback((): CommonProps => { + const id = element.getAttribute('id'); if (!id) { return { style }; } @@ -82,53 +81,58 @@ const HvView = (props: HvComponentProps) => { return { style, testID: id }; } return { accessibilityLabel: id, style }; - }; - - const getScrollViewProps = ( - children: Array | null | string>, - ): ScrollViewProps => { - const horizontal = - attributes[ATTRIBUTES.SCROLL_ORIENTATION] === 'horizontal'; - const showScrollIndicator = - attributes[ATTRIBUTES.SHOWS_SCROLL_INDICATOR] !== 'false'; + }, [element, style]); - const contentContainerStyle = attributes[ATTRIBUTES.CONTENT_CONTAINER_STYLE] - ? containerStyle - : undefined; + const getScrollViewProps = useCallback( + ( + children: Array | null | string>, + ): ScrollViewProps => { + const horizontal = + attributes[ATTRIBUTES.SCROLL_ORIENTATION] === 'horizontal'; + const showScrollIndicator = + attributes[ATTRIBUTES.SHOWS_SCROLL_INDICATOR] !== 'false'; - // Fix scrollbar rendering issue in iOS 13+ - // https://github.com/facebook/react-native/issues/26610#issuecomment-539843444 - const scrollIndicatorInsets = - Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 13 - ? { right: 1 } + const contentContainerStyle = attributes[ + ATTRIBUTES.CONTENT_CONTAINER_STYLE + ] + ? containerStyle : undefined; - // add sticky indices - const stickyHeaderIndices = children.reduce>( - (acc, element, index) => { - if ( - typeof element !== 'string' && - element?.props.element?.getAttribute?.('sticky') === 'true' - ) { - return [...acc, index]; - } - return acc; - }, - [], - ); + // Fix scrollbar rendering issue in iOS 13+ + // https://github.com/facebook/react-native/issues/26610#issuecomment-539843444 + const scrollIndicatorInsets = + Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 13 + ? { right: 1 } + : undefined; + + // add sticky indices + const stickyHeaderIndices = children.reduce>( + (acc, el, index) => { + if ( + typeof el !== 'string' && + el?.props.element?.getAttribute?.('sticky') === 'true' + ) { + return [...acc, index]; + } + return acc; + }, + [], + ); - return { - contentContainerStyle, - horizontal, - keyboardDismissMode: Keyboard.getKeyboardDismissMode(props.element), - scrollIndicatorInsets, - showsHorizontalScrollIndicator: horizontal && showScrollIndicator, - showsVerticalScrollIndicator: !horizontal && showScrollIndicator, - stickyHeaderIndices, - }; - }; + return { + contentContainerStyle, + horizontal, + keyboardDismissMode: Keyboard.getKeyboardDismissMode(element), + scrollIndicatorInsets, + showsHorizontalScrollIndicator: horizontal && showScrollIndicator, + showsVerticalScrollIndicator: !horizontal && showScrollIndicator, + stickyHeaderIndices, + }; + }, + [attributes, containerStyle, element], + ); - const getScrollToInputAdditionalOffsetProp = (): number => { + const getScrollToInputAdditionalOffsetProp = useCallback((): number => { const defaultOffset = 120; const offsetStr = attributes[ATTRIBUTES.SCROLL_TO_INPUT_OFFSET]; if (offsetStr) { @@ -136,121 +140,136 @@ const HvView = (props: HvComponentProps) => { return Number.isNaN(offset) ? 0 : defaultOffset; } return defaultOffset; - }; + }, [attributes]); - const getKeyboardAwareScrollViewProps = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - inputFieldRefs: Array, - ): KeyboardAwareScrollViewProps => ({ - automaticallyAdjustContentInsets: false, - getTextInputRefs: () => inputFieldRefs, - keyboardShouldPersistTaps: 'handled', - scrollEventThrottle: 16, - scrollToInputAdditionalOffset: getScrollToInputAdditionalOffsetProp(), - }); + const getKeyboardAwareScrollViewProps = useCallback( + ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputFieldRefs: Array, + ): KeyboardAwareScrollViewProps => ({ + automaticallyAdjustContentInsets: false, + getTextInputRefs: () => inputFieldRefs, + keyboardShouldPersistTaps: 'handled', + scrollEventThrottle: 16, + scrollToInputAdditionalOffset: getScrollToInputAdditionalOffsetProp(), + }), + [getScrollToInputAdditionalOffsetProp], + ); - const Content = (p: HvComponentProps) => { - /** - * Useful when you want keyboard avoiding behavior in non-scrollable views. - * Note: Android has built-in support for avoiding keyboard. - */ - const keyboardAvoiding = - attributes[ATTRIBUTES.AVOID_KEYBOARD] === 'true' && Platform.OS === 'ios'; + const Content = useCallback( + (p: HvComponentProps) => { + /** + * Useful when you want keyboard avoiding behavior in non-scrollable views. + * Note: Android has built-in support for avoiding keyboard. + */ + const keyboardAvoiding = + attributes[ATTRIBUTES.AVOID_KEYBOARD] === 'true' && + Platform.OS === 'ios'; - const hasInputFields = checkHasInputFields(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const inputFieldRefs: Array = []; - const scrollable = attributes[ATTRIBUTES.SCROLL] === 'true'; - const safeArea = attributes[ATTRIBUTES.SAFE_AREA] === 'true'; - if (safeArea) { - if (keyboardAvoiding || scrollable) { - Logging.warn('safe-area is incompatible with scroll or avoid-keyboard'); + const hasInputFields = checkHasInputFields(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inputFieldRefs: Array = []; + const scrollable = attributes[ATTRIBUTES.SCROLL] === 'true'; + const safeArea = attributes[ATTRIBUTES.SAFE_AREA] === 'true'; + if (safeArea) { + if (keyboardAvoiding || scrollable) { + Logging.warn( + 'safe-area is incompatible with scroll or avoid-keyboard', + ); + } } - } - const children = Render.buildChildArray( - p.element, - p.onUpdate, - { - ...p.options, - ...(scrollable && hasInputFields - ? { - registerInputHandler: ref => { - if (ref !== null) { - inputFieldRefs.push(ref); - } - }, - } - : {}), - }, - p.stylesheets, - ); + const children = Render.buildChildArray( + p.element, + p.onUpdate, + { + ...p.options, + ...(scrollable && hasInputFields + ? { + registerInputHandler: ref => { + if (ref !== null) { + inputFieldRefs.push(ref); + } + }, + } + : {}), + }, + p.stylesheets, + ); - /* eslint-disable react/jsx-props-no-spreading */ - if (scrollable) { - if (hasInputFields) { + /* eslint-disable react/jsx-props-no-spreading */ + if (scrollable) { + if (hasInputFields) { + // TODO: Replace with + return React.createElement( + KeyboardAwareScrollView, + { + element: p.element, + ...getCommonProps(), + ...getScrollViewProps(children), + ...getKeyboardAwareScrollViewProps(inputFieldRefs), + }, + ...children, + ); + } // TODO: Replace with return React.createElement( - KeyboardAwareScrollView, + ScrollView, { element: p.element, ...getCommonProps(), ...getScrollViewProps(children), - ...getKeyboardAwareScrollViewProps(inputFieldRefs), + }, + ...children, + ); + } + if (!keyboardAvoiding && safeArea) { + // TODO: Replace with + return React.createElement(SafeAreaView, getCommonProps(), ...children); + } + if (keyboardAvoiding) { + // TODO: Replace with + return React.createElement( + KeyboardAvoidingView, + { + ...getCommonProps(), + behavior: 'position', }, ...children, ); } // TODO: Replace with - return React.createElement( - ScrollView, - { - element: p.element, - ...getCommonProps(), - ...getScrollViewProps(children), - }, - ...children, - ); - } - if (!keyboardAvoiding && safeArea) { - // TODO: Replace with - return React.createElement(SafeAreaView, getCommonProps(), ...children); - } - if (keyboardAvoiding) { - // TODO: Replace with - return React.createElement( - KeyboardAvoidingView, - { - ...getCommonProps(), - behavior: 'position', - }, - ...children, - ); - } - // TODO: Replace with - return React.createElement(View, getCommonProps(), ...children); - /* eslint-enable react/jsx-props-no-spreading */ - }; + return React.createElement(View, getCommonProps(), ...children); + /* eslint-enable react/jsx-props-no-spreading */ + }, + [ + attributes, + checkHasInputFields, + getCommonProps, + getKeyboardAwareScrollViewProps, + getScrollViewProps, + ], + ); - return props.options?.skipHref ? ( + return options?.skipHref ? ( ) : ( addHref( , - props.element, - props.stylesheets, - props.onUpdate as HvComponentOnUpdate, - props.options, + element, + stylesheets, + onUpdate as HvComponentOnUpdate, + options, ) ); }; From 7cd641ebc1efb703da87c21ed4fc7dffdad1f2f7 Mon Sep 17 00:00:00 2001 From: Hardin Gray Date: Mon, 2 Jun 2025 18:01:29 -0400 Subject: [PATCH 9/9] chore: improve memo values --- src/components/hv-picker-field/index.ios.tsx | 24 +++++------ src/components/hv-picker-field/index.tsx | 41 ++++++++++--------- src/components/hv-switch/index.ts | 37 +++++++++-------- src/components/hv-view/index.tsx | 42 ++++++++++++++------ 4 files changed, 79 insertions(+), 65 deletions(-) diff --git a/src/components/hv-picker-field/index.ios.tsx b/src/components/hv-picker-field/index.ios.tsx index 05ea5a19a..dbc1f407e 100644 --- a/src/components/hv-picker-field/index.ios.tsx +++ b/src/components/hv-picker-field/index.ios.tsx @@ -1,10 +1,6 @@ import * as Behaviors from 'hyperview/src/services/behaviors'; import * as Namespaces from 'hyperview/src/services/namespaces'; -import type { - DOMString, - HvComponentProps, - StyleSheet, -} from 'hyperview/src/types'; +import type { DOMString, HvComponentProps } from 'hyperview/src/types'; import React, { useCallback, useMemo } from 'react'; import { createStyleProp, @@ -159,13 +155,17 @@ const HvPickerField = (props: HvComponentProps) => { [element], ); - const style: Array = useMemo( + const { focused, pressed, pressedSelected, selected } = options; + const style = useMemo( () => createStyleProp(element, stylesheets, { - ...options, + focused, + pressed, + pressedSelected, + selected, styleAttr: 'field-text-style', }), - [element, stylesheets, options], + [element, focused, pressed, pressedSelected, selected, stylesheets], ); const { testID, accessibilityLabel } = createTestProps(element); const value: DOMString | null | undefined = element.getAttribute('value'); @@ -190,21 +190,21 @@ const HvPickerField = (props: HvComponentProps) => { return ; }); - const focused = isFocused(); + const focusedVal = isFocused(); return ( - {focused ? ( + {focusedVal ? ( { [onCancel, onDone], ); - const style: Array = useMemo( - () => - createStyleProp(element, stylesheets, { - ...options, - styleAttr: 'field-text-style', - }), - [element, stylesheets, options], - ); + const { focused, pressed, pressedSelected, selected } = options; + const style = useMemo(() => { + return createStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr: 'field-text-style', + }); + }, [element, focused, pressed, pressedSelected, selected, stylesheets]); const { testID, accessibilityLabel } = createTestProps(element); const value: DOMString | null | undefined = element.getAttribute('value'); const placeholderTextColor: @@ -126,14 +124,15 @@ const HvPickerField = (props: HvComponentProps) => { style.push({ color: placeholderTextColor }); } - const fieldStyle: Array = useMemo( - () => - createStyleProp(element, stylesheets, { - ...options, - styleAttr: 'field-style', - }), - [element, stylesheets, options], - ); + const fieldStyle = useMemo(() => { + return createStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr: 'field-style', + }); + }, [element, focused, pressed, pressedSelected, selected, stylesheets]); // Gets all of the elements. All picker item elements // with a value and label are turned into options for the picker. diff --git a/src/components/hv-switch/index.ts b/src/components/hv-switch/index.ts index f3b834888..bb3772839 100644 --- a/src/components/hv-switch/index.ts +++ b/src/components/hv-switch/index.ts @@ -33,26 +33,25 @@ function darkenColor(color: ColorValue, percent: number): ColorValue { const HvSwitch = (props: HvComponentProps) => { // eslint-disable-next-line react/destructuring-assignment - const { element, onUpdate, stylesheets } = props; + const { element, onUpdate, options, stylesheets } = props; + const { styleAttr } = options; + const unselectedStyle = useMemo(() => { + return StyleSheet.flatten( + createStyleProp(element, stylesheets, { + selected: false, + styleAttr, + }), + ); + }, [element, styleAttr, stylesheets]); - const unselectedStyle = useMemo( - () => - StyleSheet.flatten( - createStyleProp(element, stylesheets, { - selected: false, - }), - ), - [element, stylesheets], - ); - const selectedStyle = useMemo( - () => - StyleSheet.flatten( - createStyleProp(element, stylesheets, { - selected: true, - }), - ), - [element, stylesheets], - ); + const selectedStyle = useMemo(() => { + return StyleSheet.flatten( + createStyleProp(element, stylesheets, { + selected: true, + styleAttr, + }), + ); + }, [element, styleAttr, stylesheets]); if (element.getAttribute('hide') === 'true') { return null; diff --git a/src/components/hv-view/index.tsx b/src/components/hv-view/index.tsx index 1c031ce9f..d9b583908 100644 --- a/src/components/hv-view/index.tsx +++ b/src/components/hv-view/index.tsx @@ -44,24 +44,40 @@ const HvView = (props: HvComponentProps) => { ); }, [element]); + const { focused, pressed, pressedSelected, selected } = options; + const { styleAttr } = options; + // TODO: fix type // createStyleProp returns an array of StyleSheet, // but it appears something wants a ViewStyle, which is not // not an array type. Does a type need to get fixed elsewhere? - const style = useMemo( - () => - (createStyleProp(element, stylesheets, options) as unknown) as ViewStyle, - [element, stylesheets, options], - ); + const style = useMemo(() => { + return (createStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr, + }) as unknown) as ViewStyle; + }, [ + element, + focused, + pressed, + pressedSelected, + selected, + styleAttr, + stylesheets, + ]); - const containerStyle = useMemo( - () => - createStyleProp(element, stylesheets, { - ...options, - styleAttr: ATTRIBUTES.CONTENT_CONTAINER_STYLE, - }), - [element, stylesheets, options], - ); + const containerStyle = useMemo(() => { + return createStyleProp(element, stylesheets, { + focused, + pressed, + pressedSelected, + selected, + styleAttr: ATTRIBUTES.CONTENT_CONTAINER_STYLE, + }); + }, [element, focused, pressed, pressedSelected, selected, stylesheets]); const checkHasInputFields = useCallback((): boolean => { const textFields = element.getElementsByTagNameNS(