Skip to content

Commit

Permalink
Merge pull request #358 from NullVoxPopuli/cached-decorator
Browse files Browse the repository at this point in the history
Adds @cached decorator
  • Loading branch information
ef4 authored Aug 9, 2022
2 parents 3379053 + 781b7e9 commit 4707c1c
Show file tree
Hide file tree
Showing 7 changed files with 350 additions and 3 deletions.
1 change: 1 addition & 0 deletions packages/@glimmer/tracking/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { cached } from './src/cached';
export { tracked } from './src/tracked';
112 changes: 112 additions & 0 deletions packages/@glimmer/tracking/src/cached.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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`'}`
);
}
132 changes: 132 additions & 0 deletions packages/@glimmer/tracking/test/cached-decorator-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
const { test } = QUnit;

import { DEBUG } from '@glimmer/env';
import { tracked, cached } from '@glimmer/tracking';

import * as TSFixtures from './fixtures/typescript';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import * as BabelFixtures from './fixtures/babel';

QUnit.module('[@glimmer/tracking] @cached Decorators');

test('it works', function (assert) {
class Person {
@tracked firstName = 'Jen';
@tracked lastName = 'Weber';

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

const 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', function (assert) {
class Person {
@tracked firstName: string;
@tracked lastName: string;

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

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

const jen = new Person('Jen', 'Weber');
const 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');
});

[
['Babel', BabelFixtures],
['TypeScript', TSFixtures],
].forEach(([compiler, F]) => {
QUnit.module(`[@glimmer/tracking] Cached Property Decorators with ${compiler}`);

if (DEBUG) {
test('Cached decorator on a property throws an error', (assert) => {
assert.throws(F.createClassWithCachedProperty);
});

test('Cached decorator with a setter throws an error', (assert) => {
assert.throws(F.createClassWithCachedSetter);
});

test('Cached decorator with arguments throws an error', function (assert) {
assert.throws(
F.createClassWithCachedDependentKeys,
/@cached\('firstName', 'lastName'\)/,
'the correct error is thrown'
);
});

test('Using @cached as a decorator factory throws an error', function (assert) {
assert.throws(
F.createClassWithCachedAsDecoratorFactory,
/@cached\(\)/,
'The correct error is thrown'
);
});
}
});
52 changes: 50 additions & 2 deletions packages/@glimmer/tracking/test/fixtures/babel.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { tracked } from '@glimmer/tracking';
import {cached, tracked} from '@glimmer/tracking';

export class Tom {
@tracked firstName = 'Tom';
Expand All @@ -16,7 +16,7 @@ class FrozenToran {

Object.freeze(FrozenToran);

export { FrozenToran };
export {FrozenToran};

export class PersonWithCount {
@tracked _firstName = 'Tom';
Expand Down Expand Up @@ -100,6 +100,14 @@ export function createClassWithTrackedGetter() {
return new PersonWithTrackedGetter();
}

export function createClassWithCachedProperty() {
class PersonWithCachedProperty {
@cached firstName = 'Tom';
}

return new PersonWithCachedProperty();
}

export function createClassWithTrackedSetter() {
class PersonWithTrackedSetter {
@tracked firstName = 'Tom';
Expand All @@ -115,6 +123,22 @@ export function createClassWithTrackedSetter() {
return new PersonWithTrackedSetter();
}

export function createClassWithCachedSetter() {
class PersonWithCachedSetter {
@tracked firstName = 'Tom';
@tracked lastName;

@cached set fullName(fullName) {
const [firstName, lastName] = fullName.split(' ');
this.firstName = firstName;
this.lastName = lastName;
}
}

return new PersonWithCachedSetter();
}


export function createClassWithTrackedDependentKeys() {
class DependentKeysAreCool {
@tracked('firstName', 'lastName') fullName() {
Expand All @@ -127,6 +151,18 @@ export function createClassWithTrackedDependentKeys() {
return new DependentKeysAreCool();
}

export function createClassWithCachedDependentKeys() {
class DependentKeysAreCool {
@cached('firstName', 'lastName') fullName() {
return `${this.firstName} ${this.lastName}`;
}

@tracked firstName = 'Tom';
@tracked lastName = 'Dale';
}
return new DependentKeysAreCool();
}

export function createClassWithTrackedAsDecoratorFactory() {
class DependentKeysAreCool {
@tracked() fullName() {
Expand All @@ -138,3 +174,15 @@ export function createClassWithTrackedAsDecoratorFactory() {
}
return new DependentKeysAreCool();
}

export function createClassWithCachedAsDecoratorFactory() {
class DependentKeysAreCool {
@cached() fullName() {
return `${this.firstName} ${this.lastName}`;
}

@tracked firstName = 'Tom';
@tracked lastName = 'Dale';
}
return new DependentKeysAreCool();
}
Loading

0 comments on commit 4707c1c

Please sign in to comment.