-
Notifications
You must be signed in to change notification settings - Fork 75
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #358 from NullVoxPopuli/cached-decorator
Adds @cached decorator
- Loading branch information
Showing
7 changed files
with
350 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
export { cached } from './src/cached'; | ||
export { tracked } from './src/tracked'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
132
packages/@glimmer/tracking/test/cached-decorator-test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' | ||
); | ||
}); | ||
} | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.