diff --git a/.changeset/wet-cobras-happen.md b/.changeset/wet-cobras-happen.md new file mode 100644 index 000000000..b472480e8 --- /dev/null +++ b/.changeset/wet-cobras-happen.md @@ -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. + +
Example + +```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 + +``` + +
+ +
An async doubler + +```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(); +``` + +
+ +
Example with arguments + +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 + ); + }, + }; + }); +}); +``` + +
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db42042c9..b770ea356 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/ember-resources/src/util/cell.ts b/ember-resources/src/util/cell.ts index b3fc09269..e36f209e6 100644 --- a/ember-resources/src/util/cell.ts +++ b/ember-resources/src/util/cell.ts @@ -163,3 +163,4 @@ class CellManager { const cellEvaluator = new CellManager(); setHelperManager(() => cellEvaluator, Cell.prototype); +setHelperManager(() => cellEvaluator, ReadonlyCell.prototype); diff --git a/ember-resources/src/util/function.ts b/ember-resources/src/util/function.ts index 70b9681d3..4947fc189 100644 --- a/ember-resources/src/util/function.ts +++ b/ember-resources/src/util/function.ts @@ -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'; +/** + *
+ * + * 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. + * + *
+ * + * _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" + * + * } + * ``` + */ +export function trackedFunction(fn: () => Return): State; + /** *
* @@ -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(context: object, fn: () => Return) { +export function trackedFunction(context: object, fn: () => Return): State; + +export function trackedFunction( + ...args: Parameters> | Parameters> +): State { + 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(fn: () => Return) { + const state = new State(fn); + + let destroyable = resource>(() => { + state.retry(); + + return state; + }); + + associateDestroyableChild(destroyable, state); + + return destroyable; +} + +function directTrackedFunction(context: object, fn: () => Return) { const state = new State(fn); let destroyable = resource>(context, () => { diff --git a/test-app/tests/utils/function/rendering-test.gts b/test-app/tests/utils/function/rendering-test.gts index 9b759b191..ef11d6a3a 100644 --- a/test-app/tests/utils/function/rendering-test.gts +++ b/test-app/tests/utils/function/rendering-test.gts @@ -5,10 +5,12 @@ import { click, render, settled } from '@ember/test-helpers'; import { on } from '@ember/modifier'; import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; +import { setOwner } from '@ember/application'; +import { use, resource, resourceFactory } from 'ember-resources'; import { trackedFunction } from 'ember-resources/util/function'; -const timeout = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +const timeout = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); module('Utils | trackedFunction | rendering', function (hooks) { setupRenderingTest(hooks); @@ -24,7 +26,7 @@ module('Utils | trackedFunction | rendering', function (hooks) { } @@ -55,7 +57,7 @@ module('Utils | trackedFunction | rendering', function (hooks) { } @@ -89,7 +91,7 @@ module('Utils | trackedFunction | rendering', function (hooks) { } @@ -106,4 +108,123 @@ module('Utils | trackedFunction | rendering', function (hooks) { assert.dom('out').hasText('4'); }); + + test('can be "use"d in a class', async function (assert) { + const Doubler = resourceFactory((numFn) => + trackedFunction(async () => { + let num = numFn(); + + return num * 2; + }) + ); + + class TestComponent extends Component { + @tracked multiplier = 1; + + increment = () => this.multiplier++; + + data = use( + this, + Doubler(() => this.multiplier) + ); + + + } + + await render(); + + assert.dom('out').hasText('2'); + + await click('button'); + + assert.dom('out').hasText('4'); + }); + + type NumberThunk = () => number; + test('can be composed directly within a resource', async function (assert) { + const Doubled = resourceFactory((num: number) => { + return resource(({ use }) => { + let state = use(trackedFunction(() => num * 2)); + + return () => state.current.value; + }); + }); + + class State { + @tracked num = 2; + } + + let state = new State(); + + setOwner(state, this.owner); + + await render(); + + assert.dom('out').hasText('4'); + }); + + test('can be composed with the resource use', async function (assert) { + 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); + }) + ); + + return () => c.current.value; + }); + }); + + class State { + @tracked a = 3; + @tracked b = 4; + + c = use( + this, + Hypotenuse( + () => this.a, + () => this.b + ) + ); + } + + let state = new State(); + + setOwner(state, this.owner); + + await render(); + + assert.dom('out').hasText('5'); + + state.a = 7; + await settled(); + + assert.dom('out').containsText('8.06'); + + state.b = 10; + await settled(); + assert.dom('out').containsText('12.206'); + }); });