Skip to content

Commit f95b50f

Browse files
authored
refactor(withWebcomponents): extract stable consts & fix unstable effect deps (#8314)
1 parent a7a35c8 commit f95b50f

3 files changed

Lines changed: 83 additions & 82 deletions

File tree

packages/base/src/internal/wrapper/withWebComponent.cy.tsx

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
import type { ButtonDomRef } from '@ui5/webcomponents-react';
66
import { Bar, Button, Popover, Switch } from '@ui5/webcomponents-react';
77
import { useReducer, useRef, useState } from 'react';
8+
import { withWebComponent } from './withWebComponent.js';
89

910
describe('withWebComponent', () => {
1011
// reset scoping
@@ -164,24 +165,19 @@ describe('withWebComponent', () => {
164165
cy.findByText('Btn').should('not.have.attr', 'disabled');
165166
});
166167

168+
// the underlying custom-element will not be updated, as the scoping suffix has to be set before any ui5wc import
167169
it('scoping', () => {
168-
const TestComp = () => {
169-
setCustomElementsScopingSuffix('ui5-wcr');
170-
return <Button>Test</Button>;
171-
};
172-
173-
const TestComp2 = () => {
174-
setCustomElementsScopingSuffix('ui5-wcr');
175-
setCustomElementsScopingRules({ include: [/^ui5-/], exclude: [/^ui5-button/] });
176-
return <Button>Test</Button>;
177-
};
170+
setCustomElementsScopingSuffix('ui5-wcr');
171+
const ScopedButton = withWebComponent('ui5-button', [], [], [], []);
178172

179-
cy.mount(<TestComp />);
173+
cy.mount(<ScopedButton>Test</ScopedButton>);
180174
cy.get('ui5-button-ui5-wcr').should('be.visible');
181175
cy.get('ui5-button').should('not.exist');
182-
183176
// now exclude the button
184-
cy.mount(<TestComp2 />);
177+
setCustomElementsScopingRules({ include: [/^ui5-/], exclude: [/^ui5-button/] });
178+
const UnscopedButton = withWebComponent('ui5-button', [], [], [], []);
179+
180+
cy.mount(<UnscopedButton>Test</UnscopedButton>);
185181
cy.get('ui5-button').should('be.visible');
186182
cy.get('ui5-button-ui5-wcr').should('not.exist');
187183
});

packages/base/src/internal/wrapper/withWebComponent.tsx

Lines changed: 70 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface WithWebComponentPropTypes {
2626
}
2727

2828
const definedWebComponents = new Set<ComponentType>([]);
29+
2930
/**
3031
* ⚠️ __INTERNAL__ use only! This function is not part of the public API.
3132
*/
@@ -37,35 +38,42 @@ export const withWebComponent = <Props extends Record<string, any>, RefType = Ui
3738
eventProperties: string[],
3839
) => {
3940
const webComponentsSupported = parseSemVer(version).major >= 19;
41+
const regularKebabNames = regularProperties.map(camelToKebabCase);
42+
const booleanKebabNames = booleanProperties.map(camelToKebabCase);
43+
const eventPropNames = eventProperties.map(createEventPropName);
44+
const knownKeys = new Set<string>([...regularProperties, ...slotProperties, ...booleanProperties, ...eventPropNames]);
45+
const tagNameSuffix: string = getEffectiveScopingSuffixForTag(tagName);
46+
const Component = (tagNameSuffix ? `${tagName}-${tagNameSuffix}` : tagName) as unknown as ComponentType<
47+
CommonProps & { class?: string; ref?: Ref<RefType> }
48+
>;
49+
4050
// displayName will be assigned in the individual files
4151
// eslint-disable-next-line react/display-name
4252
return forwardRef<RefType, Props & WithWebComponentPropTypes>((props, wcRef) => {
4353
const { className, children, waitForDefine, ...rest } = props;
4454
const [componentRef, ref] = useSyncRef<RefType>(wcRef);
45-
const tagNameSuffix: string = getEffectiveScopingSuffixForTag(tagName);
46-
const Component = (tagNameSuffix ? `${tagName}-${tagNameSuffix}` : tagName) as unknown as ComponentType<
47-
CommonProps & { class?: string; ref?: Ref<RefType> }
48-
>;
4955
const [isDefined, setIsDefined] = useState(definedWebComponents.has(Component));
5056
// regular props (no booleans, no slots and no events)
51-
const regularProps = regularProperties.reduce((acc, name) => {
57+
const regularProps: Record<string, unknown> = {};
58+
for (let i = 0; i < regularProperties.length; i++) {
59+
const name = regularProperties[i];
5260
if (Object.prototype.hasOwnProperty.call(rest, name) && isPrimitiveAttribute(rest[name])) {
53-
return { ...acc, [camelToKebabCase(name)]: rest[name] };
61+
regularProps[regularKebabNames[i]] = rest[name];
5462
}
55-
return acc;
56-
}, {});
63+
}
5764

5865
// boolean properties - only attach if they are truthy
59-
const booleanProps = booleanProperties.reduce((acc, name) => {
66+
const booleanProps: Record<string, unknown> = {};
67+
for (let i = 0; i < booleanProperties.length; i++) {
68+
const name = booleanProperties[i];
6069
if (webComponentsSupported) {
61-
return { ...acc, [camelToKebabCase(name)]: rest[name] };
70+
booleanProps[booleanKebabNames[i]] = rest[name];
6271
} else {
6372
if (rest[name] === true || rest[name] === 'true') {
64-
return { ...acc, [camelToKebabCase(name)]: true };
73+
booleanProps[booleanKebabNames[i]] = true;
6574
}
66-
return acc;
6775
}
68-
}, {});
76+
}
6977

7078
const slots = slotProperties.reduce((acc, name) => {
7179
const slotValue = rest[name] as ReactElement;
@@ -117,58 +125,57 @@ export const withWebComponent = <Props extends Record<string, any>, RefType = Ui
117125
return [...acc, ...slottedChildren];
118126
}, []);
119127

120-
// event binding
121-
useIsomorphicLayoutEffect(() => {
122-
if (webComponentsSupported) {
123-
return () => {
124-
// React can handle events
125-
};
126-
}
127-
const localRef = ref.current;
128-
const eventRegistry: Record<string, EventHandler> = {};
129-
if (!waitForDefine || isDefined) {
130-
eventProperties.forEach((eventName) => {
131-
const eventHandler = rest[createEventPropName(eventName)] as EventHandler;
132-
if (typeof eventHandler === 'function') {
133-
eventRegistry[eventName] = eventHandler;
134-
// @ts-expect-error: all custom events can be passed here, so `keyof HTMLElementEventMap` isn't sufficient
135-
localRef?.addEventListener(eventName, eventRegistry[eventName]);
136-
}
137-
});
128+
// event binding - React 19 supports this natively
129+
if (!webComponentsSupported) {
130+
// React version never changes between renders
131+
// eslint-disable-next-line react-hooks/rules-of-hooks
132+
useIsomorphicLayoutEffect(() => {
133+
const localRef = ref.current;
134+
const eventRegistry: Record<string, EventHandler> = {};
135+
if (!waitForDefine || isDefined) {
136+
eventProperties.forEach((eventName, i) => {
137+
const eventHandler = rest[eventPropNames[i]] as EventHandler;
138+
if (typeof eventHandler === 'function') {
139+
eventRegistry[eventName] = eventHandler;
140+
// @ts-expect-error: all custom events can be passed here, so `keyof HTMLElementEventMap` isn't sufficient
141+
localRef?.addEventListener(eventName, eventRegistry[eventName]);
142+
}
143+
});
138144

139-
return () => {
140-
for (const eventName in eventRegistry) {
141-
// @ts-expect-error: all custom events can be passed here, so `keyof HTMLElementEventMap` isn't sufficient
142-
localRef?.removeEventListener(eventName, eventRegistry[eventName]);
143-
}
144-
};
145-
}
146-
}, [...eventProperties.map((eventName) => rest[createEventPropName(eventName)]), isDefined, waitForDefine]);
145+
return () => {
146+
for (const eventName in eventRegistry) {
147+
// @ts-expect-error: all custom events can be passed here, so `keyof HTMLElementEventMap` isn't sufficient
148+
localRef?.removeEventListener(eventName, eventRegistry[eventName]);
149+
}
150+
};
151+
}
152+
}, [...eventPropNames.map((propName) => rest[propName]), isDefined, waitForDefine]);
153+
}
147154

148-
const eventHandlers = eventProperties.reduce((events, eventName) => {
149-
const eventHandlerProp = rest[createEventPropName(eventName)];
150-
if (webComponentsSupported && eventHandlerProp) {
151-
events[`on${eventName}`] = eventHandlerProp;
155+
const eventHandlers: Record<string, unknown> = {};
156+
if (webComponentsSupported) {
157+
for (let i = 0; i < eventProperties.length; i++) {
158+
const eventHandlerProp = rest[eventPropNames[i]];
159+
if (eventHandlerProp) {
160+
eventHandlers[`on${eventProperties[i]}`] = eventHandlerProp;
161+
}
152162
}
153-
return events;
154-
}, {});
163+
}
155164

156165
// In React 19 events aren't correctly attached after hydration
157166
const [attachEvents, setAttachEvents] = useState(!webComponentsSupported || !Object.keys(eventHandlers).length); // apply workaround only for React19 and if event props are defined
158167

159168
// non web component related props, just pass them
160-
const nonWebComponentRelatedProps = Object.entries(rest)
161-
.filter(([key]) => !regularProperties.includes(key))
162-
.filter(([key]) => !slotProperties.includes(key))
163-
.filter(([key]) => !booleanProperties.includes(key))
164-
.filter(([key]) => !eventProperties.map((eventName) => createEventPropName(eventName)).includes(key))
165-
.reduce((acc, [key, val]) => {
169+
const nonWebComponentRelatedProps: Record<string, unknown> = {};
170+
for (const key in rest) {
171+
if (Object.prototype.hasOwnProperty.call(rest, key) && !knownKeys.has(key)) {
172+
const val = rest[key];
166173
if (!key.startsWith('aria-') && !key.startsWith('data-') && val === false) {
167-
return acc;
174+
continue;
168175
}
169-
acc[key] = val;
170-
return acc;
171-
}, {});
176+
nonWebComponentRelatedProps[key] = val;
177+
}
178+
}
172179

173180
useEffect(() => {
174181
if (waitForDefine && !isDefined) {
@@ -177,20 +184,22 @@ export const withWebComponent = <Props extends Record<string, any>, RefType = Ui
177184
definedWebComponents.add(Component);
178185
});
179186
}
180-
}, [Component, waitForDefine, isDefined]);
187+
}, [waitForDefine, isDefined]);
181188

182-
const propsToApply = regularProperties.map((prop) => ({ name: prop, value: props[prop] }));
189+
const regularPropValues = regularProperties.map((prop) => props[prop]);
183190
useEffect(() => {
184191
void customElements.whenDefined(Component as unknown as string).then(() => {
185-
for (const prop of propsToApply) {
186-
if (prop.value != null && !isPrimitiveAttribute(prop.value)) {
192+
for (let i = 0; i < regularProperties.length; i++) {
193+
const value = regularPropValues[i];
194+
if (value != null && !isPrimitiveAttribute(value)) {
187195
if (ref.current) {
188-
ref.current[prop.name] = prop.value;
196+
ref.current[regularProperties[i]] = value;
189197
}
190198
}
191199
}
192200
});
193-
}, [Component, ...propsToApply]);
201+
// eslint-disable-next-line react-hooks/exhaustive-deps
202+
}, regularPropValues);
194203

195204
useIsomorphicLayoutEffect(() => {
196205
setAttachEvents(true);
@@ -203,7 +212,6 @@ export const withWebComponent = <Props extends Record<string, any>, RefType = Ui
203212
// compatibility wrapper for ExpandableText - remove in v3
204213
if (tagName === 'ui5-expandable-text') {
205214
const renderWhiteSpace = nonWebComponentRelatedProps['renderWhitespace'] ? true : undefined;
206-
// @ts-expect-error: overflowMode is available
207215
const { ['overflow-mode']: overflowMode, text, ...restRegularProps } = regularProps;
208216
const showOverflowInPopover = nonWebComponentRelatedProps['showOverflowInPopover'];
209217
return (

packages/main/src/components/ObjectPage/index.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,19 +118,16 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
118118
const [headerCollapsedInternal, setHeaderCollapsedInternal] = useState<undefined | boolean>(undefined);
119119
const [scrolledHeaderExpanded, setScrolledHeaderExpanded] = useState(false);
120120
const [sectionSpacer, setSectionSpacer] = useState(0);
121-
const [currentTabModeSection, setCurrentTabModeSection] = useState(null);
121+
const currentTabModeSection = useMemo(
122+
() => (mode === ObjectPageMode.IconTabBar ? getSectionById(children, internalSelectedSectionId) : null),
123+
[mode, children, internalSelectedSectionId],
124+
);
122125
const [toggledCollapsedHeaderWasVisible, setToggledCollapsedHeaderWasVisible] = useState(false);
123126
const sections = mode === ObjectPageMode.IconTabBar ? currentTabModeSection : children;
124127
const scrollEndHandler = useOnScrollEnd({ objectPageRef, setTabSelectId });
125128
// only required for IconTabBar mode
126129
const [wasUserSectionChange, setWasUserSectionChange] = useState(false);
127130

128-
useEffect(() => {
129-
const currentSection =
130-
mode === ObjectPageMode.IconTabBar ? getSectionById(children, internalSelectedSectionId) : null;
131-
setCurrentTabModeSection(currentSection);
132-
}, [mode, children, internalSelectedSectionId]);
133-
134131
const onSelectedSectionChangeRef = useRef(onSelectedSectionChange);
135132
const onToggleHeaderAreaRef = useRef(onToggleHeaderArea);
136133
const onScrollRef = useRef(rest.onScroll);

0 commit comments

Comments
 (0)