Explain how SheetObject.getValues() works
This commit is contained in:
parent
ea229695e1
commit
dd585b0790
2 changed files with 126 additions and 4 deletions
|
@ -74,60 +74,133 @@ export default class SheetObject implements IdentityDerivationProvider {
|
|||
}
|
||||
|
||||
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()', () =>
|
||||
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())
|
||||
|
||||
/**
|
||||
* The second layer is the initialValue, which is what the user sets with `sheetObject.initialValue = someValue`.
|
||||
*/
|
||||
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(
|
||||
'withInitialCache',
|
||||
() => new WeakMap(),
|
||||
[],
|
||||
)
|
||||
|
||||
// deep-merge the defaultValues with the initialValues.
|
||||
const withInitial = deepMergeWithCache(
|
||||
defaults,
|
||||
initial,
|
||||
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())
|
||||
|
||||
// Similar to above, we need a separate but stable WeakMap to cache the result of merging the static values
|
||||
const withStaticsCache = prism.memo(
|
||||
'withStatics',
|
||||
() => new WeakMap(),
|
||||
[],
|
||||
)
|
||||
|
||||
// deep-merge the static values with the previous layer
|
||||
const withStatics = deepMergeWithCache(
|
||||
withInitial,
|
||||
statics,
|
||||
withStaticsCache,
|
||||
)
|
||||
|
||||
/**
|
||||
* The final values (all layers merged together) will be put inside this variable
|
||||
*/
|
||||
let final = withStatics
|
||||
/**
|
||||
* The sequenced values will be put in this variable
|
||||
*/
|
||||
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(
|
||||
'seq',
|
||||
() => 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(
|
||||
'withSeqsCache',
|
||||
() => new WeakMap(),
|
||||
[],
|
||||
)
|
||||
|
||||
// read the sequenced values
|
||||
// (val(val(x))) unwraps the pointer and the derivation
|
||||
sequenced = val(val(pointerToSequencedValuesD))
|
||||
|
||||
// deep-merge the sequenced values with the previous layer
|
||||
const withSeqs = deepMergeWithCache(final, sequenced, withSeqsCache)
|
||||
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)
|
||||
|
||||
// Since we only return a pointer, the value cannot be mutated from outside of this prism.
|
||||
return a.pointer
|
||||
}),
|
||||
)
|
||||
|
|
|
@ -1,13 +1,62 @@
|
|||
import type {$IntentionalAny} from '@theatre/shared/utils/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>(
|
||||
base: T,
|
||||
base: DeepPartialOfSerializableValue<T>,
|
||||
override: DeepPartialOfSerializableValue<T>,
|
||||
cache: WeakMap<{}, unknown>,
|
||||
): T {
|
||||
const _cache: WeakMap<SerializableMap, {override: T; merged: T}> =
|
||||
cache as $IntentionalAny
|
||||
): DeepPartialOfSerializableValue<T> {
|
||||
const _cache: WeakMap<
|
||||
SerializableMap,
|
||||
{
|
||||
override: DeepPartialOfSerializableValue<T>
|
||||
merged: DeepPartialOfSerializableValue<T>
|
||||
}
|
||||
> = cache as $IntentionalAny
|
||||
|
||||
const possibleCachedValue = _cache.get(base)
|
||||
|
||||
|
|
Loading…
Reference in a new issue