Microsoft/fast-dna

View on GitHub
packages/utilities/fast-react-wrapper/src/index.ts

Summary

Maintainability
F
3 days
Test Coverage
import type ReactModule from "react";
import {
    Constructable,
    FASTElementDefinition,
    Observable,
} from "@microsoft/fast-element";

const reservedReactProperties = new Set([
    "children",
    "localName",
    "ref",
    "style",
    "className",
]);

const emptyProps = Object.freeze(Object.create(null));
const DEFAULT_CACHE_NAME = "_default";
// This will be a two levels cache Map<type, Map<name, ReactWrapper>>
// to distinguish components of same type but different tag name.
// Default name: '_default'
const wrappersCache = new Map<any, Map<string, any>>();

/**
 * Event signatures for a React wrapper.
 * @public
 */
export type ReactEvents<T> = {
    [P in keyof T]?: (e: Event) => unknown;
};

/**
 * Maps React event names to DOM event types for special handling.
 * @public
 */
export type ReactEventMap<T> = {
    [P in keyof T]: string;
};

/**
 * Optional configuration for the React wrapper.
 * @public
 */
export type ReactWrapperConfig<TEvents> = {
    /**
     * The tag that the React component will generate.
     */
    name?: string;
    /**
     * A mapping of React event name to DOM event type to be handled
     * by attaching event listeners to the underlying web component.
     * @remarks
     * Typically only needed for non-FAST web components.
     */
    events?: ReactEventMap<TEvents>;
    /**
     * A list of properties to be handled directly by the wrapper.
     * @remarks
     * Typically only needed for vanilla web components.
     */
    properties?: string[];
};

/**
 * The props used by a ReactWrapper.
 * @public
 */
export type ReactWrapperProps<
    TElement extends HTMLElement,
    TEvents
> = ReactModule.PropsWithChildren<
    ReactModule.PropsWithRef<
        Partial<Omit<TElement, "children" | "style">> &
            ReactEvents<TEvents> &
            ReactModule.HTMLAttributes<HTMLElement>
    > & { style?: ReactModule.CSSProperties }
>;

/**
 * A React component that wraps a Web Component.
 * @public
 */
export interface ReactWrapper<TElement extends HTMLElement, TEvents>
    extends Constructable<ReactModule.Component<ReactWrapperProps<TElement, TEvents>>> {
    displayName: string;
}

// There are 2 kinds of refs and there's no built in React API to set one.
function setRef(ref: React.Ref<unknown>, value: Element | null) {
    if (typeof ref === "function") {
        (ref as (e: Element | null) => void)(value);
    } else {
        (ref as { current: Element | null }).current = value;
    }
}

function getTagName<TElement, TEvents>(
    type: Constructable<TElement>,
    config: ReactWrapperConfig<TEvents>
) {
    if (!config.name) {
        /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
        const definition = FASTElementDefinition.getByType(type)!;

        if (definition) {
            return definition.name;
        } else {
            throw new Error(
                "React wrappers must wrap a FASTElement or be configured with a name."
            );
        }
    }

    return config.name;
}

function getElementEvents<TEvents>(config: ReactWrapperConfig<TEvents>) {
    return config.events || (config.events = {} as ReactEventMap<TEvents>);
}

function keyIsValid<TElement, TEvents>(
    type: Constructable<TElement>,
    config: ReactWrapperConfig<TEvents>,
    name: string
): boolean {
    if (reservedReactProperties.has(name)) {
        console.warn(
            `${getTagName(type, config)} contains property ${name} which is a React ` +
                `reserved property. It will be used by React and not set on ` +
                `the element.`
        );

        return false;
    }

    return true;
}

function getElementKeys<TElement, TEvents>(
    type: Constructable<TElement>,
    config: ReactWrapperConfig<TEvents>
) {
    if (!(config as any).keys) {
        if (config.properties) {
            (config as any).keys = new Set(
                config.properties.concat(Object.keys(getElementEvents(config)))
            );
        } else {
            const keys = new Set(Object.keys(getElementEvents(config)));
            const accessors = Observable.getAccessors(type.prototype);

            if (accessors.length > 0) {
                for (const a of accessors) {
                    if (keyIsValid(type, config, a.name)) {
                        keys.add(a.name);
                    }
                }
            } else {
                for (const p in type.prototype) {
                    if (!(p in HTMLElement.prototype) && keyIsValid(type, config, p)) {
                        keys.add(p);
                    }
                }
            }

            (config as any).keys = keys;
        }
    }

    return (config as any).keys;
}

/**
 * @param React - The React module, typically imported from the `react` npm
 * package
 * @param registry - The custom elements registry to register components in if wrapped by definition.
 * @public
 */
export function reactWrapper(
    React: any,
    registry: CustomElementRegistry = customElements
) {
    /**
     * Creates a React component for a custom element. Properties are distinguished
     * from attributes automatically, and events can be configured so they are
     * added to the custom element as event listeners.
     *
     * @param type - The custom element class to wrap or a FASTElementDefinition object.
     * @param config - Special configuration for the wrapper.
     */
    function wrap<TElement extends HTMLElement, TEvents>(
        def: FASTElementDefinition<Constructable<TElement>>,
        config?: ReactWrapperConfig<TEvents>
    ): ReactWrapper<TElement, TEvents>;
    function wrap<TElement extends HTMLElement, TEvents>(
        type: Constructable<TElement>,
        config?: ReactWrapperConfig<TEvents>
    ): ReactWrapper<TElement, TEvents>;
    function wrap<TElement extends HTMLElement, TEvents>(
        type: any,
        config: ReactWrapperConfig<TEvents> = {}
    ): ReactWrapper<TElement, TEvents> {
        // Props used by this component wrapper. This is the ComponentProps and the
        // special `__forwardedRef` property. Note, this ref is special because
        // it's both needed in this component to get access to the rendered element
        // and must fulfill any ref passed by the user.
        type InternalProps = ReactWrapperProps<TElement, TEvents> & {
            __forwardedRef?: ReactModule.Ref<unknown>;
        };

        if (type instanceof FASTElementDefinition) {
            type.define(registry);
            type = type.type;
        }

        const name = getTagName(type, config);

        const cachedCandidates = wrappersCache.get(type);
        if (cachedCandidates) {
            const cachedWrapper = cachedCandidates.get(name ?? DEFAULT_CACHE_NAME);
            if (cachedWrapper) {
                return cachedWrapper;
            }
        }

        class ReactComponent extends React.Component<InternalProps> {
            private _element: TElement | null = null;
            private _elementProps!: { [index: string]: unknown };
            private _userRef?: ReactModule.Ref<unknown>;
            private _ref?: (element: TElement | null) => void;

            private _updateElement(oldProps?: InternalProps) {
                const element = this._element;

                if (element === null) {
                    return;
                }

                const currentProps = this.props;
                const previousProps = oldProps || emptyProps;
                const events = getElementEvents(config);

                for (const key in this._elementProps) {
                    const newValue = currentProps[key];
                    const event = events[key as keyof TEvents];

                    if (event === undefined) {
                        element[key as keyof TElement] =
                            newValue as TElement[keyof TElement];
                    } else {
                        const oldValue = previousProps[key];

                        if (newValue === oldValue) {
                            continue;
                        }

                        if (oldValue !== undefined) {
                            element.removeEventListener(event, oldValue);
                        }

                        if (newValue !== undefined) {
                            element.addEventListener(event, newValue);
                        }
                    }
                }
            }

            componentDidMount() {
                this._updateElement();
            }

            componentDidUpdate(old: InternalProps) {
                this._updateElement(old);
            }

            render() {
                // Since refs only get fulfilled once, pass a new one if the user's
                // ref changed. This allows refs to be fulfilled as expected, going from
                // having a value to null.
                const userRef = this.props.__forwardedRef as ReactModule.Ref<unknown>;
                if (this._ref === undefined || this._userRef !== userRef) {
                    this._ref = (value: TElement | null) => {
                        if (this._element === null) {
                            this._element = value;
                        }
                        if (userRef !== null) {
                            setRef(userRef, value);
                        }
                        this._userRef = userRef;
                    };
                }

                // Filter class properties and pass the remaining attributes to React.
                // This allows attributes to use framework rules
                // for setting attributes and render correctly under SSR.
                const newReactProps: any = { ref: this._ref };
                const newElementProps = (this._elementProps = {} as any);
                const elementKeys = getElementKeys(type, config);
                const currentProps = this.props;

                for (const k in currentProps) {
                    const v = currentProps[k];

                    if (elementKeys.has(k)) {
                        newElementProps[k] = v;
                    } else {
                        // React does *not* handle `className` for custom elements so
                        // coerce it to `class` so it's handled correctly.
                        newReactProps[k === "className" ? "class" : k] = v;
                    }
                }

                return React.createElement(name, newReactProps);
            }
        }

        const reactComponent = React.forwardRef(
            (
                props?: ReactWrapperProps<TElement, TEvents>,
                ref?: ReactModule.Ref<unknown>
            ) =>
                React.createElement(
                    ReactComponent,
                    { ...props, __forwardedRef: ref } as InternalProps,
                    props?.children
                )
        ) as ReactWrapper<TElement, TEvents>;

        reactComponent.displayName = name;

        if (!wrappersCache.has(type)) {
            wrappersCache.set(type, new Map<string, any>());
        }
        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
        wrappersCache.get(type)!.set(name ?? DEFAULT_CACHE_NAME, reactComponent);

        return reactComponent;
    }

    return wrap;
}