/* * MIT License * * Copyright (c) 2019 Rémi Van Keisbelck * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. * */ import * as React from 'react'; import { ReactNode, useEffect, useRef, useState } from 'react'; import { Cmd, Dispatcher, just, Maybe, nothing, Sub } from 'tea-cup-fp'; import { flushSync } from 'react-dom'; export class DispatchBridge { private d?: Dispatcher; subscribe(d: Dispatcher): void { this.d = d; } dispatch(msg: Msg) { this.d?.(msg); } } export type SetModelFunc = (model: Model) => void; export class SetModelBridge { private sm: SetModelFunc | undefined; subscribe(sm: SetModelFunc): void { this.sm = sm; } setModel(model: Model) { this.sm?.(model); } } export type ProgramListener = (e: ProgramEvent) => void; export type ProgramEvent = | { tag: 'init'; count: number; mac: [Model, Cmd] } | { tag: 'update'; count: number; msg: Msg; mac: [Model, Cmd] }; export interface ProgramInterop { dispatchBridge?: DispatchBridge; setModelBridge?: SetModelBridge; listener?: ProgramListener; paused?: () => boolean; } /** * The program props : asks for init, view, update and subs in order * to start the TEA MVU loop. */ export interface ProgramProps extends ProgramInterop { init: () => [Model, Cmd]; view: (dispatch: Dispatcher, model: Model) => ReactNode; update: (msg: Msg, model: Model) => [Model, Cmd]; subscriptions: (model: Model) => Sub; flushSyncDefault?: boolean; } /** * A React component that holds a TEA "program", implementing the MVU loop. */ export function Program(props: ProgramProps) { const [model, setModel] = useState>(nothing); const cmd = useRef>(Cmd.none()); // this could go in state only but as we need it effects it's easier with a ref const modelRef = useRef(model); const sub = useRef>(Sub.none()); const count = useRef(0); const dispatch: Dispatcher = (msg: Msg, flush?: boolean) => { if (props.paused?.() === true) { // do not process messages if we are paused return; } const c = count.current + 1; count.current = c; if (modelRef.current.type === 'Just') { const m = modelRef.current.value; const [uModel, uCmd] = props.update(msg, m); modelRef.current = just(uModel); cmd.current = uCmd; const newSub = props.subscriptions(uModel); newSub.init(dispatch); sub.current.release(); sub.current = newSub; props.listener?.({ tag: 'update', count: count.current, msg, mac: [uModel, uCmd] }); const doFlush = needsFlush(props.flushSyncDefault, flush); if (doFlush === false) { setModel(just(uModel)); } else { flushSync(() => { setModel(just(uModel)); }); } setTimeout(() => { uCmd.execute(dispatch); }); } }; // init : run once (good old componentDidMount) useEffect(() => { if (modelRef.current.isNothing()) { // sub to bridges if any props.setModelBridge?.subscribe((model) => { setModel(just(model)); modelRef.current = just(model); }); props.dispatchBridge?.subscribe(dispatch); // init const mac = props.init(); // and start the MVU const [uModel, uCmd] = mac; setModel(just(uModel)); modelRef.current = just(uModel); cmd.current = uCmd; const newSub = props.subscriptions(uModel); sub.current = newSub; newSub.init(dispatch); props.listener?.({ tag: 'init', count: count.current, mac }); setTimeout(() => { uCmd.execute(dispatch); }); } }); return model.map((m) => props.view(dispatch, m)).withDefault(<>); } export function needsFlush(defaultValue: boolean | undefined, flush: boolean | undefined): boolean { if (flush === false) { return false; } else if (flush === undefined) { if (defaultValue !== undefined) { return defaultValue; } } return true; }