Extract front component host wrapper into hooks (#23262)
Refactors `createHtmlHostWrapper` into composable hooks (`useHtmlHostElementProps`, `useComposedElementRef`, `useCaretPreservingElementRef`) as groundwork for the geometry mirror. Behavior-focused, no feature change: - Caret preservation moves to a stable ref + `useLayoutEffect` re-assertion (covered by the caret suites). Highest regression surface in the series, isolated here for focused review. - The remote `ref` prop is now swallowed via `INTERNAL_PROPS` instead of leaking onto host elements. First of three PRs splitting the geometry mirror work. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23262?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+85
@@ -0,0 +1,85 @@
|
||||
import '../../utils/__tests__/setupServerRenderingGlobals';
|
||||
|
||||
import { act, createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import { type ElementRefCallback } from '@/host/types/ElementRefCallback';
|
||||
import { useComposedElementRef } from '../useComposedElementRef';
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const seenComposedElementRefs: ElementRefCallback[] = [];
|
||||
|
||||
const TestComponent = ({
|
||||
elementRefs,
|
||||
}: {
|
||||
elementRefs: (ElementRefCallback | undefined)[];
|
||||
}) => {
|
||||
const composedElementRef = useComposedElementRef(elementRefs);
|
||||
seenComposedElementRefs.push(composedElementRef);
|
||||
|
||||
return createElement('div', { ref: composedElementRef });
|
||||
};
|
||||
|
||||
describe('useComposedElementRef', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
seenComposedElementRefs.length = 0;
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const renderWith = (elementRefs: (ElementRefCallback | undefined)[]) => {
|
||||
act(() => {
|
||||
root.render(createElement(TestComponent, { elementRefs }));
|
||||
});
|
||||
};
|
||||
|
||||
it('should call every defined ref with the element', () => {
|
||||
const firstRef = jest.fn();
|
||||
const secondRef = jest.fn();
|
||||
|
||||
renderWith([firstRef, undefined, secondRef]);
|
||||
|
||||
expect(firstRef).toHaveBeenCalledTimes(1);
|
||||
expect(secondRef).toHaveBeenCalledTimes(1);
|
||||
expect(firstRef.mock.calls[0][0]).toBe(container.firstElementChild);
|
||||
});
|
||||
|
||||
it('should keep the same ref identity across re-renders', () => {
|
||||
const elementRef = jest.fn();
|
||||
|
||||
renderWith([elementRef]);
|
||||
renderWith([elementRef]);
|
||||
renderWith([elementRef]);
|
||||
|
||||
expect(seenComposedElementRefs).toHaveLength(3);
|
||||
expect(seenComposedElementRefs[0]).toBe(seenComposedElementRefs[1]);
|
||||
expect(seenComposedElementRefs[0]).toBe(seenComposedElementRefs[2]);
|
||||
expect(elementRef).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call every ref with null on unmount', () => {
|
||||
const elementRef = jest.fn();
|
||||
|
||||
renderWith([elementRef]);
|
||||
|
||||
act(() => {
|
||||
root.render(null);
|
||||
});
|
||||
|
||||
expect(elementRef).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
});
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { isString } from '@sniptt/guards';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type ElementRefCallback } from '@/host/types/ElementRefCallback';
|
||||
import { syncValuePreservingCaret } from '@/host/utils/syncValuePreservingCaret';
|
||||
|
||||
export const useCaretPreservingElementRef = (
|
||||
composedElementRef: ElementRefCallback,
|
||||
value: unknown,
|
||||
): ElementRefCallback => {
|
||||
const latestComposedElementRefRef = useRef(composedElementRef);
|
||||
latestComposedElementRefRef.current = composedElementRef;
|
||||
|
||||
const attachedElementRef = useRef<Element | null>(null);
|
||||
|
||||
const [caretPreservingElementRef] = useState(
|
||||
() => (element: Element | null) => {
|
||||
attachedElementRef.current = element;
|
||||
latestComposedElementRefRef.current(element);
|
||||
},
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const attachedElement = attachedElementRef.current;
|
||||
|
||||
if (!isDefined(attachedElement) || !isString(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncValuePreservingCaret(
|
||||
attachedElement as HTMLInputElement | HTMLTextAreaElement,
|
||||
value,
|
||||
);
|
||||
});
|
||||
|
||||
return caretPreservingElementRef;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { type ElementRefCallback } from '@/host/types/ElementRefCallback';
|
||||
|
||||
export const useComposedElementRef = (
|
||||
elementRefs: (ElementRefCallback | undefined)[],
|
||||
): ElementRefCallback => {
|
||||
const latestElementRefsRef = useRef(elementRefs);
|
||||
latestElementRefsRef.current = elementRefs;
|
||||
|
||||
const [composedElementRef] = useState(() => (element: Element | null) => {
|
||||
for (const elementRef of latestElementRefsRef.current) {
|
||||
elementRef?.(element);
|
||||
}
|
||||
});
|
||||
|
||||
return composedElementRef;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import {
|
||||
FrontComponentInputFocusContext,
|
||||
type SetEditableFocused,
|
||||
} from '@/host/contexts/FrontComponentInputFocusContext';
|
||||
import { useComposedElementRef } from '@/host/hooks/useComposedElementRef';
|
||||
import { useReactUnsupportedEventListenerRef } from '@/host/hooks/useReactUnsupportedEventListenerRef';
|
||||
import { type ElementRefCallback } from '@/host/types/ElementRefCallback';
|
||||
import { buildHostReactPropsFromRemoteProps } from '@/host/utils/buildHostReactPropsFromRemoteProps';
|
||||
import { createDropTargetGuardProps } from '@/host/utils/createDropTargetGuardProps';
|
||||
import { extractReactUnsupportedEventHandlers } from '@/host/utils/extractReactUnsupportedEventHandlers';
|
||||
import { preventDefaultThenForwardToRemote } from '@/host/utils/preventDefaultThenForwardToRemote';
|
||||
import { sanitizeIframeSandbox } from '@/host/utils/sanitizeIframeSandbox';
|
||||
|
||||
type HtmlHostElementProps = {
|
||||
setEditableFocused: SetEditableFocused | null;
|
||||
reactBindableProps: Record<string, unknown>;
|
||||
hostEnforcedProps: Record<string, unknown>;
|
||||
composedElementRef: ElementRefCallback;
|
||||
};
|
||||
|
||||
export const useHtmlHostElementProps = (
|
||||
props: Record<string, unknown>,
|
||||
htmlTag: string,
|
||||
): HtmlHostElementProps => {
|
||||
const setEditableFocused = useContext(FrontComponentInputFocusContext);
|
||||
|
||||
const { reactUnsupportedEventHandlers, reactBindableProps } =
|
||||
extractReactUnsupportedEventHandlers(
|
||||
buildHostReactPropsFromRemoteProps(props, htmlTag),
|
||||
);
|
||||
|
||||
const reactUnsupportedEventListenerRef = useReactUnsupportedEventListenerRef(
|
||||
reactUnsupportedEventHandlers,
|
||||
);
|
||||
|
||||
const composedElementRef = useComposedElementRef([
|
||||
reactUnsupportedEventListenerRef,
|
||||
]);
|
||||
|
||||
const hostEnforcedProps: Record<string, unknown> = {
|
||||
...createDropTargetGuardProps(reactBindableProps),
|
||||
...(htmlTag === 'iframe' && {
|
||||
sandbox: sanitizeIframeSandbox(reactBindableProps.sandbox),
|
||||
}),
|
||||
// React 19 blocks the previous `action="javascript:void(0)"` guard.
|
||||
...(htmlTag === 'form' && {
|
||||
onSubmit: preventDefaultThenForwardToRemote(reactBindableProps.onSubmit),
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
setEditableFocused,
|
||||
reactBindableProps,
|
||||
hostEnforcedProps,
|
||||
composedElementRef,
|
||||
};
|
||||
};
|
||||
+3
-7
@@ -1,11 +1,12 @@
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { type ElementRefCallback } from '@/host/types/ElementRefCallback';
|
||||
import { type ReactUnsupportedEventHandlers } from '@/host/types/ReactUnsupportedEventHandlers';
|
||||
import { createReactUnsupportedEventListenerRef } from '@/host/utils/createReactUnsupportedEventListenerRef';
|
||||
|
||||
export const useReactUnsupportedEventListenerRef = (
|
||||
reactUnsupportedEventHandlers: ReactUnsupportedEventHandlers,
|
||||
): ((element: Element | null) => void) | undefined => {
|
||||
): ElementRefCallback => {
|
||||
const latestHandlersRef = useRef(reactUnsupportedEventHandlers);
|
||||
latestHandlersRef.current = reactUnsupportedEventHandlers;
|
||||
|
||||
@@ -13,10 +14,5 @@ export const useReactUnsupportedEventListenerRef = (
|
||||
createReactUnsupportedEventListenerRef(latestHandlersRef),
|
||||
);
|
||||
|
||||
const hasReactUnsupportedEventHandlers =
|
||||
Object.keys(reactUnsupportedEventHandlers).length > 0;
|
||||
|
||||
return hasReactUnsupportedEventHandlers
|
||||
? reactUnsupportedEventListenerRef
|
||||
: undefined;
|
||||
return reactUnsupportedEventListenerRef;
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type ElementRefCallback = (element: Element | null) => void;
|
||||
+11
-5
@@ -10,8 +10,9 @@ describe('createCaretPreservingElement', () => {
|
||||
const element = createCaretPreservingElement({
|
||||
htmlTag: 'input',
|
||||
reactBindableProps: { type: 'text' },
|
||||
hostEnforcedProps: undefined,
|
||||
hostEnforcedProps: {},
|
||||
setEditableFocused: null,
|
||||
caretPreservingElementRef: () => {},
|
||||
});
|
||||
|
||||
expect(element.type).toBe('input');
|
||||
@@ -22,8 +23,9 @@ describe('createCaretPreservingElement', () => {
|
||||
const element = createCaretPreservingElement({
|
||||
htmlTag: 'input',
|
||||
reactBindableProps: { value: 'hello' },
|
||||
hostEnforcedProps: undefined,
|
||||
hostEnforcedProps: {},
|
||||
setEditableFocused: null,
|
||||
caretPreservingElementRef: () => {},
|
||||
});
|
||||
|
||||
expect(getProps(element).defaultValue).toBe('hello');
|
||||
@@ -33,8 +35,9 @@ describe('createCaretPreservingElement', () => {
|
||||
const element = createCaretPreservingElement({
|
||||
htmlTag: 'input',
|
||||
reactBindableProps: { value: 'v', defaultValue: 'd' },
|
||||
hostEnforcedProps: undefined,
|
||||
hostEnforcedProps: {},
|
||||
setEditableFocused: null,
|
||||
caretPreservingElementRef: () => {},
|
||||
});
|
||||
|
||||
expect(getProps(element).defaultValue).toBe('d');
|
||||
@@ -46,6 +49,7 @@ describe('createCaretPreservingElement', () => {
|
||||
reactBindableProps: {},
|
||||
hostEnforcedProps: { readOnly: true },
|
||||
setEditableFocused: null,
|
||||
caretPreservingElementRef: () => {},
|
||||
});
|
||||
|
||||
expect(getProps(element).readOnly).toBe(true);
|
||||
@@ -57,8 +61,9 @@ describe('createCaretPreservingElement', () => {
|
||||
const element = createCaretPreservingElement({
|
||||
htmlTag: 'input',
|
||||
reactBindableProps: { onFocus },
|
||||
hostEnforcedProps: undefined,
|
||||
hostEnforcedProps: {},
|
||||
setEditableFocused,
|
||||
caretPreservingElementRef: () => {},
|
||||
});
|
||||
|
||||
const event = {} as never;
|
||||
@@ -73,8 +78,9 @@ describe('createCaretPreservingElement', () => {
|
||||
const element = createCaretPreservingElement({
|
||||
htmlTag: 'input',
|
||||
reactBindableProps: {},
|
||||
hostEnforcedProps: undefined,
|
||||
hostEnforcedProps: {},
|
||||
setEditableFocused,
|
||||
caretPreservingElementRef: () => {},
|
||||
});
|
||||
|
||||
(getProps(element).onBlur as (event: unknown) => void)({} as never);
|
||||
|
||||
+40
@@ -135,6 +135,46 @@ describe('createHtmlHostWrapper client events', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should re-assert an unchanged controlled value on an unrelated re-render', () => {
|
||||
const Wrapper = createHtmlHostWrapper('input');
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(Wrapper, { type: 'text', value: 'fixed' }));
|
||||
});
|
||||
|
||||
const node = container.firstElementChild as HTMLInputElement;
|
||||
node.value = 'fixed-typed';
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(Wrapper, {
|
||||
type: 'text',
|
||||
value: 'fixed',
|
||||
className: 'rerendered',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(node.value).toBe('fixed');
|
||||
});
|
||||
|
||||
it('should clear the host input when a controlled value becomes empty', () => {
|
||||
const Wrapper = createHtmlHostWrapper('input');
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(Wrapper, { type: 'text', value: 'abc' }));
|
||||
});
|
||||
|
||||
const node = container.firstElementChild as HTMLInputElement;
|
||||
expect(node.value).toBe('abc');
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(Wrapper, { type: 'text', value: '' }));
|
||||
});
|
||||
|
||||
expect(node.value).toBe('');
|
||||
});
|
||||
|
||||
it('should stop forwarding focusin after the handler prop is removed', () => {
|
||||
const handleFocusIn = jest.fn();
|
||||
const Wrapper = createHtmlHostWrapper('div');
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import './setupServerRenderingGlobals';
|
||||
|
||||
import { act, createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import { useCaretPreservingElementRef } from '@/host/hooks/useCaretPreservingElementRef';
|
||||
import { createHtmlHostWrapper } from '../createHtmlHostWrapper';
|
||||
|
||||
jest.mock('@/host/hooks/useCaretPreservingElementRef', () => ({
|
||||
useCaretPreservingElementRef: jest.fn(() => () => {}),
|
||||
}));
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe('createHtmlHostWrapper caret hook scope', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const renderWrapper = (htmlTag: string, props: Record<string, unknown>) => {
|
||||
act(() => {
|
||||
root.render(createElement(createHtmlHostWrapper(htmlTag), props));
|
||||
});
|
||||
};
|
||||
|
||||
it('should not call the caret hook for a non editable tag', () => {
|
||||
renderWrapper('div', {});
|
||||
|
||||
expect(useCaretPreservingElementRef).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call the caret hook for a span', () => {
|
||||
renderWrapper('span', {});
|
||||
|
||||
expect(useCaretPreservingElementRef).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call the caret hook for a text input', () => {
|
||||
renderWrapper('input', { type: 'text' });
|
||||
|
||||
expect(useCaretPreservingElementRef).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call the caret hook for a textarea', () => {
|
||||
renderWrapper('textarea', {});
|
||||
|
||||
expect(useCaretPreservingElementRef).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should keep rendering a plain input element for a checkbox', () => {
|
||||
renderWrapper('input', { type: 'checkbox' });
|
||||
|
||||
expect(container.firstElementChild?.tagName).toBe('INPUT');
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -9,7 +9,7 @@ import { parseCssString } from '@/host/utils/parseCssString';
|
||||
import { wrapEventHandler } from '@/host/utils/wrapEventHandler';
|
||||
import { type SerializedEventData } from '@/types/SerializedEventData';
|
||||
|
||||
const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']);
|
||||
const INTERNAL_PROPS = new Set(['element', 'receiver', 'components', 'ref']);
|
||||
|
||||
// Both spellings are indexed: dblclick arrives as ondblclick or onDoubleClick.
|
||||
const LOWERCASE_EVENT_PROP_TO_REACT_PROP: Record<string, string> =
|
||||
|
||||
+5
-14
@@ -1,18 +1,17 @@
|
||||
import { isFunction, isNonEmptyString } from '@sniptt/guards';
|
||||
import React from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type SetEditableFocused } from '@/host/contexts/FrontComponentInputFocusContext';
|
||||
import { syncValuePreservingCaret } from '@/host/utils/syncValuePreservingCaret';
|
||||
import { type ElementRefCallback } from '@/host/types/ElementRefCallback';
|
||||
|
||||
type CaretPreservingElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
type CreateCaretPreservingElementParams = {
|
||||
htmlTag: 'input' | 'textarea';
|
||||
reactBindableProps: Record<string, unknown>;
|
||||
hostEnforcedProps: Record<string, unknown> | undefined;
|
||||
hostEnforcedProps: Record<string, unknown>;
|
||||
setEditableFocused: SetEditableFocused | null;
|
||||
reactUnsupportedEventListenerRef?: (node: Element | null) => void;
|
||||
caretPreservingElementRef: ElementRefCallback;
|
||||
};
|
||||
|
||||
export const createCaretPreservingElement = ({
|
||||
@@ -20,7 +19,7 @@ export const createCaretPreservingElement = ({
|
||||
reactBindableProps,
|
||||
hostEnforcedProps,
|
||||
setEditableFocused,
|
||||
reactUnsupportedEventListenerRef,
|
||||
caretPreservingElementRef,
|
||||
}: CreateCaretPreservingElementParams) => {
|
||||
const {
|
||||
value,
|
||||
@@ -55,14 +54,6 @@ export const createCaretPreservingElement = ({
|
||||
defaultValue: initialValue,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
ref: (node: CaretPreservingElement | null) => {
|
||||
reactUnsupportedEventListenerRef?.(node);
|
||||
if (!isDefined(node)) {
|
||||
return;
|
||||
}
|
||||
if (isNonEmptyString(value)) {
|
||||
syncValuePreservingCaret(node, value);
|
||||
}
|
||||
},
|
||||
ref: caretPreservingElementRef,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { FrontComponentInputFocusContext } from '@/host/contexts/FrontComponentInputFocusContext';
|
||||
import { useReactUnsupportedEventListenerRef } from '@/host/hooks/useReactUnsupportedEventListenerRef';
|
||||
import { buildHostReactPropsFromRemoteProps } from '@/host/utils/buildHostReactPropsFromRemoteProps';
|
||||
import { useCaretPreservingElementRef } from '@/host/hooks/useCaretPreservingElementRef';
|
||||
import { useHtmlHostElementProps } from '@/host/hooks/useHtmlHostElementProps';
|
||||
import { createCaretPreservingElement } from '@/host/utils/createCaretPreservingElement';
|
||||
import { createDropTargetGuardProps } from '@/host/utils/createDropTargetGuardProps';
|
||||
import { extractReactUnsupportedEventHandlers } from '@/host/utils/extractReactUnsupportedEventHandlers';
|
||||
import { createPlainHostElement } from '@/host/utils/createPlainHostElement';
|
||||
import { isTextLikeInputType } from '@/host/utils/isTextLikeInputType';
|
||||
import { preventDefaultThenForwardToRemote } from '@/host/utils/preventDefaultThenForwardToRemote';
|
||||
import { sanitizeIframeSandbox } from '@/host/utils/sanitizeIframeSandbox';
|
||||
|
||||
const VOID_ELEMENTS = new Set([
|
||||
'area',
|
||||
@@ -26,58 +22,64 @@ const VOID_ELEMENTS = new Set([
|
||||
'wbr',
|
||||
]);
|
||||
|
||||
const CARET_PRESERVING_TAGS = new Set(['input', 'textarea']);
|
||||
|
||||
type WrapperProps = { children?: React.ReactNode } & Record<string, unknown>;
|
||||
|
||||
export const createHtmlHostWrapper = (htmlTag: string) => {
|
||||
const isVoid = VOID_ELEMENTS.has(htmlTag);
|
||||
const isIframe = htmlTag === 'iframe';
|
||||
const isForm = htmlTag === 'form';
|
||||
|
||||
if (!CARET_PRESERVING_TAGS.has(htmlTag)) {
|
||||
return ({ children, ...props }: WrapperProps) => {
|
||||
const { reactBindableProps, hostEnforcedProps, composedElementRef } =
|
||||
useHtmlHostElementProps(props, htmlTag);
|
||||
|
||||
return createPlainHostElement({
|
||||
htmlTag,
|
||||
isVoid,
|
||||
reactBindableProps,
|
||||
hostEnforcedProps,
|
||||
composedElementRef,
|
||||
children,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const caretPreservingTag = htmlTag as 'input' | 'textarea';
|
||||
|
||||
return ({ children, ...props }: WrapperProps) => {
|
||||
const setEditableFocused = useContext(FrontComponentInputFocusContext);
|
||||
const {
|
||||
setEditableFocused,
|
||||
reactBindableProps,
|
||||
hostEnforcedProps,
|
||||
composedElementRef,
|
||||
} = useHtmlHostElementProps(props, htmlTag);
|
||||
|
||||
const { reactUnsupportedEventHandlers, reactBindableProps } =
|
||||
extractReactUnsupportedEventHandlers(
|
||||
buildHostReactPropsFromRemoteProps(props, htmlTag),
|
||||
);
|
||||
|
||||
const reactUnsupportedEventListenerRef =
|
||||
useReactUnsupportedEventListenerRef(reactUnsupportedEventHandlers);
|
||||
|
||||
const hostEnforcedProps: Record<string, unknown> = {
|
||||
...createDropTargetGuardProps(reactBindableProps),
|
||||
...(isIframe && {
|
||||
sandbox: sanitizeIframeSandbox(reactBindableProps.sandbox),
|
||||
}),
|
||||
// React 19 blocks the previous `action="javascript:void(0)"` guard.
|
||||
...(isForm && {
|
||||
onSubmit: preventDefaultThenForwardToRemote(
|
||||
reactBindableProps.onSubmit,
|
||||
),
|
||||
}),
|
||||
};
|
||||
const caretPreservingElementRef = useCaretPreservingElementRef(
|
||||
composedElementRef,
|
||||
reactBindableProps.value,
|
||||
);
|
||||
|
||||
if (
|
||||
htmlTag === 'textarea' ||
|
||||
(htmlTag === 'input' && isTextLikeInputType(reactBindableProps.type))
|
||||
caretPreservingTag === 'textarea' ||
|
||||
isTextLikeInputType(reactBindableProps.type)
|
||||
) {
|
||||
return createCaretPreservingElement({
|
||||
htmlTag,
|
||||
htmlTag: caretPreservingTag,
|
||||
reactBindableProps,
|
||||
hostEnforcedProps,
|
||||
setEditableFocused,
|
||||
reactUnsupportedEventListenerRef,
|
||||
caretPreservingElementRef,
|
||||
});
|
||||
}
|
||||
|
||||
return React.createElement(
|
||||
return createPlainHostElement({
|
||||
htmlTag,
|
||||
{
|
||||
...reactBindableProps,
|
||||
...hostEnforcedProps,
|
||||
ref: reactUnsupportedEventListenerRef,
|
||||
},
|
||||
isVoid ? undefined : children,
|
||||
);
|
||||
isVoid,
|
||||
reactBindableProps,
|
||||
hostEnforcedProps,
|
||||
composedElementRef,
|
||||
children,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
import { type ElementRefCallback } from '@/host/types/ElementRefCallback';
|
||||
|
||||
type CreatePlainHostElementParams = {
|
||||
htmlTag: string;
|
||||
isVoid: boolean;
|
||||
reactBindableProps: Record<string, unknown>;
|
||||
hostEnforcedProps: Record<string, unknown>;
|
||||
composedElementRef: ElementRefCallback;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const createPlainHostElement = ({
|
||||
htmlTag,
|
||||
isVoid,
|
||||
reactBindableProps,
|
||||
hostEnforcedProps,
|
||||
composedElementRef,
|
||||
children,
|
||||
}: CreatePlainHostElementParams) =>
|
||||
React.createElement(
|
||||
htmlTag,
|
||||
{
|
||||
...reactBindableProps,
|
||||
...hostEnforcedProps,
|
||||
ref: composedElementRef,
|
||||
},
|
||||
isVoid ? undefined : children,
|
||||
);
|
||||
Reference in New Issue
Block a user