From f1844952ea72b885f17cbb1d24bee16fe3ae47c9 Mon Sep 17 00:00:00 2001 From: Cole Lawrence Date: Thu, 9 Jun 2022 13:12:40 -0400 Subject: [PATCH] Add initial tools for managing derivations and React compatibility (#202) Co-authored-by: Cole Lawrence Co-authored-by: Elliot Co-authored-by: Aria --- packages/dataverse/src/Atom.ts | 8 +- packages/dataverse/src/Ticker.ts | 11 +- .../src/derivations/AbstractDerivation.ts | 12 +- .../dataverse/src/derivations/prism/prism.ts | 8 +- packages/dataverse/src/utils/Emitter.ts | 14 +-- packages/dataverse/src/utils/Tappable.ts | 4 +- packages/react/src/index.ts | 2 +- theatre/studio/src/utils/derive-utils.tsx | 116 ++++++++++++++++++ theatre/studio/src/utils/devStringify.ts | 20 +++ theatre/studio/src/utils/invariant.ts | 98 +++++++++++++++ .../src/utils/tightJsonStringify.test.ts | 27 ++++ .../studio/src/utils/tightJsonStringify.ts | 30 +++++ 12 files changed, 320 insertions(+), 30 deletions(-) create mode 100644 theatre/studio/src/utils/derive-utils.tsx create mode 100644 theatre/studio/src/utils/devStringify.ts create mode 100644 theatre/studio/src/utils/invariant.ts create mode 100644 theatre/studio/src/utils/tightJsonStringify.test.ts create mode 100644 theatre/studio/src/utils/tightJsonStringify.ts diff --git a/packages/dataverse/src/Atom.ts b/packages/dataverse/src/Atom.ts index 394d016..db5e4b9 100644 --- a/packages/dataverse/src/Atom.ts +++ b/packages/dataverse/src/Atom.ts @@ -201,7 +201,9 @@ export default class Atom private _checkUpdates(scope: Scope, oldState: unknown, newState: unknown) { if (oldState === newState) return - scope.identityChangeListeners.forEach((cb) => cb(newState)) + for (const cb of scope.identityChangeListeners) { + cb(newState) + } if (scope.children.size === 0) return @@ -212,11 +214,11 @@ export default class Atom if (oldValueType === ValueTypes.Other && oldValueType === newValueType) return - scope.children.forEach((childScope, childKey) => { + for (const [childKey, childScope] of scope.children) { const oldChildVal = getKeyOfValue(oldState, childKey, oldValueType) const newChildVal = getKeyOfValue(newState, childKey, newValueType) this._checkUpdates(childScope, oldChildVal, newChildVal) - }) + } } private _getOrCreateScopeForPath(path: (string | number)[]): Scope { diff --git a/packages/dataverse/src/Ticker.ts b/packages/dataverse/src/Ticker.ts index cb2f6ce..e857d0b 100644 --- a/packages/dataverse/src/Ticker.ts +++ b/packages/dataverse/src/Ticker.ts @@ -121,9 +121,10 @@ export default class Ticker { tick(t: number = performance.now()) { this._ticking = true this._timeAtCurrentTick = t - this._scheduledForNextTick.forEach((v) => - this._scheduledForThisOrNextTick.add(v), - ) + for (const v of this._scheduledForNextTick) { + this._scheduledForThisOrNextTick.add(v) + } + this._scheduledForNextTick.clear() this._tick(0) this._ticking = false @@ -142,9 +143,9 @@ export default class Ticker { const oldSet = this._scheduledForThisOrNextTick this._scheduledForThisOrNextTick = new Set() - oldSet.forEach((fn) => { + for (const fn of oldSet) { fn(time) - }) + } if (this._scheduledForThisOrNextTick.size > 0) { return this._tick(iterationNumber + 1) diff --git a/packages/dataverse/src/derivations/AbstractDerivation.ts b/packages/dataverse/src/derivations/AbstractDerivation.ts index 0a1c8b4..7fe71b0 100644 --- a/packages/dataverse/src/derivations/AbstractDerivation.ts +++ b/packages/dataverse/src/derivations/AbstractDerivation.ts @@ -170,9 +170,9 @@ export default abstract class AbstractDerivation implements IDerivation { this._didMarkDependentsAsStale = true this._isFresh = false - this._dependents.forEach((dependent) => { + for (const dependent of this._dependents) { dependent(this) - }) + } } /** @@ -228,14 +228,14 @@ export default abstract class AbstractDerivation implements IDerivation { this._didMarkDependentsAsStale = false this._isFresh = false if (shouldBecomeHot) { - this._dependencies.forEach((d) => { + for (const d of this._dependencies) { d.addDependent(this._internal_markAsStale) - }) + } this._keepHot() } else { - this._dependencies.forEach((d) => { + for (const d of this._dependencies) { d.removeDependent(this._internal_markAsStale) - }) + } this._becomeCold() } } diff --git a/packages/dataverse/src/derivations/prism/prism.ts b/packages/dataverse/src/derivations/prism/prism.ts index 67a1e15..a2ff718 100644 --- a/packages/dataverse/src/derivations/prism/prism.ts +++ b/packages/dataverse/src/derivations/prism/prism.ts @@ -70,18 +70,18 @@ export class PrismDerivation extends AbstractDerivation { popCollector(collector) - this._dependencies.forEach((dep) => { + for (const dep of this._dependencies) { if (!newDeps.has(dep)) { this._removeDependency(dep) } - }) + } this._dependencies = newDeps startIgnoringDependencies() - newDeps.forEach((dep) => { + for (const dep of newDeps) { this._cacheOfDendencyValues.set(dep, dep.getValue()) - }) + } stopIgnoringDependencies() return value! diff --git a/packages/dataverse/src/utils/Emitter.ts b/packages/dataverse/src/utils/Emitter.ts index 01da16c..5cc3124 100644 --- a/packages/dataverse/src/utils/Emitter.ts +++ b/packages/dataverse/src/utils/Emitter.ts @@ -20,17 +20,14 @@ export default class Emitter { this._lastTapperId = 0 this._tappers = new Map() this.tappable = new Tappable({ - tapToSource: (cb: Tapper) => { - return this._tap(cb) - }, + tapToSource: (cb: Tapper) => this._tap(cb), }) } _tap(cb: Tapper): Untap { const tapperId = this._lastTapperId++ this._tappers.set(tapperId, cb) - this._onNumberOfTappersChangeListener && - this._onNumberOfTappersChangeListener(this._tappers.size) + this._onNumberOfTappersChangeListener?.(this._tappers.size) return () => { this._removeTapperById(tapperId) } @@ -41,8 +38,7 @@ export default class Emitter { this._tappers.delete(id) const newSize = this._tappers.size if (oldSize !== newSize) { - this._onNumberOfTappersChangeListener && - this._onNumberOfTappersChangeListener(this._tappers.size) + this._onNumberOfTappersChangeListener?.(newSize) } } @@ -52,9 +48,9 @@ export default class Emitter { * @param payload - The value to be emitted. */ emit(payload: V) { - this._tappers.forEach((cb) => { + for (const cb of this._tappers.values()) { cb(payload) - }) + } } /** diff --git a/packages/dataverse/src/utils/Tappable.ts b/packages/dataverse/src/utils/Tappable.ts index 47b067a..05db260 100644 --- a/packages/dataverse/src/utils/Tappable.ts +++ b/packages/dataverse/src/utils/Tappable.ts @@ -53,9 +53,9 @@ export default class Tappable { } private _cb: any = (arg: any): void => { - this._tappers.forEach((cb) => { + for (const cb of this._tappers.values()) { cb(arg) - }) + } } /** diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 920d714..3a7e8f8 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -179,7 +179,7 @@ function queueIfNeeded() { * * I'm happy with how little bookkeeping we ended up doing here. */ -function useDerivation(der: IDerivation, debugLabel?: string): T { +export function useDerivation(der: IDerivation, debugLabel?: string): T { const _forceUpdate = useForceUpdate(debugLabel) const refs = useRef<{queueItem: QueueItem; unmounted: boolean}>( diff --git a/theatre/studio/src/utils/derive-utils.tsx b/theatre/studio/src/utils/derive-utils.tsx new file mode 100644 index 0000000..cce8c49 --- /dev/null +++ b/theatre/studio/src/utils/derive-utils.tsx @@ -0,0 +1,116 @@ +import {isDerivation, prism, val} from '@theatre/dataverse' +import type {IDerivation, Pointer} from '@theatre/dataverse' +import {useDerivation} from '@theatre/react' +import type {$IntentionalAny} from '@theatre/shared/utils/types' +import React, {useMemo, useRef} from 'react' +import {invariant} from './invariant' +import {emptyArray} from '@theatre/shared/utils' + +type DeriveAll = IDerivation< + { + [P in keyof T]: T[P] extends $ ? R : never + } +> + +export type $ = IDerivation | Pointer + +function deriveAllD[]]>(obj: T): DeriveAll +function deriveAllD>>(obj: T): DeriveAll +function deriveAllD> | $[]>( + obj: T, +): DeriveAll { + return prism(() => { + if (Array.isArray(obj)) { + const values = new Array(obj.length) + for (let i = 0; i < obj.length; i++) { + values[i] = obj[i].getValue() + } + return values + } else { + const values: $IntentionalAny = {} + for (const k in obj) { + values[k] = val((obj as Record>)[k]) + } + return values + } + }) as $IntentionalAny +} + +export function useReactPrism( + fn: () => React.ReactNode, + deps: readonly any[] = emptyArray, +): React.ReactElement { + const derivation = useMemo(() => prism(fn), deps) + return +} + +export function reactPrism(fn: () => React.ReactNode): React.ReactElement { + return +} + +function DeriveElement(props: {der: IDerivation}) { + const node = useDerivation(props.der) + return <>{node} +} + +/** This is only used for type checking to make sure the APIs are used properly */ +interface TSErrors extends Error {} + +type ReactDeriver = ( + props: { + [P in keyof Props]: Props[P] extends IDerivation + ? TSErrors<"Can't both use Derivation properties while wrapping with deriver"> + : Props[P] | IDerivation + }, +) => React.ReactElement | null + +/** + * Wrap up the component to enable it to take derivable properties. + * Invoked similarly to `React.memo`. + * + * @remarks + * This is an experimental interface for wrapping components in a version + * which allows you to pass in derivations for any of the properties that + * previously took only values. + */ +export function deriver( + Component: React.ComponentType, +): ReactDeriver { + return React.forwardRef(function deriverRender( + props: Record, + ref, + ) { + let observableArr = [] + const observables: Record> = {} + const normalProps: Record = { + ref, + } + for (const key in props) { + const value = props[key] + if (isDerivation(value)) { + observableArr.push(value) + observables[key] = value + } else { + normalProps[key] = value + } + } + + const initialCount = useRef(observableArr.length) + invariant( + initialCount.current === observableArr.length, + `expect same number of observable props on every invocation of deriver wrapped component.`, + {initial: initialCount.current, count: observableArr.length}, + ) + + const allD = useMemo(() => deriveAllD(observables), observableArr) + const observedPropState = useDerivation(allD) + + return ( + observedPropState && + React.createElement(Component, { + ...normalProps, + ...observedPropState, + } as Props) + ) + }) +} diff --git a/theatre/studio/src/utils/devStringify.ts b/theatre/studio/src/utils/devStringify.ts new file mode 100644 index 0000000..811e5b8 --- /dev/null +++ b/theatre/studio/src/utils/devStringify.ts @@ -0,0 +1,20 @@ +import {tightJsonStringify} from './tightJsonStringify' + +/** + * Stringifies any value given. If an object is given and `indentJSON` is true, + * then a developer-readable, command line friendly (not too spaced out, but with + * enough whitespace to be readable). + */ +export function devStringify(input: any, indentJSON: boolean = true): string { + try { + return typeof input === 'string' + ? input + : typeof input === 'function' || input instanceof Error + ? input.toString() + : indentJSON + ? tightJsonStringify(input) + : JSON.stringify(input) + } catch (err) { + return input?.name || String(input) + } +} diff --git a/theatre/studio/src/utils/invariant.ts b/theatre/studio/src/utils/invariant.ts new file mode 100644 index 0000000..1168d11 --- /dev/null +++ b/theatre/studio/src/utils/invariant.ts @@ -0,0 +1,98 @@ +import {devStringify} from './devStringify' + +type AllowedMessageTypes = string | number | object + +/** + * invariants are like `expect` from jest or another testing library but + * for use in implementations and not just tests. If the `condition` passed + * to `invariant` is falsy then `message`, and optionally `found`, are thrown as a + * {@link InvariantError} which has a developer-readable and command line friendly + * stack trace and error message. + */ +export function invariant( + shouldBeTruthy: any, + message: (() => AllowedMessageTypes) | AllowedMessageTypes, + butFoundInstead?: any, +): asserts shouldBeTruthy { + if (!shouldBeTruthy) { + const isFoundArgGiven = arguments.length > 2 + if (isFoundArgGiven) { + invariantThrow(message, butFoundInstead) + } else { + invariantThrow(message) + } + } +} + +/** + * Throws an error message with a developer-readable and command line friendly + * string of the argument `butFoundInstead`. + * + * Also see {@link invariant}, which accepts a condition. + */ +export function invariantThrow( + message: (() => AllowedMessageTypes) | AllowedMessageTypes, + butFoundInstead?: any, +): never { + const isFoundArgGiven = arguments.length > 1 + const prefix = devStringify( + typeof message === 'function' ? message() : message, + ) + const suffix = isFoundArgGiven + ? `\nInstead found: ${devStringify(butFoundInstead)}` + : '' + throw new InvariantError(`Invariant: ${prefix}${suffix}`, butFoundInstead) +} + +/** + * Enable exhaustive checking + * + * @example + * ```ts + * function a(x: 'a' | 'b') { + * if (x === 'a') { + * + * } else if (x === 'b') { + * + * } else { + * invariantUnreachable(x) + * } + * } + * ``` + */ +export function invariantUnreachable(x: never): never { + invariantThrow( + 'invariantUnreachable encountered value which was supposed to be never', + x, + ) +} + +// regexes to remove lines from thrown error stacktraces +const AT_NODE_INTERNAL_RE = /^\s*at.+node:internal.+/gm +const AT_INVARIANT_RE = /^\s*(at|[^@]+@) (?:Object\.)?invariant.+/gm +const AT_TEST_HELPERS_RE = /^\s*(at|[^@]+@).+test\-helpers.+/gm +// const AT_WEB_MODULES = /^\s*(at|[^@]+@).+(web_modules|\-[a-f0-9]{8}\.js).*/gm +const AT_ASSORTED_HELPERS_RE = + /^\s*(at|[^@]+@).+(debounce|invariant|iif)\.[tj]s.*/gm + +/** + * `InvariantError` removes lines from the `Error.stack` stack trace string + * which cleans up the stack trace, making it more developer friendly to read. + */ +class InvariantError extends Error { + found: any + constructor(message: string, found?: any) { + super(message) + if (found !== undefined) { + this.found = found + } + // const before = this.stack + // prettier-ignore + this.stack = this.stack + ?.replace(AT_INVARIANT_RE, "") + .replace(AT_ASSORTED_HELPERS_RE, "") + .replace(AT_TEST_HELPERS_RE, "") + .replace(AT_NODE_INTERNAL_RE, "") + // console.error({ before, after: this.stack }) + } +} diff --git a/theatre/studio/src/utils/tightJsonStringify.test.ts b/theatre/studio/src/utils/tightJsonStringify.test.ts new file mode 100644 index 0000000..5911a7e --- /dev/null +++ b/theatre/studio/src/utils/tightJsonStringify.test.ts @@ -0,0 +1,27 @@ +import {tightJsonStringify} from './tightJsonStringify' +describe('tightJsonStringify', () => { + it('matches a series of expectations', () => { + expect(tightJsonStringify({a: 1, b: 2, c: {y: 4, z: 745}})) + .toMatchInlineSnapshot(` + "{ \\"a\\": 1, + \\"b\\": 2, + \\"c\\": { + \\"y\\": 4, + \\"z\\": 745 } }" + `) + expect(tightJsonStringify(true)).toMatchInlineSnapshot(`"true"`) + expect(tightJsonStringify('Already a string')).toMatchInlineSnapshot( + `"\\"Already a string\\""`, + ) + expect(tightJsonStringify({a: 1, b: {c: [1, 2, {d: 4}], e: 8}})) + .toMatchInlineSnapshot(` + "{ \\"a\\": 1, + \\"b\\": { + \\"c\\": [ + 1, + 2, + { \\"d\\": 4 } ], + \\"e\\": 8 } }" + `) + }) +}) diff --git a/theatre/studio/src/utils/tightJsonStringify.ts b/theatre/studio/src/utils/tightJsonStringify.ts new file mode 100644 index 0000000..1d1c204 --- /dev/null +++ b/theatre/studio/src/utils/tightJsonStringify.ts @@ -0,0 +1,30 @@ +/** + * Stringifies an object in a developer-readable, command line friendly way + * (not too spaced out, but with enough whitespace to be readable). + * + * e.g. + * ```ts + * tightJsonStringify({a:1, b: {c: [1, 2, {d: 4}], e: 8}}) + * ``` + * becomes + * ```json + * { "a": 1, + * "b": { + * "c": [ + * 1, + * 2, + * { "d": 4 } ], + * "e": 8 } } + * ``` + * + * Also, see the examples in [`./tightJsonStringify.test.ts`](./tightJsonStringify.test.ts) + */ +export function tightJsonStringify( + obj: any, + replacer?: ((this: any, key: string, value: any) => any) | undefined, +) { + return JSON.stringify(obj, replacer, 2) + .replace(/^([\{\[])\n (\s+)/, '$1$2') + .replace(/(\n[ ]+[\{\[])\n\s+/g, '$1 ') + .replace(/\n\s*([\]\}])/g, ' $1') +}