Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement @cached #19772

Merged
merged 3 commits into from
Oct 8, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
118 changes: 118 additions & 0 deletions packages/@ember/-internals/metal/lib/cached.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// NOTE: copied from: https://github.com/glimmerjs/glimmer.js/pull/358
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
// Both glimmerjs/glimmer.js and emberjs/ember.js have the exact same implementation
// of @cached, so any changes made to one should also be made to the other
import { EMBER_CACHED } from '@ember/canary-features';
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[]) => {
if (EMBER_CACHED) {
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`'}`
);
}
102 changes: 102 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,102 @@
import { AbstractTestCase, moduleFor } from 'internal-test-helpers';
import { cached, tracked } from '../..';
import { EMBER_CACHED } from '@ember/canary-features';

if (EMBER_CACHED) {
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');
}

'@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');
}
}
);
}
2 changes: 2 additions & 0 deletions packages/@ember/canary-features/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const DEFAULT_FEATURES = {
EMBER_STRICT_MODE: true,
EMBER_DYNAMIC_HELPERS_AND_MODIFIERS: true,
EMBER_ROUTING_ROUTER_SERVICE_REFRESH: null,
EMBER_CACHED: null,
};

/**
Expand Down Expand Up @@ -84,3 +85,4 @@ export const EMBER_DYNAMIC_HELPERS_AND_MODIFIERS = featureValue(
export const EMBER_ROUTING_ROUTER_SERVICE_REFRESH = featureValue(
FEATURES.EMBER_ROUTING_ROUTER_SERVICE_REFRESH
);
export const EMBER_CACHED = featureValue(FEATURES.EMBER_CACHED);
2 changes: 1 addition & 1 deletion packages/@glimmer/tracking/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { tracked } from '@ember/-internals/metal';
export { tracked, cached } from '@ember/-internals/metal';