Skip to content

Commit e4fadb5

Browse files
Merge pull request #797 from NullVoxPopuli/link-util
Implement experiment for: emberjs/rfcs#905 (link)
2 parents adab9f6 + 18adb86 commit e4fadb5

File tree

6 files changed

+354
-0
lines changed

6 files changed

+354
-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,23 @@
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>;

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

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

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)