/**
* `@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,
}
}