Skip to content

Commit

Permalink
Implement @cached
Browse files Browse the repository at this point in the history
  • Loading branch information
NullVoxPopuli committed Oct 4, 2021
1 parent 61be2e1 commit a23270d
Show file tree
Hide file tree
Showing 3 changed files with 214 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/@ember/-internals/metal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export { Mixin, mixin, observer, applyMixin } from './lib/mixin';
export { default as inject, DEBUG_INJECTION_FUNCTIONS } from './lib/injected_property';
export { tagForProperty, tagForObject, markObjectAsDirty } from './lib/tags';
export { tracked, TrackedDescriptor } from './lib/tracked';
export { cached } from './lib/cached';
export { createCache, getValue, isConst } from './lib/cache';

export {
Expand Down
113 changes: 113 additions & 0 deletions packages/@ember/-internals/metal/lib/cached.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// NOTE: copied from: https://github.com/glimmerjs/glimmer.js/pull/358
import { DEBUG } from '@glimmer/env';
import { createCache, getValue } from '@glimmer/validator';

/**
* @decorator
*
* The `@cached` decorator can be used on getters in order to cache the return
* value of the getter. This is useful when a getter is expensive and used very
* often.
*
*
* @example
*
* in this guest list class, we have the `sortedGuests`
* getter that sorts the guests alphabetically:
*
* ```js
* import { tracked } from '@glimmer/tracking';
*
* class GuestList {
* @tracked guests = ['Zoey', 'Tomster'];
*
* get sortedGuests() {
* return this.guests.slice().sort()
* }
* }
* ```
*
* Every time `sortedGuests` is accessed, a new array will be created and sorted,
* because JavaScript getters do not cache by default. When the guest list is
* small, like the one in the example, this is not a problem. However, if the guest
* list were to grow very large, it would mean that we would be doing a large
* amount of work each time we accessed `sortedGetters`. With `@cached`, we can
* cache the value instead:
*
* ```js
* import { tracked, cached } from '@glimmer/tracking';
*
* class GuestList {
* @tracked guests = ['Zoey', 'Tomster'];
*
* @cached
* get sortedGuests() {
* return this.guests.slice().sort()
* }
* }
* ```
*
* Now the `sortedGuests` getter will be cached based on _autotracking_. It will
* only rerun and create a new sorted array when the `guests` tracked property is
* updated.
*
* In general, you should avoid using `@cached` unless you have confirmed that the
* getter you are decorating is computationally expensive. `@cached` adds a small
* amount of overhead to the getter, making it more expensive. While this overhead
* is small, if `@cached` is overused it can add up to a large impact overall in
* your app. Many getters and tracked properties are only accessed once, rendered,
* and then never rerendered, so adding `@cached` when it is unnecessary can
* negatively impact performance.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const cached: PropertyDecorator = (...args: any[]) => {
const [target, key, descriptor] = args;

// Error on `@cached()`, `@cached(...args)`, and `@cached propName = value;`
if (DEBUG && target === undefined) throwCachedExtraneousParens();
if (
DEBUG &&
(typeof target !== 'object' ||
typeof key !== 'string' ||
typeof descriptor !== 'object' ||
args.length !== 3)
) {
throwCachedInvalidArgsError(args);
}
if (DEBUG && (!('get' in descriptor) || typeof descriptor.get !== 'function')) {
throwCachedGetterOnlyError(key);
}

const caches = new WeakMap();
const getter = descriptor.get;

descriptor.get = function (): unknown {
if (!caches.has(this)) {
caches.set(this, createCache(getter.bind(this)));
}

return getValue(caches.get(this));
};
};

function throwCachedExtraneousParens(): never {
throw new Error(
'You attempted to use @cached(), which is not necessary nor supported. Remove the parentheses and you will be good to go!'
);
}

function throwCachedGetterOnlyError(key: string): never {
throw new Error(`The @cached decorator must be applied to getters. '${key}' is not a getter.`);
}

function throwCachedInvalidArgsError(args: unknown[] = []): never {
throw new Error(
`You attempted to use @cached on with ${
args.length > 1 ? 'arguments' : 'an argument'
} ( @cached(${args
.map((d) => `'${d}'`)
.join(
', '
)}), which is not supported. Dependencies are automatically tracked, so you can just use ${'`@cached`'}`
);
}
100 changes: 100 additions & 0 deletions packages/@ember/-internals/metal/tests/cached/get_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { AbstractTestCase, moduleFor } from 'internal-test-helpers';
import { cached, tracked } from '../..';

moduleFor(
'@cached decorator: get',
class extends AbstractTestCase {
'@test it works'() {
let assert = this.assert;

class Person {
@tracked firstName = 'Jen';
@tracked lastName = 'Weber';

@cached
get fullName() {
let fullName = `${this.firstName} ${this.lastName}`;

assert.step(fullName);
return fullName;
}
}

let person = new Person();

assert.verifySteps([], 'getter is not called after class initialization');

assert.strictEqual(person.fullName, 'Jen Weber');
assert.verifySteps(['Jen Weber'], 'getter was called after property access');

assert.strictEqual(person.fullName, 'Jen Weber');
assert.verifySteps([], 'getter was not called again after repeated property access');

person.firstName = 'Kenneth';
assert.verifySteps([], 'changing a property does not trigger an eager re-computation');

assert.strictEqual(person.fullName, 'Kenneth Weber');
assert.verifySteps(['Kenneth Weber'], 'accessing the property triggers a re-computation');

assert.strictEqual(person.fullName, 'Kenneth Weber');
assert.verifySteps([], 'getter was not called again after repeated property access');

person.lastName = 'Larsen';
assert.verifySteps([], 'changing a property does not trigger an eager re-computation');

assert.strictEqual(person.fullName, 'Kenneth Larsen');
assert.verifySteps(['Kenneth Larsen'], 'accessing the property triggers a re-computation');
}

// https://github.com/ember-polyfills/ember-cached-decorator-polyfill/issues/7
'@test it has a separate cache per class instance'() {
let assert = this.assert;

class Person {
@tracked firstName;
@tracked lastName;

constructor(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}

@cached
get fullName() {
let fullName = `${this.firstName} ${this.lastName}`;
assert.step(fullName);
return fullName;
}
}

let jen = new Person('Jen', 'Weber');
let chris = new Person('Chris', 'Garrett');

assert.verifySteps([], 'getter is not called after class initialization');

assert.strictEqual(jen.fullName, 'Jen Weber');
assert.verifySteps(['Jen Weber'], 'getter was called after property access');

assert.strictEqual(jen.fullName, 'Jen Weber');
assert.verifySteps([], 'getter was not called again after repeated property access');

assert.strictEqual(chris.fullName, 'Chris Garrett', 'other instance has a different value');
assert.verifySteps(['Chris Garrett'], 'getter was called after property access');

assert.strictEqual(chris.fullName, 'Chris Garrett');
assert.verifySteps([], 'getter was not called again after repeated property access');

chris.lastName = 'Manson';
assert.verifySteps([], 'changing a property does not trigger an eager re-computation');

assert.strictEqual(jen.fullName, 'Jen Weber', 'other instance is unaffected');
assert.verifySteps([], 'getter was not called again after repeated property access');

assert.strictEqual(chris.fullName, 'Chris Manson');
assert.verifySteps(['Chris Manson'], 'getter was called after property access');

assert.strictEqual(jen.fullName, 'Jen Weber', 'other instance is unaffected');
assert.verifySteps([], 'getter was not called again after repeated property access');
}
}
);

0 comments on commit a23270d

Please sign in to comment.