Add initial tools for managing derivations and React compatibility (#202)

Co-authored-by: Cole Lawrence <cole@colelawrence.com>
Co-authored-by: Elliot <key.draw@gmail.com>
Co-authored-by: Aria <aria.minaei@gmail.com>
This commit is contained in:
Cole Lawrence 2022-06-09 13:12:40 -04:00 committed by GitHub
parent bebf281517
commit f1844952ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 320 additions and 30 deletions

View file

@ -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<T> = IDerivation<
{
[P in keyof T]: T[P] extends $<infer R> ? R : never
}
>
export type $<T> = IDerivation<T> | Pointer<T>
function deriveAllD<T extends [...$<any>[]]>(obj: T): DeriveAll<T>
function deriveAllD<T extends Record<string, $<any>>>(obj: T): DeriveAll<T>
function deriveAllD<T extends Record<string, $<any>> | $<any>[]>(
obj: T,
): DeriveAll<T> {
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<string, $<any>>)[k])
}
return values
}
}) as $IntentionalAny
}
export function useReactPrism(
fn: () => React.ReactNode,
deps: readonly any[] = emptyArray,
): React.ReactElement {
const derivation = useMemo(() => prism(fn), deps)
return <DeriveElement der={derivation} />
}
export function reactPrism(fn: () => React.ReactNode): React.ReactElement {
return <DeriveElement der={prism(fn)} />
}
function DeriveElement(props: {der: IDerivation<React.ReactNode>}) {
const node = useDerivation(props.der)
return <>{node}</>
}
/** This is only used for type checking to make sure the APIs are used properly */
interface TSErrors<M> extends Error {}
type ReactDeriver<Props extends {}> = (
props: {
[P in keyof Props]: Props[P] extends IDerivation<infer _>
? TSErrors<"Can't both use Derivation properties while wrapping with deriver">
: Props[P] | IDerivation<Props[P]>
},
) => 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<Props extends {}>(
Component: React.ComponentType<Props>,
): ReactDeriver<Props> {
return React.forwardRef(function deriverRender(
props: Record<string, $IntentionalAny>,
ref,
) {
let observableArr = []
const observables: Record<string, IDerivation<$IntentionalAny>> = {}
const normalProps: Record<string, $IntentionalAny> = {
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)
)
})
}

View file

@ -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)
}
}

View file

@ -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 })
}
}

View file

@ -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 } }"
`)
})
})

View file

@ -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')
}