Explain how SheetObject.getValues() works

This commit is contained in:
Aria Minaei 2022-11-23 16:29:21 +01:00
parent ea229695e1
commit dd585b0790
2 changed files with 126 additions and 4 deletions

View file

@ -74,60 +74,133 @@ export default class SheetObject implements IdentityDerivationProvider {
} }
getValues(): IDerivation<Pointer<SheetObjectPropsValue>> { getValues(): IDerivation<Pointer<SheetObjectPropsValue>> {
// Cache the derivation because only one is needed per SheetObject.
// Also, if `onValuesChange()` is unsubscribed from, this derivation will go cold
// and free its resources. So it's no problem to still keep it on the cache.
return this._cache.get('getValues()', () => return this._cache.get('getValues()', () =>
prism(() => { prism(() => {
/**
* The final value is a deep-merge of defaults + initial + static + sequenced values.
* We calculate each of those separately, and deep merge them one-by-one until
* we get the final value.
*
* Notes on performance: This prism _will_ recalculate every time any value of any prop changes,
* including nested props. In other words, if foo.bar.baz changes, this prism will recalculate. Even more,
* if boo.bar.baz is sequenced and the sequence is playing, this prism will recalculate on every frame.
* This might sound inefficient, but we have a few tricks to make it fast:
*
* First, on each recalculation, most of the derivations that this prism depends on will not have changed,
* and so reading them is cheap. For example, if foo.bar.baz changed due to being sequenced, but
* foo.bar2 hasn't because it is static, reading foo.bar2 will be cheap.
*
* Secondly, as we deep merge each layer, we use a cache to avoid recalculating the same merge over and over.
*
* Third, we have sorted our layers in the order of how likely they are to change. For example, sequenced
* values are likely to change on each frame, so they're layerd on last. Static values seldom change,
* and default values almost never do, so they're layered on first.
*
* All of this means that most the work of this prism is done on the very first calculation, and subsequent
* recalculations are cheap.
*
* Question: What about object.initialValue which _could_ change on every frame, but isn't layerd on last?
* Answer: initialValue is seldom used (it's only used in `@theatre/r3f` as far as we know). So this won't
* affect the majority of use cases. And in case it _is_ used, it's better for us to implement an alternative
* to `object.getValues()` that does not layer initialValue (and also skips defaultValue too). This is discussed
* in issue [P-208](https://linear.app/theatre/issue/P-208/use-overrides-rather-than-final-values-in-r3f).
*/
/**
* The lowest layer is the default value of the root prop. Since an object's config
* _could_ change, we read it as a derivation. Otherwise, we could have just `getDefaultsOfPropTypeConfig(this.template.staticConfig)`.
*
* Note: If studio is not present, there is no known use-case for the config of an object to change on the fly, so
* we could read this value statically.
*/
const defaults = val(this.template.getDefaultValues()) const defaults = val(this.template.getDefaultValues())
/**
* The second layer is the initialValue, which is what the user sets with `sheetObject.initialValue = someValue`.
*/
const initial = val(this._initialValue.pointer) const initial = val(this._initialValue.pointer)
/**
* For each deep-merge, we need a separate WeakMap to cache the result of the merge. See {@link deepMergeWithCache}
* to learn how that works.
*
* Here we use a `prism.memo()` so we can re-use our cache.
*/
const withInitialCache = prism.memo( const withInitialCache = prism.memo(
'withInitialCache', 'withInitialCache',
() => new WeakMap(), () => new WeakMap(),
[], [],
) )
// deep-merge the defaultValues with the initialValues.
const withInitial = deepMergeWithCache( const withInitial = deepMergeWithCache(
defaults, defaults,
initial, initial,
withInitialCache, withInitialCache,
) )
/**
* The third layer are the static values. Since these are (currently) commnon to all instances
* of the same SheetObject, we can read it from the template.
*/
const statics = val(this.template.getStaticValues()) const statics = val(this.template.getStaticValues())
// Similar to above, we need a separate but stable WeakMap to cache the result of merging the static values
const withStaticsCache = prism.memo( const withStaticsCache = prism.memo(
'withStatics', 'withStatics',
() => new WeakMap(), () => new WeakMap(),
[], [],
) )
// deep-merge the static values with the previous layer
const withStatics = deepMergeWithCache( const withStatics = deepMergeWithCache(
withInitial, withInitial,
statics, statics,
withStaticsCache, withStaticsCache,
) )
/**
* The final values (all layers merged together) will be put inside this variable
*/
let final = withStatics let final = withStatics
/**
* The sequenced values will be put in this variable
*/
let sequenced let sequenced
{ {
// NOTE: we're reading the sequenced values as a derivation to a pointer. This should be refactored
// to a simple pointer.
const pointerToSequencedValuesD = prism.memo( const pointerToSequencedValuesD = prism.memo(
'seq', 'seq',
() => this.getSequencedValues(), () => this.getSequencedValues(),
[], [],
) )
// like before, we need a separate but stable WeakMap to cache the result of merging the sequenced values
// on top of the last layer
const withSeqsCache = prism.memo( const withSeqsCache = prism.memo(
'withSeqsCache', 'withSeqsCache',
() => new WeakMap(), () => new WeakMap(),
[], [],
) )
// read the sequenced values
// (val(val(x))) unwraps the pointer and the derivation
sequenced = val(val(pointerToSequencedValuesD)) sequenced = val(val(pointerToSequencedValuesD))
// deep-merge the sequenced values with the previous layer
const withSeqs = deepMergeWithCache(final, sequenced, withSeqsCache) const withSeqs = deepMergeWithCache(final, sequenced, withSeqsCache)
final = withSeqs final = withSeqs
} }
// Finally, we wrap the final value in an atom, so we can return a pointer to it.
const a = valToAtom<SheetObjectPropsValue>('finalAtom', final) const a = valToAtom<SheetObjectPropsValue>('finalAtom', final)
// Since we only return a pointer, the value cannot be mutated from outside of this prism.
return a.pointer return a.pointer
}), }),
) )

View file

@ -1,13 +1,62 @@
import type {$IntentionalAny} from '@theatre/shared/utils/types' import type {$IntentionalAny} from '@theatre/shared/utils/types'
import type {DeepPartialOfSerializableValue, SerializableMap} from './types' import type {DeepPartialOfSerializableValue, SerializableMap} from './types'
/**
* This is like `Object.assign(base, override)`, with the following differences:
*
* 1. It returns a new value, instead of mutating `base`:
* ```js
* const cache = new WeakMap()
* const base = {foo: 1}
* const override = {bar: 1}
* const result = deepMergeWithCache(base, override, cache)
* console.log(result) // {foo: 1, bar: 1}
* console.log(base) // base is not mutated, so: {foo: 1}
* ```
*
* 2. It does a recursive merge for objects:
* ```js
* const cache = new WeakMap()
* const base = {a: {b: 1}}
* const override = {a: {b: 2}}
* const result = deepMergeWithCache(base, override, cache)
* console.log(result) // {a: {b: 2}}
* ```
*
* 2. It uses a WeakMap to cache its results at each level. So merges are referentially stable:
* ```js
* const cache = new WeakMap()
* const base = {a: {b: 1}}
* const override1 = {a: {b: 2}}
* const result1 = deepMergeWithCache(base, override, cache)
* console.log(result1 === deepMergeWithCache(base, override, cache)) // true
*
* const override2 = {...override, c: 1}
* const result2 = deepMergeWithCache(base, override2, cache)
*
* console.log(result1.a === result2.a) // logs true, because override1.a === override2.a
* ```
*
* 4. Both `base` and `override` must be plain JSON values and *NO* arrays, so: `boolean, string, number, undefined, {}`
*
* Rationale: This is used in {@link SheetObject.getValues()} to deep-merge static and sequenced
* and other types of overrides. If we were to do a deep-merge without a cache, we'd be creating and discarding
* several JS objects on each frame for every Theatre object, and that would pressure the GC.
* Plus, keeping the values referentially stable helps lib authors optimize how they patch these values
* to the rendering engine.
*/
export default function deepMergeWithCache<T extends SerializableMap>( export default function deepMergeWithCache<T extends SerializableMap>(
base: T, base: DeepPartialOfSerializableValue<T>,
override: DeepPartialOfSerializableValue<T>, override: DeepPartialOfSerializableValue<T>,
cache: WeakMap<{}, unknown>, cache: WeakMap<{}, unknown>,
): T { ): DeepPartialOfSerializableValue<T> {
const _cache: WeakMap<SerializableMap, {override: T; merged: T}> = const _cache: WeakMap<
cache as $IntentionalAny SerializableMap,
{
override: DeepPartialOfSerializableValue<T>
merged: DeepPartialOfSerializableValue<T>
}
> = cache as $IntentionalAny
const possibleCachedValue = _cache.get(base) const possibleCachedValue = _cache.get(base)