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

SAP / ui5-webcomponents-react / 10347460016

12 Aug 2024 07:10AM CUT coverage: 79.674% (-0.2%) from 79.875%
10347460016

Pull #6169

github

web-flow
Merge 5e4f861e7 into 15660be0c
Pull Request #6169: feat(react 19): bind web components event handlers using react lifecycle

2527 of 3770 branches covered (67.03%)

12 of 15 new or added lines in 1 file covered. (80.0%)

11 existing lines in 1 file now uncovered.

4598 of 5771 relevant lines covered (79.67%)

72537.49 hits per line

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

79.17
/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 { useServerSideEffect } from './ssr.js';
9
import { camelToKebabCase, capitalizeFirstLetter, kebabToCamelCase } from './utils.js';
10

11
const SEMVER_REGEX =
12
  /^(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)(?:-(?<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
361✔
13

14
const createEventPropName = (eventName: string) => `on${capitalizeFirstLetter(kebabToCamelCase(eventName))}`;
1,696,098✔
15

16
const isPrimitiveAttribute = (value: unknown): boolean => {
361✔
17
  return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
681,389✔
18
};
19

20
type EventHandler = (event: CustomEvent<unknown>) => void;
21

22
export interface WithWebComponentPropTypes {
23
  /**
24
   * 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.
25
   *
26
   * __Note:__ This adds a rendering cycle to your component.
27
   */
28
  waitForDefine?: boolean;
29
}
30

31
const definedWebComponents = new Set<ComponentType>([]);
361✔
32

33
export const withWebComponent = <Props extends Record<string, any>, RefType = Ui5DomRef>(
361✔
34
  tagName: string,
35
  regularProperties: string[],
36
  booleanProperties: string[],
37
  slotProperties: string[],
38
  eventProperties: string[],
39
  loader: () => Promise<unknown>
40
) => {
41
  const reactMajorVersion = SEMVER_REGEX.exec(version)?.groups?.major;
44,403✔
42
  const webComponentsSupported = parseInt(reactMajorVersion) >= 19;
44,403✔
43
  // displayName will be assigned in the individual files
44
  // eslint-disable-next-line react/display-name
45
  return forwardRef<RefType, Props & WithWebComponentPropTypes>((props, wcRef) => {
44,403✔
46
    const { className, children, waitForDefine, ...rest } = props;
475,466✔
47
    const [componentRef, ref] = useSyncRef<RefType>(wcRef);
475,423✔
48
    const tagNameSuffix: string = getEffectiveScopingSuffixForTag(tagName);
475,423✔
49
    const Component = (tagNameSuffix ? `${tagName}-${tagNameSuffix}` : tagName) as unknown as ComponentType<
475,423✔
50
      CommonProps & { class?: string; ref?: Ref<RefType> }
51
    >;
52

53
    const [isDefined, setIsDefined] = useState(definedWebComponents.has(Component));
475,423✔
54

55
    useServerSideEffect(() => {
475,423✔
56
      void loader();
59,112✔
57
    });
58

59
    // regular props (no booleans, no slots and no events)
60
    const regularProps = regularProperties.reduce((acc, name) => {
475,423✔
61
      if (rest.hasOwnProperty(name) && isPrimitiveAttribute(rest[name])) {
1,972,943✔
62
        return { ...acc, [camelToKebabCase(name)]: rest[name] };
322,895✔
63
      }
64
      return acc;
1,650,048✔
65
    }, {});
66

67
    // boolean properties - only attach if they are truthy
68
    const booleanProps = booleanProperties.reduce((acc, name) => {
475,423✔
69
      if (webComponentsSupported) {
963,055!
70
        return { ...acc, [camelToKebabCase(name)]: rest[name] };
963,055✔
71
      } else {
NEW
72
        if (rest[name] === true || rest[name] === 'true') {
×
NEW
73
          return { ...acc, [camelToKebabCase(name)]: true };
×
74
        }
NEW
75
        return acc;
×
76
      }
77
    }, {});
78

79
    const slots = slotProperties.reduce((acc, name) => {
475,423✔
80
      const slotValue = rest[name] as ReactElement;
86,807✔
81

82
      if (!slotValue) {
86,807✔
83
        return acc;
65,640✔
84
      }
85

86
      if (rest[name]?.$$typeof === Symbol.for('react.portal')) {
21,167!
87
        console.warn('ReactPortal is not supported for slot props.');
×
88
        return acc;
×
89
      }
90

91
      const slottedChildren = [];
21,167✔
92
      let index = 0;
21,167✔
93
      const removeFragments = (element: ReactNode) => {
21,167✔
94
        if (!isValidElement(element)) return;
29,013✔
95
        if (element.type === Fragment) {
26,299✔
96
          const elementChildren = (element as ReactElement<{ children?: ReactNode | ReactNode[] }>).props?.children;
4,250✔
97
          if (Array.isArray(elementChildren)) {
4,250✔
98
            elementChildren.forEach((item) => {
4,231✔
99
              if (Array.isArray(item)) {
10,795✔
100
                item.forEach(removeFragments);
5✔
101
              } else {
102
                removeFragments(item);
10,790✔
103
              }
104
            });
105
          } else {
106
            removeFragments(elementChildren);
19✔
107
          }
108
        } else {
109
          slottedChildren.push(
22,049✔
110
            cloneElement<Partial<HTMLElement>>(element, {
111
              key: element.key ?? `${name}-${index}`,
39,900✔
112
              slot: name
113
            })
114
          );
115
          index++;
22,049✔
116
        }
117
      };
118

119
      if (Array.isArray(slotValue)) {
21,167✔
120
        slotValue.forEach((item) => {
7,161✔
121
          removeFragments(item);
4,188✔
122
        });
123
      } else {
124
        removeFragments(slotValue);
14,006✔
125
      }
126
      return [...acc, ...slottedChildren];
21,167✔
127
    }, []);
128

129
    // event binding
130
    useIsomorphicLayoutEffect(() => {
475,423✔
131
      if (webComponentsSupported) {
211,910✔
132
        return () => {
211,910✔
133
          // React can handle events
134
        };
135
      }
UNCOV
136
      const localRef = ref.current;
×
UNCOV
137
      const eventRegistry: Record<string, EventHandler> = {};
×
UNCOV
138
      if (!waitForDefine || isDefined) {
×
UNCOV
139
        eventProperties.forEach((eventName) => {
×
UNCOV
140
          const eventHandler = rest[createEventPropName(eventName)] as EventHandler;
×
UNCOV
141
          if (typeof eventHandler === 'function') {
×
UNCOV
142
            eventRegistry[eventName] = eventHandler;
×
143
            // @ts-expect-error: all custom events can be passed here, so `keyof HTMLElementEventMap` isn't sufficient
UNCOV
144
            localRef?.addEventListener(eventName, eventRegistry[eventName]);
×
145
          }
146
        });
147

UNCOV
148
        return () => {
×
UNCOV
149
          for (const eventName in eventRegistry) {
×
150
            // @ts-expect-error: all custom events can be passed here, so `keyof HTMLElementEventMap` isn't sufficient
UNCOV
151
            localRef?.removeEventListener(eventName, eventRegistry[eventName]);
×
152
          }
153
        };
154
      }
155
    }, [...eventProperties.map((eventName) => rest[createEventPropName(eventName)]), isDefined, waitForDefine]);
314,229✔
156

157
    const eventHandlers = eventProperties.reduce((events, eventName) => {
475,423✔
158
      const eventHandlerProp = rest[createEventPropName(eventName)];
314,229✔
159
      if (webComponentsSupported && eventHandlerProp) {
314,229✔
160
        events[`on${eventName}`] = eventHandlerProp;
184,261✔
161
      }
162
      return events;
314,229✔
163
    }, {});
164

165
    // non web component related props, just pass them
166
    const nonWebComponentRelatedProps = Object.entries(rest)
475,423✔
167
      .filter(([key]) => !regularProperties.includes(key))
2,069,866✔
168
      .filter(([key]) => !slotProperties.includes(key))
1,711,455✔
169
      .filter(([key]) => !booleanProperties.includes(key))
1,688,128✔
170
      .filter(([key]) => !eventProperties.map((eventName) => createEventPropName(eventName)).includes(key))
1,425,010✔
171
      .reduce((acc, [key, val]) => {
172
        if (!key.startsWith('aria-') && !key.startsWith('data-') && val === false) {
1,230,087✔
173
          return acc;
90✔
174
        }
175
        acc[key] = val;
1,229,997✔
176
        return acc;
1,229,997✔
177
      }, {});
178

179
    useEffect(() => {
475,423✔
180
      if (waitForDefine && !isDefined) {
59,112!
181
        customElements.whenDefined(Component as unknown as string).then(() => {
×
182
          setIsDefined(true);
×
183
          definedWebComponents.add(Component);
×
184
        });
185
      }
186
    }, [Component, waitForDefine, isDefined]);
187

188
    const propsToApply = regularProperties.map((prop) => ({ name: prop, value: props[prop] }));
1,972,943✔
189
    useEffect(() => {
475,423✔
190
      void customElements.whenDefined(Component as unknown as string).then(() => {
461,241✔
191
        for (const prop of propsToApply) {
461,238✔
192
          if (prop.value != null && !isPrimitiveAttribute(prop.value)) {
1,972,916✔
193
            if (ref.current) {
83✔
194
              ref.current[prop.name] = prop.value;
83✔
195
            }
196
          }
197
        }
198
      });
199
    }, [Component, ...propsToApply]);
200

201
    if (waitForDefine && !isDefined) {
475,423!
202
      return null;
×
203
    }
204

205
    return (
475,423✔
206
      <Component
207
        ref={componentRef}
208
        {...booleanProps}
209
        {...regularProps}
210
        {...eventHandlers}
211
        {...nonWebComponentRelatedProps}
212
        class={className}
213
        suppressHydrationWarning
214
      >
215
        {slots}
216
        {children}
217
      </Component>
218
    );
219
  });
220
};
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