diff --git a/theatre/core/src/sheetObjects/SheetObject.ts b/theatre/core/src/sheetObjects/SheetObject.ts index 4f8cca7..750204d 100644 --- a/theatre/core/src/sheetObjects/SheetObject.ts +++ b/theatre/core/src/sheetObjects/SheetObject.ts @@ -74,60 +74,133 @@ export default class SheetObject implements IdentityDerivationProvider { } getValues(): IDerivation> { + // 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('finalAtom', final) + // Since we only return a pointer, the value cannot be mutated from outside of this prism. return a.pointer }), ) diff --git a/theatre/shared/src/utils/deepMergeWithCache.ts b/theatre/shared/src/utils/deepMergeWithCache.ts index 96f2b31..601fcc7 100644 --- a/theatre/shared/src/utils/deepMergeWithCache.ts +++ b/theatre/shared/src/utils/deepMergeWithCache.ts @@ -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( - base: T, + base: DeepPartialOfSerializableValue, override: DeepPartialOfSerializableValue, cache: WeakMap<{}, unknown>, -): T { - const _cache: WeakMap = - cache as $IntentionalAny +): DeepPartialOfSerializableValue { + const _cache: WeakMap< + SerializableMap, + { + override: DeepPartialOfSerializableValue + merged: DeepPartialOfSerializableValue + } + > = cache as $IntentionalAny const possibleCachedValue = _cache.get(base)