Skip to content

Commit

Permalink
Implement experiment for: emberjs/rfcs#905
Browse files Browse the repository at this point in the history
  • Loading branch information
NullVoxPopuli committed Mar 10, 2023
1 parent e7b15d7 commit 18adb86
Show file tree
Hide file tree
Showing 6 changed files with 354 additions and 0 deletions.
74 changes: 74 additions & 0 deletions .changeset/hip-fishes-agree.md
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;
}
}
```

4 changes: 4 additions & 0 deletions ember-resources/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"./core": "./dist/core/index.js",
"./core/class-based": "./dist/core/class-based/index.js",
"./core/function-based": "./dist/core/function-based/index.js",
"./link": "./dist/link.js",
"./service": "./dist/service.js",
"./util": "./dist/util/index.js",
"./util/cell": "./dist/util/cell.js",
Expand All @@ -33,6 +34,9 @@
"core": [
"dist/core/index.d.ts"
],
"link": [
"dist/link.d.ts"
],
"service": [
"dist/service.d.ts"
],
Expand Down
23 changes: 23 additions & 0 deletions ember-resources/src/-type-tests/link.test.ts
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>;
6 changes: 6 additions & 0 deletions ember-resources/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export interface Stage1DecoratorDescriptor {
initializer: () => unknown;
}

export type Stage1Decorator = (
prototype: object,
key: string | symbol,
descriptor?: Stage1DecoratorDescriptor
) => any;

export interface ClassResourceConfig {
thunk: Thunk;
definition: unknown;
Expand Down
175 changes: 175 additions & 0 deletions ember-resources/src/link.ts
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. */;
}
72 changes: 72 additions & 0 deletions test-app/tests/link-test.ts
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);
});
});

0 comments on commit 18adb86

Please sign in to comment.