Skip to content
This repository was archived by the owner on Oct 29, 2024. It is now read-only.

Commit 4707c1c

Browse files
authored
Merge pull request #358 from NullVoxPopuli/cached-decorator
Adds @cached decorator
2 parents 3379053 + 781b7e9 commit 4707c1c

File tree

7 files changed

+350
-3
lines changed

7 files changed

+350
-3
lines changed

packages/@glimmer/tracking/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export { cached } from './src/cached';
12
export { tracked } from './src/tracked';
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { DEBUG } from '@glimmer/env';
2+
import { createCache, getValue } from '@glimmer/validator';
3+
4+
/**
5+
* @decorator
6+
*
7+
* The `@cached` decorator can be used on getters in order to cache the return
8+
* value of the getter. This is useful when a getter is expensive and used very
9+
* often.
10+
*
11+
*
12+
* @example
13+
*
14+
* in this guest list class, we have the `sortedGuests`
15+
* getter that sorts the guests alphabetically:
16+
*
17+
* ```js
18+
* import { tracked } from '@glimmer/tracking';
19+
*
20+
* class GuestList {
21+
* @tracked guests = ['Zoey', 'Tomster'];
22+
*
23+
* get sortedGuests() {
24+
* return this.guests.slice().sort()
25+
* }
26+
* }
27+
* ```
28+
*
29+
* Every time `sortedGuests` is accessed, a new array will be created and sorted,
30+
* because JavaScript getters do not cache by default. When the guest list is
31+
* small, like the one in the example, this is not a problem. However, if the guest
32+
* list were to grow very large, it would mean that we would be doing a large
33+
* amount of work each time we accessed `sortedGetters`. With `@cached`, we can
34+
* cache the value instead:
35+
*
36+
* ```js
37+
* import { tracked, cached } from '@glimmer/tracking';
38+
*
39+
* class GuestList {
40+
* @tracked guests = ['Zoey', 'Tomster'];
41+
*
42+
* @cached
43+
* get sortedGuests() {
44+
* return this.guests.slice().sort()
45+
* }
46+
* }
47+
* ```
48+
*
49+
* Now the `sortedGuests` getter will be cached based on _autotracking_. It will
50+
* only rerun and create a new sorted array when the `guests` tracked property is
51+
* updated.
52+
*
53+
* In general, you should avoid using `@cached` unless you have confirmed that the
54+
* getter you are decorating is computationally expensive. `@cached` adds a small
55+
* amount of overhead to the getter, making it more expensive. While this overhead
56+
* is small, if `@cached` is overused it can add up to a large impact overall in
57+
* your app. Many getters and tracked properties are only accessed once, rendered,
58+
* and then never rerendered, so adding `@cached` when it is unnecessary can
59+
* negatively impact performance.
60+
*/
61+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
62+
export const cached: PropertyDecorator = (...args: any[]) => {
63+
const [target, key, descriptor] = args;
64+
65+
// Error on `@cached()`, `@cached(...args)`, and `@cached propName = value;`
66+
if (DEBUG && target === undefined) throwCachedExtraneousParens();
67+
if (
68+
DEBUG &&
69+
(typeof target !== 'object' ||
70+
typeof key !== 'string' ||
71+
typeof descriptor !== 'object' ||
72+
args.length !== 3)
73+
) {
74+
throwCachedInvalidArgsError(args);
75+
}
76+
if (DEBUG && (!('get' in descriptor) || typeof descriptor.get !== 'function')) {
77+
throwCachedGetterOnlyError(key);
78+
}
79+
80+
const caches = new WeakMap();
81+
const getter = descriptor.get;
82+
83+
descriptor.get = function (): unknown {
84+
if (!caches.has(this)) {
85+
caches.set(this, createCache(getter.bind(this)));
86+
}
87+
88+
return getValue(caches.get(this));
89+
};
90+
};
91+
92+
function throwCachedExtraneousParens(): never {
93+
throw new Error(
94+
'You attempted to use @cached(), which is not necessary nor supported. Remove the parentheses and you will be good to go!'
95+
);
96+
}
97+
98+
function throwCachedGetterOnlyError(key: string): never {
99+
throw new Error(`The @cached decorator must be applied to getters. '${key}' is not a getter.`);
100+
}
101+
102+
function throwCachedInvalidArgsError(args: unknown[] = []): never {
103+
throw new Error(
104+
`You attempted to use @cached on with ${
105+
args.length > 1 ? 'arguments' : 'an argument'
106+
} ( @cached(${args
107+
.map((d) => `'${d}'`)
108+
.join(
109+
', '
110+
)}), which is not supported. Dependencies are automatically tracked, so you can just use ${'`@cached`'}`
111+
);
112+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/* eslint-disable @typescript-eslint/explicit-function-return-type */
2+
const { test } = QUnit;
3+
4+
import { DEBUG } from '@glimmer/env';
5+
import { tracked, cached } from '@glimmer/tracking';
6+
7+
import * as TSFixtures from './fixtures/typescript';
8+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
9+
// @ts-ignore
10+
import * as BabelFixtures from './fixtures/babel';
11+
12+
QUnit.module('[@glimmer/tracking] @cached Decorators');
13+
14+
test('it works', function (assert) {
15+
class Person {
16+
@tracked firstName = 'Jen';
17+
@tracked lastName = 'Weber';
18+
19+
@cached
20+
get fullName() {
21+
const fullName = `${this.firstName} ${this.lastName}`;
22+
assert.step(fullName);
23+
return fullName;
24+
}
25+
}
26+
27+
const person = new Person();
28+
assert.verifySteps([], 'getter is not called after class initialization');
29+
30+
assert.strictEqual(person.fullName, 'Jen Weber');
31+
assert.verifySteps(['Jen Weber'], 'getter was called after property access');
32+
33+
assert.strictEqual(person.fullName, 'Jen Weber');
34+
assert.verifySteps([], 'getter was not called again after repeated property access');
35+
36+
person.firstName = 'Kenneth';
37+
assert.verifySteps([], 'changing a property does not trigger an eager re-computation');
38+
39+
assert.strictEqual(person.fullName, 'Kenneth Weber');
40+
assert.verifySteps(['Kenneth Weber'], 'accessing the property triggers a re-computation');
41+
42+
assert.strictEqual(person.fullName, 'Kenneth Weber');
43+
assert.verifySteps([], 'getter was not called again after repeated property access');
44+
45+
person.lastName = 'Larsen';
46+
assert.verifySteps([], 'changing a property does not trigger an eager re-computation');
47+
48+
assert.strictEqual(person.fullName, 'Kenneth Larsen');
49+
assert.verifySteps(['Kenneth Larsen'], 'accessing the property triggers a re-computation');
50+
});
51+
52+
// https://github.com/ember-polyfills/ember-cached-decorator-polyfill/issues/7
53+
test('it has a separate cache per class instance', function (assert) {
54+
class Person {
55+
@tracked firstName: string;
56+
@tracked lastName: string;
57+
58+
constructor(firstName: string, lastName: string) {
59+
this.firstName = firstName;
60+
this.lastName = lastName;
61+
}
62+
63+
@cached
64+
get fullName() {
65+
const fullName = `${this.firstName} ${this.lastName}`;
66+
assert.step(fullName);
67+
return fullName;
68+
}
69+
}
70+
71+
const jen = new Person('Jen', 'Weber');
72+
const chris = new Person('Chris', 'Garrett');
73+
74+
assert.verifySteps([], 'getter is not called after class initialization');
75+
76+
assert.strictEqual(jen.fullName, 'Jen Weber');
77+
assert.verifySteps(['Jen Weber'], 'getter was called after property access');
78+
79+
assert.strictEqual(jen.fullName, 'Jen Weber');
80+
assert.verifySteps([], 'getter was not called again after repeated property access');
81+
82+
assert.strictEqual(chris.fullName, 'Chris Garrett', 'other instance has a different value');
83+
assert.verifySteps(['Chris Garrett'], 'getter was called after property access');
84+
85+
assert.strictEqual(chris.fullName, 'Chris Garrett');
86+
assert.verifySteps([], 'getter was not called again after repeated property access');
87+
88+
chris.lastName = 'Manson';
89+
assert.verifySteps([], 'changing a property does not trigger an eager re-computation');
90+
91+
assert.strictEqual(jen.fullName, 'Jen Weber', 'other instance is unaffected');
92+
assert.verifySteps([], 'getter was not called again after repeated property access');
93+
94+
assert.strictEqual(chris.fullName, 'Chris Manson');
95+
assert.verifySteps(['Chris Manson'], 'getter was called after property access');
96+
97+
assert.strictEqual(jen.fullName, 'Jen Weber', 'other instance is unaffected');
98+
assert.verifySteps([], 'getter was not called again after repeated property access');
99+
});
100+
101+
[
102+
['Babel', BabelFixtures],
103+
['TypeScript', TSFixtures],
104+
].forEach(([compiler, F]) => {
105+
QUnit.module(`[@glimmer/tracking] Cached Property Decorators with ${compiler}`);
106+
107+
if (DEBUG) {
108+
test('Cached decorator on a property throws an error', (assert) => {
109+
assert.throws(F.createClassWithCachedProperty);
110+
});
111+
112+
test('Cached decorator with a setter throws an error', (assert) => {
113+
assert.throws(F.createClassWithCachedSetter);
114+
});
115+
116+
test('Cached decorator with arguments throws an error', function (assert) {
117+
assert.throws(
118+
F.createClassWithCachedDependentKeys,
119+
/@cached\('firstName', 'lastName'\)/,
120+
'the correct error is thrown'
121+
);
122+
});
123+
124+
test('Using @cached as a decorator factory throws an error', function (assert) {
125+
assert.throws(
126+
F.createClassWithCachedAsDecoratorFactory,
127+
/@cached\(\)/,
128+
'The correct error is thrown'
129+
);
130+
});
131+
}
132+
});

packages/@glimmer/tracking/test/fixtures/babel.js

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { tracked } from '@glimmer/tracking';
1+
import {cached, tracked} from '@glimmer/tracking';
22

33
export class Tom {
44
@tracked firstName = 'Tom';
@@ -16,7 +16,7 @@ class FrozenToran {
1616

1717
Object.freeze(FrozenToran);
1818

19-
export { FrozenToran };
19+
export {FrozenToran};
2020

2121
export class PersonWithCount {
2222
@tracked _firstName = 'Tom';
@@ -100,6 +100,14 @@ export function createClassWithTrackedGetter() {
100100
return new PersonWithTrackedGetter();
101101
}
102102

103+
export function createClassWithCachedProperty() {
104+
class PersonWithCachedProperty {
105+
@cached firstName = 'Tom';
106+
}
107+
108+
return new PersonWithCachedProperty();
109+
}
110+
103111
export function createClassWithTrackedSetter() {
104112
class PersonWithTrackedSetter {
105113
@tracked firstName = 'Tom';
@@ -115,6 +123,22 @@ export function createClassWithTrackedSetter() {
115123
return new PersonWithTrackedSetter();
116124
}
117125

126+
export function createClassWithCachedSetter() {
127+
class PersonWithCachedSetter {
128+
@tracked firstName = 'Tom';
129+
@tracked lastName;
130+
131+
@cached set fullName(fullName) {
132+
const [firstName, lastName] = fullName.split(' ');
133+
this.firstName = firstName;
134+
this.lastName = lastName;
135+
}
136+
}
137+
138+
return new PersonWithCachedSetter();
139+
}
140+
141+
118142
export function createClassWithTrackedDependentKeys() {
119143
class DependentKeysAreCool {
120144
@tracked('firstName', 'lastName') fullName() {
@@ -127,6 +151,18 @@ export function createClassWithTrackedDependentKeys() {
127151
return new DependentKeysAreCool();
128152
}
129153

154+
export function createClassWithCachedDependentKeys() {
155+
class DependentKeysAreCool {
156+
@cached('firstName', 'lastName') fullName() {
157+
return `${this.firstName} ${this.lastName}`;
158+
}
159+
160+
@tracked firstName = 'Tom';
161+
@tracked lastName = 'Dale';
162+
}
163+
return new DependentKeysAreCool();
164+
}
165+
130166
export function createClassWithTrackedAsDecoratorFactory() {
131167
class DependentKeysAreCool {
132168
@tracked() fullName() {
@@ -138,3 +174,15 @@ export function createClassWithTrackedAsDecoratorFactory() {
138174
}
139175
return new DependentKeysAreCool();
140176
}
177+
178+
export function createClassWithCachedAsDecoratorFactory() {
179+
class DependentKeysAreCool {
180+
@cached() fullName() {
181+
return `${this.firstName} ${this.lastName}`;
182+
}
183+
184+
@tracked firstName = 'Tom';
185+
@tracked lastName = 'Dale';
186+
}
187+
return new DependentKeysAreCool();
188+
}

0 commit comments

Comments
 (0)