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>> {
|
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
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue