/** * `@virtuoso.dev/react-urx` exports the [[systemToComponent]] function. * It wraps urx systems in to UI **logic provider components**, * mapping the system input and output streams to the component input / output points. * * ### Simple System wrapped as React Component * * ```tsx * const sys = system(() => { * const foo = statefulStream(42) * return { foo } * }) * * const { Component: MyComponent, useEmitterValue } = systemToComponent(sys, { * required: { fooProp: 'foo' }, * }) * * const Child = () => { * const foo = useEmitterValue('foo') * return
{foo}
* } * * const App = () => { * return * } * ``` * * @packageDocumentation */ import * as React from 'react' import { ComponentType, createContext, createElement, forwardRef, ForwardRefExoticComponent, ReactNode, RefAttributes, useContext, useImperativeHandle, useState, useCallback, } from 'react' import { AnySystemSpec, reset, curry1to0, curry2to1, Emitter, SR, eventHandler, getValue, publish, Publisher, init, StatefulStream, Stream, subscribe, always, tap, } from '@virtuoso.dev/urx' /** @internal */ interface Dict { [key: string]: T } /** @internal */ function omit, K extends readonly string[]>(keys: K, obj: O): Omit { var result = {} as Dict var index = {} as Dict<1> var idx = 0 var len = keys.length while (idx < len) { index[keys[idx]] = 1 idx += 1 } for (var prop in obj) { if (!index.hasOwnProperty(prop)) { result[prop] = obj[prop] } } return result as any } const useIsomorphicLayoutEffect = typeof document !== 'undefined' ? React.useLayoutEffect : React.useEffect /** @internal */ export type Observable = Emitter | Publisher /** * Describes the mapping between the system streams and the component properties. * Each property uses the keys as the names of the properties and the values as the corresponding stream names. * @typeParam SS the type of the system. */ export interface SystemPropsMap, D = { [key: string]: K }> { /** * Specifies the required component properties. */ required?: D /** * Specifies the optional component properties. */ optional?: D /** * Specifies the component methods, if any. Streams are converted to methods with a single argument. * When invoked, the method publishes the value of the argument to the specified stream. */ methods?: D /** * Specifies the component "event" properties, if any. * Event properties accept callback functions which get executed when the stream emits a new value. */ events?: D } /** @internal */ export type PropsFromPropMap> = { [K in Extract]: M['required'][K] extends string ? SR[M['required'][K]] extends Observable ? R : never : never } & { [K in Extract]?: M['optional'][K] extends string ? SR[M['optional'][K]] extends Observable ? R : never : never } & { [K in Extract]?: M['events'][K] extends string ? SR[M['events'][K]] extends Observable ? (value: R) => void : never : never } /** @internal */ export type MethodsFromPropMap> = { [K in Extract]: M['methods'][K] extends string ? SR[M['methods'][K]] extends Observable ? (value: R) => void : never : never } /** * Used to correctly specify type refs for system components * * ```tsx * const s = system(() => { return { a: statefulStream(0) } }) * const { Component } = systemToComponent(s) * * const App = () => { * const ref = useRef>() * return * } * ``` * * @typeParam T the type of the component */ export type RefHandle = T extends ForwardRefExoticComponent> ? Handle : never /** * Converts a system spec to React component by mapping the system streams to component properties, events and methods. Returns hooks for querying and modifying * the system streams from the component's child components. * @param systemSpec The return value from a [[system]] call. * @param map The streams to props / events / methods mapping Check [[SystemPropsMap]] for more details. * @param Root The optional React component to render. By default, the resulting component renders nothing, acting as a logical wrapper for its children. * @returns an object containing the following: * - `Component`: the React component. * - `useEmitterValue`: a hook that lets child components use values emitted from the specified output stream. * - `useEmitter`: a hook that calls the provided callback whenever the specified stream emits a value. * - `usePublisher`: a hook which lets child components publish values to the specified stream. *
*/ export function systemToComponent, S extends SR, R>( systemSpec: SS, map: M, Root?: R ) { const requiredPropNames = Object.keys(map.required || {}) const optionalPropNames = Object.keys(map.optional || {}) const methodNames = Object.keys(map.methods || {}) const eventNames = Object.keys(map.events || {}) const Context = createContext>(({} as unknown) as any) type RootCompProps = R extends ComponentType ? RP : { children?: ReactNode } type CompProps = PropsFromPropMap & RootCompProps type CompMethods = MethodsFromPropMap function applyPropsToSystem(system: ReturnType, props: any) { if (system['propsReady']) { publish(system['propsReady'], false) } for (const requiredPropName of requiredPropNames) { const stream = system[map.required![requiredPropName]] publish(stream, (props as any)[requiredPropName]) } for (const optionalPropName of optionalPropNames) { if (optionalPropName in props) { const stream = system[map.optional![optionalPropName]] publish(stream, (props as any)[optionalPropName]) } } if (system['propsReady']) { publish(system['propsReady'], true) } } function buildMethods(system: ReturnType) { return methodNames.reduce((acc, methodName) => { ;(acc as any)[methodName] = (value: any) => { const stream = system[map.methods![methodName]] publish(stream, value) } return acc }, {} as CompMethods) } function buildEventHandlers(system: ReturnType) { return eventNames.reduce((handlers, eventName) => { handlers[eventName] = eventHandler(system[map.events![eventName]]) return handlers }, {} as { [key: string]: Emitter }) } /** * A React component generated from an urx system */ const Component = forwardRef((propsWithChildren, ref) => { const { children, ...props } = propsWithChildren as any const [system] = useState(() => { return tap(init(systemSpec), system => applyPropsToSystem(system, props)) }) const [handlers] = useState(curry1to0(buildEventHandlers, system)) useIsomorphicLayoutEffect(() => { for (const eventName of eventNames) { if (eventName in props) { subscribe(handlers[eventName], props[eventName]) } } return () => { Object.values(handlers).map(reset) } }, [props, handlers, system]) useIsomorphicLayoutEffect(() => { applyPropsToSystem(system, props) }) useImperativeHandle(ref, always(buildMethods(system))) return createElement( Context.Provider, { value: system }, Root ? createElement( (Root as unknown) as ComponentType, omit([...requiredPropNames, ...optionalPropNames, ...eventNames], props), children ) : children ) }) const usePublisher = (key: K) => { return useCallback(curry2to1(publish, React.useContext(Context)[key]), [key]) as ( value: S[K] extends Stream ? R : never ) => void } /** * Returns the value emitted from the stream. */ const useEmitterValue = ? R : never>(key: K) => { const context = useContext(Context) const source: StatefulStream = context[key] const [value, setValue] = useState(curry1to0(getValue, source)) useIsomorphicLayoutEffect( () => subscribe(source, (next: V) => { if (next !== value) { setValue(always(next)) } }), [source, value] ) return value } const useEmitter = ? R : never>(key: K, callback: (value: V) => void) => { const context = useContext(Context) const source: Stream = context[key] useIsomorphicLayoutEffect(() => subscribe(source, callback), [callback, source]) } return { Component, usePublisher, useEmitterValue, useEmitter, } }