diff --git a/packages/core/src/connect.tsx b/packages/core/src/connect.tsx index 91f8562a..a5825092 100644 --- a/packages/core/src/connect.tsx +++ b/packages/core/src/connect.tsx @@ -1,8 +1,11 @@ import * as React from "react"; import logger from "./logger"; -import { Comp, Connect, Dispatch, Node, Store, Watch } from "./types"; +import { Comp, Connect, Node, Store } from "./types"; import { joinPath, splitPath } from "./utils"; -import { subscribe, unsubscribe } from "./watch"; +import { + subscribe as subscribeInner, + unsubscribe as unsubscribeInner, +} from "./watch"; export const ProdoContext = React.createContext>(null as any); @@ -20,54 +23,17 @@ export const createUniverseWatcher = (universePath: string) => { return readProxy([universePath]); }; -const getValue = (path: string[], obj: any): any => - path.reduce((x: any, y: any) => x && x[y], obj); +const state = createUniverseWatcher("state"); -const valueExtractor = ( - store: Store, - watched: { [key: string]: any }, -) => (x: any) => { - const path = x[pathSymbol]; - const pathKey = joinPath(path); - const value = getValue(path, store.universe); - watched[pathKey] = value; - return value; +const useForceUpdate = () => { + const [, setCounter] = React.useState(0); + return React.useCallback(() => { + setCounter(tick => tick + 1); + }, []); }; -export const shallowEqual = (objA: any, objB: any): boolean => { - if (Object.is(objA, objB)) { - return true; - } - - if ( - typeof objA !== "object" || - objA === null || - typeof objB !== "object" || - objB === null - ) { - return false; - } - - const keysA = Object.keys(objA); - const keysB = Object.keys(objB); - - if (keysA.length !== keysB.length) { - return false; - } - - // Test for A's keys different from B. - // tslint:disable-next-line:prefer-for-of - for (let i = 0; i < keysA.length; i++) { - if ( - !Object.prototype.hasOwnProperty.call(objB, keysA[i]) || - !Object.is(objA[keysA[i]], objB[keysA[i]]) - ) { - return false; - } - } - - return true; -}; +const getValue = (path: string[], obj: any): any => + path.reduce((x: any, y: any) => x && x[y], obj); let _compIdCnt = 1; @@ -75,203 +41,190 @@ export type Func = (viewCtx: V) => React.ComponentType

; export const connect: Connect =

( func: Func, - name: string = "(anonymous)", -): React.ComponentType

=> - class ConnectComponent

extends React.Component { - public static contextType = ProdoContext; - - public state: any; - private watched: { [key: string]: any }; - private prevWatched: { [key: string]: any }; - private pathNodes: { [key: string]: Node }; - private compId: number; - private comp: Comp; - private name: string; - private eventIdCnt: number; - private subscribe: ( - path: string[], - unsubscribe?: (comp: Comp) => void, - ) => void; - private unsubscribe: (path: string[]) => void; - private firstTime: boolean; - private status: { unmounted: boolean }; - private store: Store; - private _renderFunc: any; - private _watch: Watch; - private _dispatch: Dispatch; - private _state: any; - - private _viewCtx: any; - - constructor(props: P) { - super(props); - - this.state = {}; - this.watched = {}; - this.prevWatched = {}; - this.pathNodes = {}; - this.firstTime = true; - this.compId = _compIdCnt++; - this.name = name + "." + this.compId; - this.store = this.context; - - const setState = this.setState.bind(this); - this.status = { unmounted: false }; - - this.comp = { - name: this.name, - compId: this.compId, - }; - - this.eventIdCnt = 0; - - this.subscribe = (path: string[], unsubscribe?: (comp: Comp) => void) => { - const pathKey = joinPath(path); - - const node: Node = this.pathNodes[pathKey] || { - pathKey, - status: this.status, - setState, - unsubscribe, - ...this.comp, - }; - - this.pathNodes[pathKey] = node; - subscribe(this.store, path, node); - }; - - this.unsubscribe = (path: string[]) => { - const pathKey = joinPath(path); - const node = this.pathNodes[pathKey]; - if (node != null) { - unsubscribe(this.store, path, node); - delete this.pathNodes[pathKey]; - } - }; - - this._watch = x => x; - - this._dispatch = func => (...args) => - this.store.exec( - { - id: `${this.comp.name}/event.${this.eventIdCnt++}`, - parentId: this.comp.name, - }, - func, - ...args, - ); - - this._renderFunc = (props: any): any => { - return (func as ((args: any) => (props: any) => any))(this._viewCtx)( - props, - ); - }; - - logger.info(`[constructing] ${this.name}`); - } - - public componentDidMount() { - logger.info(`[did mount] ${this.name}`); - - Object.keys(this.watched).forEach(pathKey => { - logger.info(`[start watching] ${this.name}: < ${pathKey} >`); - this.subscribe(splitPath(pathKey)); + baseName: string = "(anonymous)", +): React.ComponentType

=> (props: P) => { + const compId = React.useRef(_compIdCnt++); + const name = baseName + "." + compId.current; + + logger.info(`[rendering] ${name}`); + + const store = React.useContext(ProdoContext); + const forceUpdate = useForceUpdate(); + + // Subscribing to part of the state + const status = React.useRef({ unmounted: false }); + React.useEffect(() => { + logger.info(`[did mount] ${name}`); + logger.debug("store", store); + }, []); + + const pathNodes = React.useRef({}); + const subscribe = (path: string[], unsubscribe?: (comp: Comp) => void) => { + const pathKey = joinPath(path); + logger.info(`[subscribe] ${name} subscribing to ${pathKey}`); + + const node: Node = + pathNodes.current[pathKey] || + (pathNodes.current[pathKey] = { + pathKey, + status: status.current, + forceUpdate, + unsubscribe, + name, + compId: compId.current, }); - logger.debug("store", this.store); - - this.prevWatched = { ...this.watched }; - this.firstTime = true; - this.setState(this.watched); - this.watched = {}; - } - - public shouldComponentUpdate(nextProps: P, nextState: any) { - const test = - !shallowEqual(this.props, nextProps) || - (!this.firstTime && !shallowEqual(this.state, nextState)); - - logger.info(`[should update] ${this.name}`, test); - this.firstTime = false; - - return test; + subscribeInner(store, path, node); + }; + const unsubscribe = (path: string[]) => { + const pathKey = joinPath(path); + const node = pathNodes.current[pathKey]; + if (node != null) { + unsubscribeInner(store, path, node); + delete pathNodes.current[pathKey]; } + }; - public componentDidUpdate() { - logger.info(`[did update] ${this.name}`); - - Object.keys(this.watched).forEach(pathKey => { - const keyExisted = this.prevWatched.hasOwnProperty(pathKey); - if (!keyExisted) { - logger.info(`[update] ${this.name}: now watching < ${pathKey} >`); - this.subscribe(splitPath(pathKey)); - } - }); - - Object.keys(this.prevWatched).forEach(pathKey => { - const keyDeleted = !this.watched.hasOwnProperty(pathKey); - if (keyDeleted) { - logger.info(`[update] ${this.name}: stop watching < ${pathKey} >`); - this.unsubscribe(splitPath(pathKey)); - } - }); - - this.prevWatched = { ...this.watched }; - this.watched = {}; - } + const eventIdCnt = React.useRef(0); + const dispatch = func => (...args) => + store.exec( + { + id: `${name}/event.${eventIdCnt.current++}`, + parentId: name, + }, + func, + ...args, + ); - public componentWillUnmount() { - logger.info(`[will unmount]: ${this.name}`, this.state); - Object.keys(this.state).forEach(pathKey => { - logger.info(`[unmount] ${this.name}: stop watching < ${pathKey} >`); - this.unsubscribe(splitPath(pathKey)); + const watched = React.useRef({}); + const subscriptions = React.useRef({}); + // As we are rendering, create subscriptions + const watch = React.useCallback( + (proxy: any) => { + const path = proxy[pathSymbol]; + const pathKey = joinPath(path); + const value = getValue(path, store.universe); + watched.current[pathKey] = true; + if (!subscriptions.current[pathKey]) { + subscriptions.current[pathKey] = true; + subscribe(path); + } + return value; + }, + [store], + ); + + // After updating, prune subscriptions + React.useEffect(() => { + Object.keys(watched.current).forEach(pathKey => { + logger.info(`[update] ${name}: watching < ${pathKey} >`); + }); + Object.keys(subscriptions.current).forEach(pathKey => { + const keyDeleted = !watched.current.hasOwnProperty(pathKey); + if (keyDeleted) { + logger.info(`[update] ${name}: stop watching < ${pathKey} >`); + unsubscribe(splitPath(pathKey)); + } + }); + + subscriptions.current = { ...watched.current }; + watched.current = {}; + }); + + // On unmount, unsubscribe from everything + React.useEffect(() => { + return () => { + logger.info(`[unmounting]: ${name}`, watched.current); + Object.keys(subscriptions.current).forEach(pathKey => { + logger.info(`[unmount] ${name}: stop watching < ${pathKey} >`); + unsubscribe(splitPath(pathKey)); }); - - logger.debug("store", this.store); - - this.status.unmounted = true; + status.current = { unmounted: true }; + }; + }, []); + + const _subscribe = (path: string[], unsubscribe?: () => void): void => { + const pathKey = joinPath(path); + watched.current[pathKey] = getValue(path, store.universe); + if (!subscriptions.current[pathKey]) { + subscriptions.current[pathKey] = true; + subscribe(path, unsubscribe); } + }; - public render() { - this.createViewCtx(); - - const Comp = this._renderFunc; - return ; - } - - private createViewCtx() { - this.store = this.context; - this._state = createUniverseWatcher("state"); - this._watch = valueExtractor(this.store, this.watched); - - const subscribe = (path: string[], unsubscribe?: () => void): void => { - const pathKey = joinPath(path); - this.watched[pathKey] = getValue(path, this.store.universe); - this.subscribe(path, unsubscribe); - }; - - const ctx = { - dispatch: this._dispatch, - state: this._state, - watch: this._watch, - subscribe, - }; - - this.store.plugins.forEach(p => { - if (p._internals.viewCtx) { - (ctx as any).dispatch = this._dispatch; - - p._internals.viewCtx( - { - ctx, - universe: this.store.universe, - comp: this.comp, + const ctx = React.useMemo(() => { + const ctx = { + dispatch, + state, + watch, + subscribe: _subscribe, + }; + store.plugins.forEach(p => { + if (p._internals.viewCtx) { + ctx.dispatch = dispatch; + p._internals.viewCtx( + { + ctx, + universe: store.universe, + comp: { + name, + compId: compId.current, }, - this.store.config, - ); - } - }); + }, + store.config, + ); + } + }); + return ctx; + }, [store]); - this._viewCtx = ctx; - } - }; + return (func as ((args: any) => (props: any) => any))(ctx)(props); +}; + +// export const shallowEqual = (objA: any, objB: any): boolean => { +// if (Object.is(objA, objB)) { +// return true; +// } + +// if ( +// typeof objA !== "object" || +// objA === null || +// typeof objB !== "object" || +// objB === null +// ) { +// return false; +// } + +// const keysA = Object.keys(objA); +// const keysB = Object.keys(objB); + +// if (keysA.length !== keysB.length) { +// return false; +// } + +// // Test for A's keys different from B. +// // tslint:disable-next-line:prefer-for-of +// for (let i = 0; i < keysA.length; i++) { +// if ( +// !Object.prototype.hasOwnProperty.call(objB, keysA[i]) || +// !Object.is(objA[keysA[i]], objB[keysA[i]]) +// ) { +// return false; +// } +// } + +// return true; +// }; +// We're not applying any kind of `shouldComponentUpdate` any more. +// Previous code was: +// +// public shouldComponentUpdate(nextProps: P, nextState: any) { +// const test = +// !shallowEqual(this.props, nextProps) || +// (!this.firstTime && !shallowEqual(this.state, nextState)); + +// logger.info(`[should update] ${this.name}`, test); +// this.firstTime = false; + +// return test; +// } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 56063beb..e8589d55 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -72,7 +72,7 @@ export interface Comp { export interface Node extends Comp { pathKey: string; status: { unmounted: boolean }; - setState: (state: any) => void; + forceUpdate: () => void; unsubscribe?: (comp: Comp) => void; } diff --git a/packages/core/src/watch.ts b/packages/core/src/watch.ts index d0a8709b..1d4a67a9 100644 --- a/packages/core/src/watch.ts +++ b/packages/core/src/watch.ts @@ -2,10 +2,14 @@ import logger from "./logger"; import { Event, Node, Store, WatchTree } from "./types"; import { splitPath } from "./utils"; +// const getValue = (path: string[], obj: any): any => +// path.reduce((x: any, y: any) => x && x[y], obj); + export const subscribe = ( store: Store, path: string[], node: Node, + // seenValue: any, ) => { // root tree let tree: WatchTree = store.watchTree; @@ -33,6 +37,11 @@ export const subscribe = ( // add node to esubs of exact part of state tree that was subscribed tree.esubs.add(node); + + // if (seenValue !== getValue(path, store.universe)) { + // console.log(`Not seen ${path} before, so rerendering`); + // node.forceUpdate(); + // } }; export const unsubscribe = ( @@ -108,7 +117,7 @@ export const submitPatches = ( if (!comps[x.compId]) { compIds.push(x.compId); comps[x.compId] = { - setState: x.setState, + forceUpdate: x.forceUpdate, status: x.status, name: x.name, newValues: {}, @@ -125,11 +134,13 @@ export const submitPatches = ( event.rerender = {}; Object.keys(comps).forEach(compId => { - const { setState, name, newValues, status } = comps[compId]; + const { forceUpdate, name, newValues, status } = comps[compId]; if (!status.unmounted) { event.rerender![comps[compId].name] = true; logger.info(`[upcoming state update] ${name}`, newValues, status); - setState(newValues); + forceUpdate(); + } else { + logger.info(`[can't update state] ${name}`, status); } }); }; diff --git a/packages/core/tests/watch.test.ts b/packages/core/tests/watch.test.ts index 31223da9..4b1f6bfa 100644 --- a/packages/core/tests/watch.test.ts +++ b/packages/core/tests/watch.test.ts @@ -38,7 +38,7 @@ const createNode = (name: string, compId: number, pathKey: string): Node => ({ name, compId, pathKey, - setState: () => {}, // tslint:disable-line:no-empty + forceUpdate: () => void {}, status: { unmounted: false }, });