Skip to content

Commit f317bec

Browse files
committed
Implement experiment for: emberjs/rfcs#905
1 parent e7b15d7 commit f317bec

File tree

6 files changed

+363
-0
lines changed

6 files changed

+363
-0
lines changed

.changeset/hip-fishes-agree.md

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
"ember-resources": minor
3+
---
4+
5+
Add link() and @link, importable from `ember-resources/link`.
6+
7+
NOTE: for existing users of `ember-resources`, this addition has no impact on your bundle.
8+
9+
<details><summary>Example property usage</summary>
10+
11+
```js
12+
import { link } from 'ember-resources/link';
13+
14+
class MyClass { ... }
15+
16+
export default class Demo extends Component {
17+
// This usage does now allow passing args to `MyClass`
18+
@link(MyClass) myInstance;
19+
}
20+
```
21+
22+
</details>
23+
24+
<details><summary>Example inline usage</summary>
25+
26+
```js
27+
import Component from '@glimmer/component';
28+
import { cached } from '@glimmer/tracking';
29+
import { link } from 'ember-resources/link';
30+
31+
export default class Demo extends Component {
32+
// To pass args to `MyClass`, you must use this form
33+
// NOTE though, that `instance` is linked to the `Demo`s lifecycle.
34+
// So if @foo is changing frequently, memory pressure will increase rapidly
35+
// until the `Demo` instance is destroyed.
36+
//
37+
// Resources are a better fit for this use case, as they won't add to memory pressure.
38+
@cached
39+
get myFunction() {
40+
let instance = new MyClass(this.args.foo);
41+
42+
return link(instance, this);
43+
}
44+
}
45+
```
46+
47+
</details>
48+
49+
50+
This abstracts away the following boilerplate:
51+
```js
52+
import { getOwner, setOwner } from '@ember/owner';
53+
import { associateDestroyableChild } from '@ember/destroyable';
54+
55+
class MyClass { /* ... */ }
56+
57+
export default class Demo extends Component {
58+
@cached
59+
get myInstance() {
60+
let instance = new MyClass();
61+
62+
associateDestroyableChild(this, instance);
63+
64+
let owner = getOwner(this);
65+
66+
if (owner) {
67+
setOwner(instance, owner);
68+
}
69+
70+
return instance;
71+
}
72+
}
73+
```
74+

ember-resources/package.json

+4
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"./core": "./dist/core/index.js",
1313
"./core/class-based": "./dist/core/class-based/index.js",
1414
"./core/function-based": "./dist/core/function-based/index.js",
15+
"./link": "./dist/link.js",
1516
"./service": "./dist/service.js",
1617
"./util": "./dist/util/index.js",
1718
"./util/cell": "./dist/util/cell.js",
@@ -33,6 +34,9 @@
3334
"core": [
3435
"dist/core/index.d.ts"
3536
],
37+
"link": [
38+
"dist/link.d.ts"
39+
],
3640
"service": [
3741
"dist/service.d.ts"
3842
],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { expectTypeOf } from 'expect-type';
2+
3+
import { link } from '../link';
4+
5+
class Demo {
6+
foo = 2;
7+
}
8+
9+
class A {
10+
@link demo = new Demo();
11+
}
12+
13+
expectTypeOf(new A().demo).toMatchTypeOf<Demo>;
14+
15+
class B {
16+
@link(Demo) declare demo: Demo;
17+
}
18+
19+
expectTypeOf(new B().demo).toMatchTypeOf<Demo>;
20+
21+
let c = link(new Demo(), new Demo());
22+
23+
expectTypeOf(c).toMatchTypeOf<Demo>;
24+
25+

ember-resources/src/core/types.ts

+6
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ export interface Stage1DecoratorDescriptor {
2323
initializer: () => unknown;
2424
}
2525

26+
export type Stage1Decorator = (
27+
prototype: object,
28+
key: string | symbol,
29+
descriptor: Stage1DecoratorDescriptor
30+
) => any;
31+
2632
export interface ClassResourceConfig {
2733
thunk: Thunk;
2834
definition: unknown;

ember-resources/src/link.ts

+182
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { getOwner, setOwner } from '@ember/application';
2+
import { assert } from '@ember/debug';
3+
import { associateDestroyableChild } from '@ember/destroyable';
4+
5+
import type { Class, Stage1Decorator, Stage1DecoratorDescriptor } from '[core-types]';
6+
7+
/**
8+
* A util to abstract away the boilerplate of linking of "things" with an owner
9+
* and making them destroyable.
10+
*
11+
* ```js
12+
* import Component from '@glimmer/component';
13+
* import { link } from 'ember-resources/link';
14+
*
15+
* class MyClass { ... }
16+
*
17+
* export default class Demo extends Component {
18+
* @link(MyClass) myInstance;
19+
* }
20+
* ```
21+
*/
22+
export function _link<Instance>(child: Class<Instance>): Stage1Decorator;
23+
/**
24+
* A util to abstract away the boilerplate of linking of "things" with an owner
25+
* and making them destroyable.
26+
*
27+
* ```js
28+
* import Component from '@glimmer/component';
29+
* import { cached } from '@glimmer/tracking';
30+
* import { link } from 'ember-resources/link';
31+
*
32+
* export default class Demo extends Component {
33+
* @cached
34+
* get myFunction() {
35+
* let instance = new MyClass(this.args.foo);
36+
*
37+
* return link(instance, this);
38+
* }
39+
* }
40+
* ```
41+
*
42+
* NOTE: If args change, as in this example, memory pressure will increase,
43+
* as the linked instance will be held on to until the host object is destroyed.
44+
*/
45+
export function _link<Child extends InstanceType<any>>(child: Child, parent: object): Child;
46+
47+
/**
48+
* A util to abstract away the boilerplate of linking of "things" with an owner
49+
* and making them destroyable.
50+
*
51+
* ```js
52+
* import Component from '@glimmer/component';
53+
* import { link } from 'ember-resources/link';
54+
*
55+
* class MyClass { ... }
56+
*
57+
* export default class Demo extends Component {
58+
* @link myInstance = new MyClass();
59+
* }
60+
* ```
61+
*
62+
* NOTE: reactive args may not be passed to `MyClass` directly if you wish updates to be observed.
63+
* A way to use reactive args is this:
64+
*
65+
* ```js
66+
* import Component from '@glimmer/component';
67+
* import { tracked } from '@glimmer/tracking';
68+
* import { link } from 'ember-resources/link';
69+
*
70+
* class MyClass { ... }
71+
*
72+
* export default class Demo extends Component {
73+
* @tracked foo = 'bar';
74+
*
75+
* @link myInstance = new MyClass({
76+
* foo: () => this.args.foo,
77+
* bar: () => this.bar,
78+
* });
79+
* }
80+
* ```
81+
*
82+
* This way, whenever foo() or bar() is invoked within `MyClass`,
83+
* only the thing that does that invocation will become entangled with the tracked data
84+
* referenced within those functions.
85+
*/
86+
export function _link(...args: Parameters<Stage1Decorator>): void;
87+
88+
export function _link(...args: Parameters<Stage1Decorator> | [object, object] | [Class<unknown>]) {
89+
if (args.length === 3) {
90+
/**
91+
* Uses initializer to get the child
92+
*/
93+
return linkDecorator(...args as Parameters<Stage1Decorator>);
94+
}
95+
96+
if (args.length === 1) {
97+
return linkDecoratorFactory(...args as unknown as [any]);
98+
}
99+
100+
// Because TS types assume property decorators might not have a descriptor,
101+
// we have to cast....
102+
return directLink(...args as unknown as [object, object])
103+
}
104+
105+
type TSDecorator = (prototype: object, key: string | symbol) => void;
106+
107+
type Link<Instance = {}> =
108+
| TSDecorator
109+
| ((a: Class<Instance>) => TSDecorator)
110+
| ((a: Instance, b: object) => Instance);
111+
112+
export const link: Link = _link;
113+
114+
function directLink(child: object, parent: object) {
115+
associateDestroyableChild(parent, child);
116+
117+
let owner = getOwner(parent);
118+
119+
if (owner) {
120+
setOwner(child, owner);
121+
}
122+
123+
return child;
124+
}
125+
126+
function linkDecoratorFactory(child: Class<unknown>) {
127+
return function decoratorPrep(...args: Parameters<Stage1Decorator>) {
128+
return linkDecorator(...args, child);
129+
};
130+
}
131+
132+
function linkDecorator(
133+
_prototype: object,
134+
key: string | Symbol,
135+
descriptor: Stage1DecoratorDescriptor | undefined,
136+
explicitChild?: Class<unknown>
137+
): void {
138+
assert(`@link is a stage 1 decorator, and requires a descriptor`, descriptor);
139+
assert(`@link can only be used with string-keys`, typeof key === 'string');
140+
141+
let { initializer } = descriptor;
142+
143+
assert(
144+
`@link requires an initializer or be used as a decorator factory (\`@link(...))\`). For example, ` +
145+
`\`@link foo = new MyClass();\` or \`@link(MyClass) foo;\``,
146+
initializer || explicitChild
147+
);
148+
149+
let caches = new WeakMap<object, any>();
150+
151+
return {
152+
get(this: object) {
153+
let child = caches.get(this);
154+
155+
if (!child) {
156+
if (initializer) {
157+
child = initializer.call(this);
158+
}
159+
160+
if (explicitChild) {
161+
// How do you narrow this to a constructor?
162+
child = new explicitChild();
163+
}
164+
165+
assert(`Failed to create child instance.`, child);
166+
167+
associateDestroyableChild(this, child);
168+
169+
let owner = getOwner(this);
170+
171+
assert(`Owner was not present on parent. Is instance of ${this.constructor.name}`, owner);
172+
173+
setOwner(child, owner);
174+
175+
caches.set(this, child);
176+
assert(`Failed to create cache for internal resource configuration object`, child);
177+
}
178+
179+
return child;
180+
},
181+
} as unknown as void /* Thanks TS. */;
182+
}

test-app/tests/link-test.ts

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { setOwner } from '@ember/application';
2+
import Service, { inject as service } from '@ember/service';
3+
import { module, test } from 'qunit';
4+
import { setupTest } from 'ember-qunit';
5+
6+
import { link } from 'ember-resources/link';
7+
8+
module('@link', function (hooks) {
9+
setupTest(hooks);
10+
11+
class FooService extends Service {
12+
bar = 2;
13+
}
14+
15+
test('works with no initializer', async function (assert) {
16+
this.owner.register('service:foo', FooService);
17+
18+
class Demo {
19+
@service declare foo: FooService;
20+
}
21+
22+
class TestDemo {
23+
@link(Demo) declare demo: Demo;
24+
}
25+
26+
let testDemo = new TestDemo();
27+
28+
setOwner(testDemo, this.owner);
29+
30+
assert.strictEqual(testDemo.demo.foo.bar, 2);
31+
});
32+
33+
test('works with initializer', async function (assert) {
34+
this.owner.register('service:foo', FooService);
35+
36+
class Demo {
37+
@service declare foo: FooService;
38+
}
39+
40+
class TestDemo {
41+
@link demo = new Demo();
42+
}
43+
44+
let testDemo = new TestDemo();
45+
46+
setOwner(testDemo, this.owner);
47+
48+
assert.strictEqual(testDemo.demo.foo.bar, 2);
49+
});
50+
});
51+
52+
module('link', function (hooks) {
53+
setupTest(hooks);
54+
55+
class FooService extends Service {
56+
foo = 2;
57+
}
58+
59+
test('it works', async function (assert) {
60+
this.owner.register('service:foo', FooService);
61+
62+
class Demo {
63+
@service declare foo: FooService;
64+
}
65+
66+
let demo = new Demo();
67+
68+
link(demo, this);
69+
70+
assert.strictEqual(demo.foo.foo, 2);
71+
});
72+
});

0 commit comments

Comments
 (0)