Skip to content

Commit

Permalink
Merge pull request #975 from NullVoxPopuli/trackedFunction-usable
Browse files Browse the repository at this point in the history
Tracked function usable
  • Loading branch information
NullVoxPopuli authored Aug 8, 2023
2 parents 8141f05 + 0958ae1 commit 56f49b9
Show file tree
Hide file tree
Showing 5 changed files with 342 additions and 5 deletions.
133 changes: 133 additions & 0 deletions .changeset/wet-cobras-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---
"ember-resources": minor
---

`trackedFunction` can now be composed just like regular resources.

The function body still auto-tracks and will update within consuming resource appropriately.

<details><summary>Example</summary>

```ts
const Person = resourceFactory((maybeIdFn) => {
return resource(({ use }) => {
let request = use(
trackedFunction(async () => {
let id = typeof maybeIdFn === "function" ? maybeIdFn() : maybeIdFn;
let response = await fetch(`https://github.com/gitapi/users/${id}`);
return response.json();
})
);

// `use` returns a ReadonlyCell where `.current`
// is the State of trackedFunction.
return () => request.current;
});
});
```

Usage examples:

```gjs
<template>
{{#let (Person 1) as |request|}}
{{#if request.isLoading}}
... loading ...
{{/if}}
{{#if request.value}}
{{/if}}
{{/let}}
</template>
```

</details>

<details><summary>An async doubler</summary>

```ts
const Doubled = resourceFactory((num: number) => {
return resource(({ use }) => {
let doubler = use(trackedFunction(async () => num * 2));

// Since current is the "State" of `trackedFunction`,
// accessing .value on it means that the overall value of
// `Doubled` is the eventual return value of the `trackedFunction`
return () => doubler.current.value;
});
});

// Actual code from a test
class State {
@tracked num = 2;
}

let state = new State();

setOwner(state, this.owner);

await render(<template><out>{{Doubled state.num}}</out></template>);
```

</details>

<details><summary>Example with arguments</summary>

Imagine you want to compute the hypotenuse of a triangle,
but all calculations are asynchronous (maybe the measurements exist on external APIs or something).

```ts
// Actual code from a test
type NumberThunk = () => number;

const Sqrt = resourceFactory((numFn: NumberThunk) =>
trackedFunction(async () => {
let num = numFn();

return Math.sqrt(num);
})
);

const Squared = resourceFactory((numFn: NumberThunk) =>
trackedFunction(async () => {
let num = numFn();

return Math.pow(num, 2);
})
);

const Hypotenuse = resourceFactory((aFn: NumberThunk, bFn: NumberThunk) => {
return resource(({ use }) => {
const aSquared = use(Squared(aFn));
const bSquared = use(Squared(bFn));
const c = use(
Sqrt(() => {
return (aSquared.current.value ?? 0) + (bSquared.current.value ?? 0);
})
);

// We use the function return because we want this property chain
// to be what's lazily evaluated -- in this example, since
// we want to return the hypotenuse, we don't (atm)
// care about loading / error state, etc.
// In real apps, you might care about loading state though!
return () => c.current.value;

// In situations where you care about forwarding other states,
// you could do this
return {
get value() {
return c.current.value;
},
get isLoading() {
return (
a.current.isLoading || b.current.isLoading || c.current.isLoading
);
},
};
});
});
```

</details>
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ jobs:
with:
no-lockfile: true
- uses: ./.github/actions/download-built-package
- run: pnpm i -f
- run: pnpm --filter test-app test:ember


Expand Down
1 change: 1 addition & 0 deletions ember-resources/src/util/cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,4 @@ class CellManager {
const cellEvaluator = new CellManager();

setHelperManager(() => cellEvaluator, Cell.prototype);
setHelperManager(() => cellEvaluator, ReadonlyCell.prototype);
83 changes: 82 additions & 1 deletion ember-resources/src/util/function.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,61 @@
import { tracked } from '@glimmer/tracking';
import { assert } from '@ember/debug';
import { associateDestroyableChild, destroy, isDestroyed, isDestroying } from '@ember/destroyable';

import { TrackedAsyncData } from 'ember-async-data';

import { resource } from '../core/function-based';

/**
* <div class="callout note">
*
* This is not a core part of ember-resources, but is an example utility to demonstrate a concept when authoring your own resources. However, this utility is still under the broader library's SemVer policy.
*
* A consuming app will not pay for the bytes of this utility unless imported.
*
* </div>
*
* _An example utility that uses resource_
*
* Any tracked data accessed in a tracked function _before_ an `await`
* will "entangle" with the function -- we can call these accessed tracked
* properties, the "tracked prelude". If any properties within the tracked
* payload change, the function will re-run.
*
* ```js
* import Component from '@glimmer/component';
* import { tracked } from '@glimmer/tracking';
* import { resourceFactory, use } from 'ember-resources';
* import { trackedFunction } from 'ember-resources/util/function';
*
* const Request = resourceFactory((idFn) => {
* return trackedFunction(this, async () => {
* let id = idFn();
* let response = await fetch(`https://swapi.dev/api/people/${id}`);
* let data = await response.json();
*
* return data; // { name: 'Luke Skywalker', ... }
* });
* });
*
* class Demo extends Component {
* @tracked id = 1;
*
* updateId = (event) => this.id = event.target.value;
*
* request = use(this, Request(() => this.id));
*
* // Renders "Luke Skywalker"
* <template>
* {{this.request.current.value.name}}
*
* <input value={{this.id}} {{on 'input' this.updateId}}>
* </template>
* }
* ```
*/
export function trackedFunction<Return>(fn: () => Return): State<Return>;

/**
* <div class="callout note">
*
Expand Down Expand Up @@ -56,7 +107,37 @@ import { resource } from '../core/function-based';
* @param {Object} context destroyable parent, e.g.: component instance aka "this"
* @param {Function} fn the function to run with the return value available on .value
*/
export function trackedFunction<Return>(context: object, fn: () => Return) {
export function trackedFunction<Return>(context: object, fn: () => Return): State<Return>;

export function trackedFunction<Return>(
...args: Parameters<typeof directTrackedFunction<Return>> | Parameters<typeof classUsable<Return>>
): State<Return> {
if (args.length === 1) {
return classUsable(...args);
}

if (args.length === 2) {
return directTrackedFunction(...args);
}

assert('Unknown arity: trackedFunction must be called with 1 or 2 arguments');
}

function classUsable<Return>(fn: () => Return) {
const state = new State(fn);

let destroyable = resource<State<Return>>(() => {
state.retry();

return state;
});

associateDestroyableChild(destroyable, state);

return destroyable;
}

function directTrackedFunction<Return>(context: object, fn: () => Return) {
const state = new State(fn);

let destroyable = resource<State<Return>>(context, () => {
Expand Down
Loading

0 comments on commit 56f49b9

Please sign in to comment.