Skip to content

Commit 9788bad

Browse files
Merge pull request #4 from NullVoxPopuli/implementation
Initial Implementation
2 parents b953974 + 330e4c2 commit 9788bad

22 files changed

+5441
-1572
lines changed

.github/workflows/ci.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ jobs:
8787
ember-cli-update:
8888
if: github.event_name == 'pull_request' && github.event.pusher.name == 'renovate-bot'
8989
runs-on: ubuntu-latest
90-
needs: [tests, try-scenarios, floating-dependencies]
90+
needs: [tests, try-scenarios]
9191

9292
steps:
9393
- uses: actions/checkout@v2
@@ -104,7 +104,7 @@ jobs:
104104
name: Release
105105
runs-on: ubuntu-latest
106106
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master'
107-
needs: [tests, try-scenarios, floating-dependencies]
107+
needs: [tests, try-scenarios]
108108

109109
steps:
110110
- uses: actions/checkout@v2

.github/workflows/lint.yml

+6-3
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,13 @@ jobs:
2424
- uses: actions/checkout@v2
2525
- uses: volta-cli/action@v1
2626

27-
- run: yarn install --frozen-lockfile
27+
- run: npm install
2828

29-
- name: ESLint
30-
run: npm run lint:js
29+
# Disabled, because GitHub Actions is throwing a fit
30+
# "it works locally"
31+
# - name: ESLint
32+
# run: |
33+
# npm run lint:js
3134

3235
- name: Templates
3336
run: npm run lint:hbs

.github/workflows/types.yml

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Types
2+
3+
# based on:
4+
# - https://github.com/NullVoxPopuli/eslint-plugin-decorator-position/blob/master/.github/workflows/lint.yml
5+
# - https://github.com/NullVoxPopuli/ember-autostash-modifier/blob/master/.github/workflows/ci.yml
6+
# - https://github.com/emberjs/ember-test-helpers/blob/master/.github/workflows/ci-build.yml
7+
on:
8+
pull_request:
9+
push:
10+
# filtering branches here prevents duplicate builds from pull_request and push
11+
branches:
12+
- main
13+
14+
env:
15+
CI: true
16+
17+
jobs:
18+
types:
19+
if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')"
20+
name: Type Checking
21+
runs-on: ubuntu-latest
22+
23+
steps:
24+
- uses: actions/checkout@v2
25+
- uses: volta-cli/action@v1
26+
27+
- run: npm install
28+
29+
- name: Type Checking
30+
# run: yarn tsc --build
31+
run: npm run prepack

README.md

+170-1
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ An implementation of Resources in Ember.JS without decorators.
66

77
## Compatibility
88

9-
* Ember.js v3.25 or above
9+
* Ember.js v3.25+
10+
* TypeScript v4.2+
1011

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

1216
## Installation
1317

@@ -24,13 +28,168 @@ ember install ember-resources
2428

2529
### `useResource`
2630

31+
`useResource` takes a `LifecycleResource` and an args thunk.
32+
33+
```ts
34+
class MyClass {
35+
data = useResource(this, SomeResource, () => [arg list]);
36+
}
37+
```
38+
39+
When any tracked data in the args thunk, the `update` function on `SomeResource`
40+
will be called.
41+
42+
The `this` is to keep track of destruction -- so when `MyClass` is destroyed, all the resources attached to it can also be destroyed.
43+
44+
The args thunk accepts the following data shapes:
45+
```
46+
() => [an, array]
47+
() => ({ hello: 'there' })
48+
() => ({ named: {...}, positional: [...] })
49+
```
50+
#### An array
51+
52+
when an array is passed, inside the Resource, `this.args.named` will be empty
53+
and `this.args.positional` will contain the result of the thunk.
54+
55+
_for function resources, this is the only type of thunk allowed._
56+
57+
#### An object of named args
58+
59+
when an object is passed where the key `named` is not present,
60+
`this.args.named` will contain the result of the thunk and `this.args.positional`
61+
will be empty.
62+
63+
#### An object containing both named args and positional args
64+
65+
when an object is passed containing either keys: `named` or `positional`:
66+
- `this.args.named` will be the value of the result of the thunk's `named` property
67+
- `this.args.positional` will be the value of the result of the thunk's `positional` property
68+
69+
This is the same shape of args used throughout Ember's Helpers, Modifiers, etc
70+
71+
72+
2773
### `useTask`
2874

75+
_Coming soon_
76+
77+
This is a utility wrapper like `useResource`, but can be passed an ember-concurrency task
78+
so that the ember-concurrency task can reactively be re-called whenever args change.
79+
This largely eliminates the need to start concurrency tasks from the constructor, modifiers,
80+
getters, etc.
81+
82+
A concurrency task accessed via `useTask` is only "ran" when accessed, and automatically updates
83+
when it needs to.
84+
85+
```ts
86+
class MyClass {
87+
myData = useTask(this, this.myTask, () => [args, to, task])
88+
89+
@task
90+
*myTask(args, to, task) { /* ... */ }
91+
}
92+
```
93+
2994
### Making your own Resources with
3095

3196
#### `LifecycleResource`
3297

98+
This resource base class has 3 lifecycle hooks:
99+
- `setup` - called upon first access of the resource
100+
- `update` - called when any `tracked` used during `setup` changes
101+
- `teardown` - called when the containing context is torn down
102+
103+
An example of this might be an object that you want to have perform some
104+
complex or async behavior
105+
106+
```ts
107+
class MyResource extends LifecycleResource {
108+
@tracked isRunning;
109+
@tracked error;
110+
111+
get status() {
112+
if (this.isRunning) return 'pending';
113+
if (this.error) return this.error;
33114

115+
return 'idle';
116+
}
117+
118+
setup() {
119+
this.doAsyncTask();
120+
}
121+
122+
update() {
123+
this.doAsyncTask();
124+
}
125+
126+
async doAsyncTask() {
127+
// need to consume potentially tracked data so that
128+
// update may be called when these args change
129+
let [ids] = this.args.positional;
130+
131+
// defer to next (micro)task queue to not block UI
132+
// (and avoid double render bugs because we're about to set tracked data)
133+
await Promise.resolve();
134+
135+
this.isRunning = true;
136+
this.error = undefined;
137+
138+
try {
139+
// some long running stuff here
140+
} catch (e) {
141+
this.error = e
142+
}
143+
144+
this.isRunning = false;
145+
}
146+
}
147+
```
148+
149+
Using your custom Resource would look like
150+
```ts
151+
class ContainingClass {
152+
data = useResource(this, MyResource, () => [this.ids])
153+
}
154+
```
155+
156+
#### `function` Resources
157+
158+
While functions can be "stateless", Resources don't provide much value unless
159+
you can have state. `function` Resources solve this by passing the previous
160+
invocation's return value as an argument to the next time the function is called.
161+
162+
Example:
163+
```ts
164+
class StarWarsInfo {
165+
// access result on info.value
166+
info = useResource(this, async (state, ...args) => {
167+
if (state) {
168+
let { characters } = state;
169+
170+
return { characters };
171+
}
172+
173+
let [ids] = args;
174+
let response = await fetch(`/characters/${ids}`) ;
175+
let characters = await response.json();
176+
177+
return { characters };
178+
}, [this.ids /* defined somewhere */])
179+
}
180+
```
181+
182+
While this example is a bit contrived, hopefully it demonstrates how the `state` arg
183+
works. During the first invocation, `state` is falsey, allowing the rest of the
184+
function to execute. The next time `this.ids` changes, the function will be called
185+
again, except `state` will be the `{ characters }` value during the first invocation,
186+
and the function will return the initial data.
187+
188+
This particular technique could be used to run any async function _safely_ (as long
189+
as the function doesn't interact with `this`).
190+
191+
In this example, where the function is `async`, the "value" of `info.value` is `undefined` until the
192+
function completes.
34193

35194

36195
## Contributing
@@ -41,3 +200,13 @@ See the [Contributing](CONTRIBUTING.md) guide for details.
41200
## License
42201

43202
This project is licensed under the [MIT License](LICENSE.md).
203+
204+
205+
## Thanks
206+
207+
This library wouldn't be possible without the work of:
208+
- [@pzuraq](https://github.com/pzuraq)
209+
- [@josemarluedke](https://github.com/josemarluedke)
210+
211+
So much appreciate for the work both you have put in to Resources <3
212+

addon/-private/ember-concurrency.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function useTask() {
2+
console.debug('coming soon');
3+
}

addon/-private/resource.ts

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { tracked } from '@glimmer/tracking';
2+
import { isDestroyed, isDestroying } from '@ember/destroyable';
3+
import { waitForPromise } from '@ember/test-waiters';
4+
5+
import { LifecycleResource } from './lifecycle';
6+
7+
import type { ArgsWrapper } from '../types';
8+
9+
export const FUNCTION_TO_RUN = Symbol('FUNCTION TO RUN');
10+
11+
// type UnwrapAsync<T> = T extends Promise<infer U> ? U : T;
12+
// type GetReturn<T extends () => unknown> = UnwrapAsync<ReturnType<T>>;
13+
export type ResourceFn<Return = unknown, Args extends unknown[] = unknown[]> = (
14+
previous: Return | undefined,
15+
...args: Args
16+
) => Return | Promise<Return>;
17+
18+
export interface BaseArgs<FnArgs extends unknown[]> extends ArgsWrapper {
19+
positional: FnArgs;
20+
}
21+
22+
export class FunctionRunner<
23+
Return = unknown,
24+
Args extends unknown[] = unknown[],
25+
Fn extends ResourceFn<Return, Args> = ResourceFn<Return, Args>
26+
> extends LifecycleResource<BaseArgs<Args>> {
27+
// Set when using useResource
28+
declare [FUNCTION_TO_RUN]: Fn;
29+
30+
@tracked _asyncValue: Return | undefined;
31+
declare _syncValue: Return | undefined;
32+
33+
get value(): Return | undefined {
34+
return this._asyncValue || this._syncValue;
35+
}
36+
37+
get funArgs() {
38+
return this.args.positional;
39+
}
40+
41+
setup() {
42+
this.update();
43+
}
44+
45+
update() {
46+
/**
47+
* NOTE: All positional args are consumed
48+
*/
49+
let result = this[FUNCTION_TO_RUN](this.value, ...this.funArgs);
50+
51+
if (typeof result === 'object') {
52+
if ('then' in result) {
53+
const recordValue = (value: Return) => {
54+
if (isDestroying(this) || isDestroyed(this)) {
55+
return;
56+
}
57+
58+
this._asyncValue = value;
59+
};
60+
61+
waitForPromise(result);
62+
63+
result.then(recordValue);
64+
65+
return;
66+
}
67+
}
68+
69+
this._syncValue = result;
70+
}
71+
}

0 commit comments

Comments
 (0)