-
Notifications
You must be signed in to change notification settings - Fork 37
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement experiment for: emberjs/rfcs#905
- Loading branch information
1 parent
e7b15d7
commit 18adb86
Showing
6 changed files
with
354 additions
and
0 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 |
---|---|---|
@@ -0,0 +1,74 @@ | ||
--- | ||
"ember-resources": minor | ||
--- | ||
|
||
Add link() and @link, importable from `ember-resources/link`. | ||
|
||
NOTE: for existing users of `ember-resources`, this addition has no impact on your bundle. | ||
|
||
<details><summary>Example property usage</summary> | ||
|
||
```js | ||
import { link } from 'ember-resources/link'; | ||
|
||
class MyClass { ... } | ||
|
||
export default class Demo extends Component { | ||
// This usage does now allow passing args to `MyClass` | ||
@link(MyClass) myInstance; | ||
} | ||
``` | ||
|
||
</details> | ||
|
||
<details><summary>Example inline usage</summary> | ||
|
||
```js | ||
import Component from '@glimmer/component'; | ||
import { cached } from '@glimmer/tracking'; | ||
import { link } from 'ember-resources/link'; | ||
|
||
export default class Demo extends Component { | ||
// To pass args to `MyClass`, you must use this form | ||
// NOTE though, that `instance` is linked to the `Demo`s lifecycle. | ||
// So if @foo is changing frequently, memory pressure will increase rapidly | ||
// until the `Demo` instance is destroyed. | ||
// | ||
// Resources are a better fit for this use case, as they won't add to memory pressure. | ||
@cached | ||
get myFunction() { | ||
let instance = new MyClass(this.args.foo); | ||
|
||
return link(instance, this); | ||
} | ||
} | ||
``` | ||
|
||
</details> | ||
|
||
|
||
This abstracts away the following boilerplate: | ||
```js | ||
import { getOwner, setOwner } from '@ember/owner'; | ||
import { associateDestroyableChild } from '@ember/destroyable'; | ||
|
||
class MyClass { /* ... */ } | ||
|
||
export default class Demo extends Component { | ||
@cached | ||
get myInstance() { | ||
let instance = new MyClass(); | ||
|
||
associateDestroyableChild(this, instance); | ||
|
||
let owner = getOwner(this); | ||
|
||
if (owner) { | ||
setOwner(instance, owner); | ||
} | ||
|
||
return instance; | ||
} | ||
} | ||
``` | ||
|
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
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,23 @@ | ||
import { expectTypeOf } from 'expect-type'; | ||
|
||
import { link } from '../link'; | ||
|
||
class Demo { | ||
foo = 2; | ||
} | ||
|
||
class A { | ||
@link demo = new Demo(); | ||
} | ||
|
||
expectTypeOf(new A().demo).toMatchTypeOf<Demo>; | ||
|
||
class B { | ||
@link(Demo) declare demo: Demo; | ||
} | ||
|
||
expectTypeOf(new B().demo).toMatchTypeOf<Demo>; | ||
|
||
let c = link(new Demo(), new Demo()); | ||
|
||
expectTypeOf(c).toMatchTypeOf<Demo>; |
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
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,175 @@ | ||
import { getOwner, setOwner } from '@ember/application'; | ||
import { assert } from '@ember/debug'; | ||
import { associateDestroyableChild } from '@ember/destroyable'; | ||
|
||
import type { Class, Stage1Decorator, Stage1DecoratorDescriptor } from '[core-types]'; | ||
|
||
type NonKey<K> = K extends string ? never : K extends symbol ? never : K; | ||
|
||
/** | ||
* A util to abstract away the boilerplate of linking of "things" with an owner | ||
* and making them destroyable. | ||
* | ||
* ```js | ||
* import Component from '@glimmer/component'; | ||
* import { link } from 'ember-resources/link'; | ||
* | ||
* class MyClass { ... } | ||
* | ||
* export default class Demo extends Component { | ||
* @link(MyClass) myInstance; | ||
* } | ||
* ``` | ||
*/ | ||
export function link<Instance>(child: Class<Instance>): Stage1Decorator; | ||
/** | ||
* A util to abstract away the boilerplate of linking of "things" with an owner | ||
* and making them destroyable. | ||
* | ||
* ```js | ||
* import Component from '@glimmer/component'; | ||
* import { cached } from '@glimmer/tracking'; | ||
* import { link } from 'ember-resources/link'; | ||
* | ||
* export default class Demo extends Component { | ||
* @cached | ||
* get myFunction() { | ||
* let instance = new MyClass(this.args.foo); | ||
* | ||
* return link(instance, this); | ||
* } | ||
* } | ||
* ``` | ||
* | ||
* NOTE: If args change, as in this example, memory pressure will increase, | ||
* as the linked instance will be held on to until the host object is destroyed. | ||
*/ | ||
export function link<Child, Other>(child: Child, parent: NonKey<Other>): Child; | ||
|
||
/** | ||
* A util to abstract away the boilerplate of linking of "things" with an owner | ||
* and making them destroyable. | ||
* | ||
* ```js | ||
* import Component from '@glimmer/component'; | ||
* import { link } from 'ember-resources/link'; | ||
* | ||
* class MyClass { ... } | ||
* | ||
* export default class Demo extends Component { | ||
* @link myInstance = new MyClass(); | ||
* } | ||
* ``` | ||
* | ||
* NOTE: reactive args may not be passed to `MyClass` directly if you wish updates to be observed. | ||
* A way to use reactive args is this: | ||
* | ||
* ```js | ||
* import Component from '@glimmer/component'; | ||
* import { tracked } from '@glimmer/tracking'; | ||
* import { link } from 'ember-resources/link'; | ||
* | ||
* class MyClass { ... } | ||
* | ||
* export default class Demo extends Component { | ||
* @tracked foo = 'bar'; | ||
* | ||
* @link myInstance = new MyClass({ | ||
* foo: () => this.args.foo, | ||
* bar: () => this.bar, | ||
* }); | ||
* } | ||
* ``` | ||
* | ||
* This way, whenever foo() or bar() is invoked within `MyClass`, | ||
* only the thing that does that invocation will become entangled with the tracked data | ||
* referenced within those functions. | ||
*/ | ||
export function link(...args: Parameters<Stage1Decorator>): void; | ||
|
||
export function link(...args: any[]) { | ||
if (args.length === 3) { | ||
/** | ||
* Uses initializer to get the child | ||
*/ | ||
return linkDecorator(...(args as Parameters<Stage1Decorator>)); | ||
} | ||
|
||
if (args.length === 1) { | ||
return linkDecoratorFactory(...(args as unknown as [any])); | ||
} | ||
|
||
// Because TS types assume property decorators might not have a descriptor, | ||
// we have to cast.... | ||
return directLink(...(args as unknown as [object, object])); | ||
} | ||
|
||
function directLink(child: object, parent: object) { | ||
associateDestroyableChild(parent, child); | ||
|
||
let owner = getOwner(parent); | ||
|
||
if (owner) { | ||
setOwner(child, owner); | ||
} | ||
|
||
return child; | ||
} | ||
|
||
function linkDecoratorFactory(child: Class<unknown>) { | ||
return function decoratorPrep(...args: Parameters<Stage1Decorator>) { | ||
return linkDecorator(...args, child); | ||
}; | ||
} | ||
|
||
function linkDecorator( | ||
_prototype: object, | ||
key: string | Symbol, | ||
descriptor: Stage1DecoratorDescriptor | undefined, | ||
explicitChild?: Class<unknown> | ||
): void { | ||
assert(`@link is a stage 1 decorator, and requires a descriptor`, descriptor); | ||
assert(`@link can only be used with string-keys`, typeof key === 'string'); | ||
|
||
let { initializer } = descriptor; | ||
|
||
assert( | ||
`@link requires an initializer or be used as a decorator factory (\`@link(...))\`). For example, ` + | ||
`\`@link foo = new MyClass();\` or \`@link(MyClass) foo;\``, | ||
initializer || explicitChild | ||
); | ||
|
||
let caches = new WeakMap<object, any>(); | ||
|
||
return { | ||
get(this: object) { | ||
let child = caches.get(this); | ||
|
||
if (!child) { | ||
if (initializer) { | ||
child = initializer.call(this); | ||
} | ||
|
||
if (explicitChild) { | ||
// How do you narrow this to a constructor? | ||
child = new explicitChild(); | ||
} | ||
|
||
assert(`Failed to create child instance.`, child); | ||
|
||
associateDestroyableChild(this, child); | ||
|
||
let owner = getOwner(this); | ||
|
||
assert(`Owner was not present on parent. Is instance of ${this.constructor.name}`, owner); | ||
|
||
setOwner(child, owner); | ||
|
||
caches.set(this, child); | ||
assert(`Failed to create cache for internal resource configuration object`, child); | ||
} | ||
|
||
return child; | ||
}, | ||
} as unknown as void /* Thanks 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,72 @@ | ||
import { setOwner } from '@ember/application'; | ||
import Service, { inject as service } from '@ember/service'; | ||
import { module, test } from 'qunit'; | ||
import { setupTest } from 'ember-qunit'; | ||
|
||
import { link } from 'ember-resources/link'; | ||
|
||
module('@link', function (hooks) { | ||
setupTest(hooks); | ||
|
||
class FooService extends Service { | ||
bar = 2; | ||
} | ||
|
||
test('works with no initializer', async function (assert) { | ||
this.owner.register('service:foo', FooService); | ||
|
||
class Demo { | ||
@service declare foo: FooService; | ||
} | ||
|
||
class TestDemo { | ||
@link(Demo) declare demo: Demo; | ||
} | ||
|
||
let testDemo = new TestDemo(); | ||
|
||
setOwner(testDemo, this.owner); | ||
|
||
assert.strictEqual(testDemo.demo.foo.bar, 2); | ||
}); | ||
|
||
test('works with initializer', async function (assert) { | ||
this.owner.register('service:foo', FooService); | ||
|
||
class Demo { | ||
@service declare foo: FooService; | ||
} | ||
|
||
class TestDemo { | ||
@link demo = new Demo(); | ||
} | ||
|
||
let testDemo = new TestDemo(); | ||
|
||
setOwner(testDemo, this.owner); | ||
|
||
assert.strictEqual(testDemo.demo.foo.bar, 2); | ||
}); | ||
}); | ||
|
||
module('link', function (hooks) { | ||
setupTest(hooks); | ||
|
||
class FooService extends Service { | ||
foo = 2; | ||
} | ||
|
||
test('it works', async function (assert) { | ||
this.owner.register('service:foo', FooService); | ||
|
||
class Demo { | ||
@service declare foo: FooService; | ||
} | ||
|
||
let demo = new Demo(); | ||
|
||
link(demo, this); | ||
|
||
assert.strictEqual(demo.foo.foo, 2); | ||
}); | ||
}); |