• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SAP / ui5-webcomponents-react / 13201212040

07 Feb 2025 01:52PM CUT coverage: 87.432% (+0.007%) from 87.425%
13201212040

Pull #6910

github

web-flow
Merge de947bd37 into 06df689aa
Pull Request #6910: fix: suppress hydration warning

2928 of 3885 branches covered (75.37%)

4 of 4 new or added lines in 1 file covered. (100.0%)

1 existing line in 1 file now uncovered.

5113 of 5848 relevant lines covered (87.43%)

47016.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

76.0
/packages/main/src/internal/withWebComponent.tsx
1
'use client';
2

3
import { getEffectiveScopingSuffixForTag } from '@ui5/webcomponents-base/dist/CustomElementsScope.js';
4
import { useIsomorphicLayoutEffect, useSyncRef } from '@ui5/webcomponents-react-base';
5
import type { ComponentType, ReactElement, ReactNode, Ref } from 'react';
6
import { cloneElement, forwardRef, Fragment, isValidElement, useEffect, useState, version } from 'react';
7
import type { CommonProps, Ui5DomRef } from '../types/index.js';
8
import { camelToKebabCase, capitalizeFirstLetter, kebabToCamelCase, parseSemVer } from './utils.js';
9

10
const createEventPropName = (eventName: string) => `on${capitalizeFirstLetter(kebabToCamelCase(eventName))}`;
2,367,618✔
11

12
const isPrimitiveAttribute = (value: unknown): boolean => {
426✔
13
  return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
966,306✔
14
};
15

16
type EventHandler = (event: CustomEvent<unknown>) => void;
17

18
export interface WithWebComponentPropTypes {
19
  /**
20
   * Defines whether the component should wait for the underlying custom element of the web component to be defined. This can be useful, for example, for using instance methods when mounting the component.
21
   *
22
   * __Note:__ This adds a rendering cycle to your component.
23
   */
24
  waitForDefine?: boolean;
25
}
26

27
const definedWebComponents = new Set<ComponentType>([]);
426✔
28

29
export const withWebComponent = <Props extends Record<string, any>, RefType = Ui5DomRef>(
426✔
30
  tagName: string,
31
  regularProperties: string[],
32
  booleanProperties: string[],
33
  slotProperties: string[],
34
  eventProperties: string[]
35
) => {
36
  const webComponentsSupported = parseSemVer(version).major >= 19;
56,658✔
37
  // displayName will be assigned in the individual files
38
  // eslint-disable-next-line react/display-name
39
  return forwardRef<RefType, Props & WithWebComponentPropTypes>((props, wcRef) => {
56,658✔
40
    const { className, children, waitForDefine, ...rest } = props;
634,689✔
41
    const [componentRef, ref] = useSyncRef<RefType>(wcRef);
634,689✔
42
    const tagNameSuffix: string = getEffectiveScopingSuffixForTag(tagName);
634,689✔
43
    const Component = (tagNameSuffix ? `${tagName}-${tagNameSuffix}` : tagName) as unknown as ComponentType<
634,689✔
44
      CommonProps & { class?: string; ref?: Ref<RefType> }
45
    >;
46
    const [isDefined, setIsDefined] = useState(definedWebComponents.has(Component));
634,689✔
47
    // regular props (no booleans, no slots and no events)
48
    const regularProps = regularProperties.reduce((acc, name) => {
634,689✔
49
      if (Object.prototype.hasOwnProperty.call(rest, name) && isPrimitiveAttribute(rest[name])) {
3,014,133✔
50
        return { ...acc, [camelToKebabCase(name)]: rest[name] };
480,931✔
51
      }
52
      return acc;
2,533,202✔
53
    }, {});
54

55
    // boolean properties - only attach if they are truthy
56
    const booleanProps = booleanProperties.reduce((acc, name) => {
634,689✔
57
      if (webComponentsSupported) {
1,261,378!
58
        return { ...acc, [camelToKebabCase(name)]: rest[name] };
1,261,378✔
59
      } else {
60
        if (rest[name] === true || rest[name] === 'true') {
×
61
          return { ...acc, [camelToKebabCase(name)]: true };
×
62
        }
63
        return acc;
×
64
      }
65
    }, {});
66

67
    const slots = slotProperties.reduce((acc, name) => {
634,689✔
68
      const slotValue = rest[name] as ReactElement;
269,800✔
69

70
      if (!slotValue) {
269,800✔
71
        return acc;
213,590✔
72
      }
73

74
      if (rest[name]?.$$typeof === Symbol.for('react.portal')) {
56,210!
75
        console.warn('ReactPortal is not supported for slot props.');
×
76
        return acc;
×
77
      }
78

79
      const slottedChildren = [];
56,210✔
80
      let index = 0;
56,210✔
81
      const removeFragments = (element: ReactNode) => {
56,210✔
82
        if (!isValidElement(element)) return;
58,619✔
83
        if (element.type === Fragment) {
55,546✔
84
          const elementChildren = (element as ReactElement<{ children?: ReactNode | ReactNode[] }>).props?.children;
6,898✔
85
          if (Array.isArray(elementChildren)) {
6,898✔
86
            elementChildren.forEach((item) => {
4,976✔
87
              if (Array.isArray(item)) {
12,752✔
88
                item.forEach(removeFragments);
5✔
89
              } else {
90
                removeFragments(item);
12,747✔
91
              }
92
            });
93
          } else {
94
            removeFragments(elementChildren);
1,922✔
95
          }
96
        } else {
97
          slottedChildren.push(
48,648✔
98
            cloneElement<Partial<HTMLElement>>(element, {
99
              key: element.key ?? `${name}-${index}`,
84,913✔
100
              slot: name
101
            })
102
          );
103
          index++;
48,648✔
104
        }
105
      };
106

107
      if (Array.isArray(slotValue)) {
56,210✔
108
        slotValue.forEach((item) => {
24,643✔
109
          removeFragments(item);
12,373✔
110
        });
111
      } else {
112
        removeFragments(slotValue);
31,567✔
113
      }
114
      return [...acc, ...slottedChildren];
56,210✔
115
    }, []);
116

117
    // event binding
118
    useIsomorphicLayoutEffect(() => {
634,689✔
119
      if (webComponentsSupported) {
269,692✔
120
        return () => {
269,692✔
121
          // React can handle events
122
        };
123
      }
124
      const localRef = ref.current;
×
125
      const eventRegistry: Record<string, EventHandler> = {};
×
126
      if (!waitForDefine || isDefined) {
×
127
        eventProperties.forEach((eventName) => {
×
128
          const eventHandler = rest[createEventPropName(eventName)] as EventHandler;
×
129
          if (typeof eventHandler === 'function') {
×
130
            eventRegistry[eventName] = eventHandler;
×
131
            // @ts-expect-error: all custom events can be passed here, so `keyof HTMLElementEventMap` isn't sufficient
132
            localRef?.addEventListener(eventName, eventRegistry[eventName]);
×
133
          }
134
        });
135

136
        return () => {
×
137
          for (const eventName in eventRegistry) {
×
138
            // @ts-expect-error: all custom events can be passed here, so `keyof HTMLElementEventMap` isn't sufficient
139
            localRef?.removeEventListener(eventName, eventRegistry[eventName]);
×
140
          }
141
        };
142
      }
143
    }, [...eventProperties.map((eventName) => rest[createEventPropName(eventName)]), isDefined, waitForDefine]);
454,104✔
144

145
    const eventHandlers = eventProperties.reduce((events, eventName) => {
634,689✔
146
      const eventHandlerProp = rest[createEventPropName(eventName)];
454,104✔
147
      if (webComponentsSupported && eventHandlerProp) {
454,104✔
148
        events[`on${eventName}`] = eventHandlerProp;
243,956✔
149
      }
150
      return events;
454,104✔
151
    }, {});
152

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

156
    // non web component related props, just pass them
157
    const nonWebComponentRelatedProps = Object.entries(rest)
634,689✔
158
      .filter(([key]) => !regularProperties.includes(key))
2,726,169✔
159
      .filter(([key]) => !slotProperties.includes(key))
2,239,945✔
160
      .filter(([key]) => !booleanProperties.includes(key))
2,178,792✔
161
      .filter(([key]) => !eventProperties.map((eventName) => createEventPropName(eventName)).includes(key))
1,858,608✔
162
      .reduce((acc, [key, val]) => {
163
        if (!key.startsWith('aria-') && !key.startsWith('data-') && val === false) {
1,589,079✔
164
          return acc;
718✔
165
        }
166
        acc[key] = val;
1,588,361✔
167
        return acc;
1,588,361✔
168
      }, {});
169

170
    useEffect(() => {
634,689✔
171
      if (waitForDefine && !isDefined) {
80,100!
172
        void customElements.whenDefined(Component as unknown as string).then(() => {
×
173
          setIsDefined(true);
×
174
          definedWebComponents.add(Component);
×
175
        });
176
      }
177
    }, [Component, waitForDefine, isDefined]);
178

179
    const propsToApply = regularProperties.map((prop) => ({ name: prop, value: props[prop] }));
3,014,133✔
180
    useEffect(() => {
634,689✔
181
      void customElements.whenDefined(Component as unknown as string).then(() => {
630,983✔
182
        for (const prop of propsToApply) {
630,980✔
183
          if (prop.value != null && !isPrimitiveAttribute(prop.value)) {
3,009,497✔
184
            if (ref.current) {
4✔
185
              ref.current[prop.name] = prop.value;
4✔
186
            }
187
          }
188
        }
189
      });
190
    }, [Component, ...propsToApply]);
191

192
    useEffect(() => {
634,689✔
193
      setAttachEvents(true);
80,100✔
194
    }, []);
195

196
    if (waitForDefine && !isDefined) {
634,689!
UNCOV
197
      return null;
×
198
    }
199

200
    // compatibility wrapper for ExpandableText - remove in v3
201
    if (tagName === 'ui5-expandable-text') {
634,689!
202
      const renderWhiteSpace = nonWebComponentRelatedProps['renderWhitespace'] ? true : undefined;
×
203
      // @ts-expect-error: overflowMode is available
204
      const { ['overflow-mode']: overflowMode, text, ...restRegularProps } = regularProps;
×
205
      const showOverflowInPopover = nonWebComponentRelatedProps['showOverflowInPopover'];
×
206
      return (
×
207
        <Component
208
          ref={componentRef}
209
          {...booleanProps}
210
          {...restRegularProps}
211
          {...(attachEvents ? eventHandlers : {})}
×
212
          {...nonWebComponentRelatedProps}
213
          overflow-mode={overflowMode ?? (showOverflowInPopover ? 'Popover' : 'InPlace')}
×
214
          // @ts-expect-error: text is available
215
          text={text ?? children}
×
216
          class={className}
217
          suppressHydrationWarning
218
          data-_render-whitespace={renderWhiteSpace}
219
        >
220
          {slots}
221
        </Component>
222
      );
223
    }
224

225
    return (
634,689✔
226
      <Component
227
        ref={componentRef}
228
        {...booleanProps}
229
        {...regularProps}
230
        {...(attachEvents ? eventHandlers : {})}
634,689✔
231
        {...nonWebComponentRelatedProps}
232
        class={className}
233
        suppressHydrationWarning
234
      >
235
        {slots}
236
        {children}
237
      </Component>
238
    );
239
  });
240
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc