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

@use decorator #107

Merged
merged 4 commits into from
Jan 31, 2022
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
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,6 @@ _This is a [V2-format Addon](https://github.com/emberjs/rfcs/pull/507) with V1 c
* typeScript v4.2+
* ember-auto-import v2+

_NOTE_: if you are also using ember-could-get-used-to-this, `@use` is not compatible with
this library's `LifecycleResource`, and `useResource` does not work with ember-could-get-used-to-this' `Resource`.
However, both libraries can still be used in the same project.

## Installation

```bash
Expand Down Expand Up @@ -62,6 +58,24 @@ class MyClass {

## Usage

### `@use`

The `@use` decorator abstractions away the underlying reactivity configuration
needed to use a Resource. `@use` can work with `Resource` or `LifecycleResource`.

```js
class MyClass {
@use data = SomeResource.with(() => [arg list]);
}
```

All subclasses of `Resource` and `LifecycleResource` have a static method, `with`.
This `with` method takes the same argument Thunk you'll see throughout other usages
of Resources in this document.

The `type` of `data` in this example will be an instance of `SomeResource`, so that
typescript is happy / correct.

### `useResource`

`useResource` takes either a `Resource` or `LifecycleResource` and an args thunk.
Expand Down
11 changes: 10 additions & 1 deletion ember-resources/src/-private/resources/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { associateDestroyableChild, registerDestructor } from '@ember/destroyabl
// @ts-ignore
import { capabilities as helperCapabilities, setHelperManager } from '@ember/helper';

import type { ArgsWrapper, Cache, LooseArgs } from '../types';
import type { ArgsWrapper, Cache, LooseArgs, Thunk } from '../types';

export declare interface LifecycleResource<T extends LooseArgs = ArgsWrapper> {
args: T;
Expand All @@ -19,6 +19,15 @@ export declare interface LifecycleResource<T extends LooseArgs = ArgsWrapper> {
}

export class LifecycleResource<T extends LooseArgs = ArgsWrapper> {
static with<Args extends ArgsWrapper, SubClass extends LifecycleResource<Args>>(
/* hack to get inheritence in static methods */
this: { new (owner: unknown, args: Args, previous?: SubClass): SubClass },
thunk: Thunk
): SubClass {
// Lie about the type because `with` must be used with the `@use` decorator
return [this, thunk] as unknown as SubClass;
}

constructor(owner: unknown, public args: T) {
setOwner(this, owner);
}
Expand Down
11 changes: 10 additions & 1 deletion ember-resources/src/-private/resources/simple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { associateDestroyableChild, destroy } from '@ember/destroyable';
// @ts-ignore
import { capabilities as helperCapabilities, setHelperManager } from '@ember/helper';

import type { ArgsWrapper, Cache, LooseArgs } from '../types';
import type { ArgsWrapper, Cache, LooseArgs, Thunk } from '../types';

export declare interface Resource<T extends LooseArgs = ArgsWrapper> {
args: T;
Expand All @@ -21,6 +21,15 @@ export class Resource<T extends LooseArgs = ArgsWrapper> {
return new this(getOwner(prev), args, prev) as R;
}

static with<Args extends ArgsWrapper, SubClass extends Resource<Args>>(
/* hack to get inheritence in static methods */
this: { new (owner: unknown, args: Args, previous?: SubClass): SubClass },
thunk: Thunk
): SubClass {
// Lie about the type because `with` must be used with the `@use` decorator
return [this, thunk] as unknown as SubClass;
}

/**
* @param {unknown} [owner] the application owner which allows service injections
* @param {T} [args] both positional (array) and named (object) args
Expand Down
100 changes: 100 additions & 0 deletions ember-resources/src/-private/use.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/ban-types */

// typed-ember has not publihsed types for this yet
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { getValue } from '@glimmer/tracking/primitives/cache';
import { assert } from '@ember/debug';
// typed-ember has not publihsed types for this yet
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { invokeHelper } from '@ember/helper';

import { normalizeThunk } from './utils';

import type { Thunk } from './types';

interface Class<T = unknown> {
new (...args: unknown[]): T;
}

interface Descriptor {
initializer: () => [Class, Thunk];
}

/**
* works with
* - resources (both Resource and LifecycleResource)
* - functions
*/
export function use(_prototype: object, key: string, descriptor?: Descriptor): void {
if (!descriptor) return;

assert(`@use can only be used with string-keys`, typeof key === 'string');

let resources = new WeakMap<object, { resource: unknown; type: 'class' | 'function' }>();
let { initializer } = descriptor;

// https://github.com/pzuraq/ember-could-get-used-to-this/blob/master/addon/index.js
return {
get() {
let wrapper = resources.get(this as object);

if (!wrapper) {
let initialized = initializer.call(this);

if (Array.isArray(initialized)) {
assert(
`@use ${key} was given unexpected value. Make sure usage is '@use ${key} = MyResource.with(() => ...)'`,

initialized.length === 2 && typeof initialized[1] === 'function'
);

let [Klass, thunk] = initialized;

let resource = invokeHelper(this, Klass, () => {
return normalizeThunk(thunk);
});

wrapper = { resource, type: 'class' };
resources.set(this as object, wrapper);
} else if (typeof initialized === 'function') {
throw new Error('Functions are not yet supported by @use');
}
}

assert(`Resource could not be created`, wrapper);

switch (wrapper.type) {
case 'function':
return getValue(wrapper.resource).value;
case 'class':
return getValue(wrapper.resource);

default:
assert('Resource value could not be extracted', false);
}
},
} as unknown as void /* Thanks TS. */;
}

/**
* Class:
* typeof klass.prototype === 'object'
* typeof klass === 'function'
* klass instanceof Object === true
* Symbol.hasInstance in klass === true
* Function:
* typeof fun.prototype === 'object';
* typeof fun === 'function';
* fun instanceof Object === true
* Symbol.hasInstance in fun === true
* Object:
* typeof obj.prototype === 'undefined'
* typeof obj === 'object'
*
*/
// function isClass(klass?: any) {
// return typeof klass === 'function' && /^class\s/.test(Function.prototype.toString.call(klass));
// }
6 changes: 5 additions & 1 deletion ember-resources/src/-private/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import type { ArgsWrapper, Thunk } from './types';

export const DEFAULT_THUNK = () => [];

export function normalizeThunk(thunk: Thunk): ArgsWrapper {
export function normalizeThunk(thunk?: Thunk): ArgsWrapper {
if (!thunk) {
return { named: {}, positional: [] };
}

let args = thunk();

if (Array.isArray(args)) {
Expand Down
1 change: 1 addition & 0 deletions ember-resources/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export { Resource } from './-private/resources/simple';
// Public API -- for reducing consumed API surface
export { useTask } from './-private/ember-concurrency';
export { trackedFunction } from './-private/tracked-function';
export { use } from './-private/use';
export { useFunction } from './-private/use-function';
export { useHelper } from './-private/use-helper';
export { useResource } from './-private/use-resource';
Expand Down
132 changes: 132 additions & 0 deletions testing/ember-app/tests/unit/use-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { tracked } from '@glimmer/tracking';
import { settled } from '@ember/test-helpers';
import { module, skip, test } from 'qunit';
import { setupTest } from 'ember-qunit';

import { timeout } from 'ember-concurrency';
import { LifecycleResource, Resource, use } from 'ember-resources';

import type { Positional } from 'ember-resources';

module('@use', function (hooks) {
setupTest(hooks);

module('Resource', function () {
test('it works', async function (assert) {
class MyResource<Args extends Positional<[number]>> extends Resource<Args> {
doubled = 0;

constructor(owner: unknown, args: Args, previous?: MyResource<Args>) {
super(owner, args, previous);

this.doubled = previous?.doubled ?? 0;
this.doubled = args.positional[0] * 2;
}
}

class Test {
@tracked num = 1;

@use data = MyResource.with(() => [this.num]);

// @use(() => [this.num]) data = MyResource;
// @use data = MyResource.of(() => [this.num]);
// @use data = MyResource.from(() => [this.num]);
}

let instance = new Test();

assert.strictEqual(instance.data.doubled, 2);

instance.num = 3;
await settled();

assert.strictEqual(instance.data.doubled, 6);
});
});
module('LifecycleResource', function () {
test('it works', async function (assert) {
class MyResource<Args extends Positional<[number]>> extends LifecycleResource<Args> {
doubled = 0;

setup() {
this.update();
}

update() {
this.doubled = this.args.positional[0] * 2;
}
}

class Test {
@tracked num = 1;

@use data = MyResource.with(() => [this.num]);
}

let instance = new Test();

assert.strictEqual(instance.data.doubled, 2);

instance.num = 3;
await settled();

assert.strictEqual(instance.data.doubled, 6);
});
});
module('Task', function () {});
module('Function', function () {
skip('it works with sync functions', async function (assert) {
class Test {
@tracked num = 1;

// How to make TS happy about this?
@use data = () => {
return this.num * 2;
};
}

let instance = new Test();

assert.strictEqual(instance.data, undefined);
await settled();
// @ts-expect-error
assert.strictEqual(instance.data, 2);

instance.num = 3;
await settled();

// @ts-expect-error
assert.strictEqual(instance.data, 6);
});

skip('it works with async functions', async function (assert) {
class Test {
@tracked num = 1;

// How to make TS happy about this?
@use data = async () => {
await timeout(100);

return this.num * 2;
};
}

let instance = new Test();

assert.strictEqual(instance.data, undefined);
await timeout(100);
await settled();

// @ts-expect-error
assert.strictEqual(instance.data, 2);

instance.num = 3;
await settled();

// @ts-expect-error
assert.strictEqual(instance.data, 6);
});
});
});