From ea8be9e74f4f5bf3e1900de20f649367997dc8d9 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 7 Aug 2023 17:07:33 -0400 Subject: [PATCH 1/5] trackedFunction is now usable in other resources --- ember-resources/src/util/cell.ts | 1 + ember-resources/src/util/function.ts | 83 +++++++++++++- .../tests/utils/function/rendering-test.gts | 108 +++++++++++++++++- 3 files changed, 187 insertions(+), 5 deletions(-) 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..23b725250 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,102 @@ 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'); + }); + + test('can be composed with the resource use', async function (assert) { + 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); + }) + ); + + 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'); + }); }); From 1a964f16ca6e528c337ad2788d4c870aa699ef32 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 7 Aug 2023 18:44:20 -0400 Subject: [PATCH 2/5] Add changeset and another test --- .changeset/wet-cobras-happen.md | 133 ++++++++++++++++++ .../tests/utils/function/rendering-test.gts | 25 +++- 2 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 .changeset/wet-cobras-happen.md 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/test-app/tests/utils/function/rendering-test.gts b/test-app/tests/utils/function/rendering-test.gts index 23b725250..ef11d6a3a 100644 --- a/test-app/tests/utils/function/rendering-test.gts +++ b/test-app/tests/utils/function/rendering-test.gts @@ -143,9 +143,30 @@ module('Utils | trackedFunction | rendering', function (hooks) { assert.dom('out').hasText('4'); }); - test('can be composed with the resource use', async function (assert) { - type NumberThunk = () => number; + 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(); From 071323e6255905c90107c0f2588402f8d29aa6be Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 7 Aug 2023 19:05:13 -0400 Subject: [PATCH 3/5] Refactor --- ember-resources/package.json | 2 +- ember-resources/src/{util => core}/cell.ts | 4 ++-- ember-resources/src/core/function-based/manager.ts | 2 +- ember-resources/src/core/use.ts | 2 +- ember-resources/src/index.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) rename ember-resources/src/{util => core}/cell.ts (96%) diff --git a/ember-resources/package.json b/ember-resources/package.json index 830b64785..5999c8673 100644 --- a/ember-resources/package.json +++ b/ember-resources/package.json @@ -16,7 +16,7 @@ "./service": "./dist/service.js", "./modifier": "./dist/modifier/index.js", "./util": "./dist/util/index.js", - "./util/cell": "./dist/util/cell.js", + "./util/cell": "./dist/core/cell.js", "./util/keep-latest": "./dist/util/keep-latest.js", "./util/fps": "./dist/util/fps.js", "./util/map": "./dist/util/map.js", diff --git a/ember-resources/src/util/cell.ts b/ember-resources/src/core/cell.ts similarity index 96% rename from ember-resources/src/util/cell.ts rename to ember-resources/src/core/cell.ts index e36f209e6..18990cf6f 100644 --- a/ember-resources/src/util/cell.ts +++ b/ember-resources/src/core/cell.ts @@ -142,9 +142,9 @@ export function cell(initialValue?: Value): Cell { // @ts-ignore import { capabilities as helperCapabilities, setHelperManager } from '@ember/helper'; -import { CURRENT } from '../core/function-based/types'; +import { CURRENT } from './function-based/types'; -import type { GlintRenderable, Reactive } from '../core/function-based/types'; +import type { GlintRenderable, Reactive } from './function-based/types'; class CellManager { capabilities = helperCapabilities('3.23', { diff --git a/ember-resources/src/core/function-based/manager.ts b/ember-resources/src/core/function-based/manager.ts index 80681d533..9c7252c1b 100644 --- a/ember-resources/src/core/function-based/manager.ts +++ b/ember-resources/src/core/function-based/manager.ts @@ -8,7 +8,7 @@ import { invokeHelper } from '@ember/helper'; import { capabilities as helperCapabilities } from '@ember/helper'; import { dependencySatisfies, importSync, macroCondition } from '@embroider/macros'; -import { ReadonlyCell } from '../../util/cell'; +import { ReadonlyCell } from '../cell'; import { CURRENT, INTERNAL } from './types'; import type { diff --git a/ember-resources/src/core/use.ts b/ember-resources/src/core/use.ts index 397e6929b..6cb77498f 100644 --- a/ember-resources/src/core/use.ts +++ b/ember-resources/src/core/use.ts @@ -8,7 +8,7 @@ import { associateDestroyableChild } from '@ember/destroyable'; // @ts-ignore import { invokeHelper } from '@ember/helper'; -import { ReadonlyCell } from '../util/cell'; +import { ReadonlyCell } from './cell'; import { INTERNAL } from './function-based/types'; import { normalizeThunk } from './utils'; diff --git a/ember-resources/src/index.ts b/ember-resources/src/index.ts index ef90a965e..a256f187d 100644 --- a/ember-resources/src/index.ts +++ b/ember-resources/src/index.ts @@ -4,7 +4,7 @@ export { resource, resourceFactory } from './core/function-based'; export { use } from './core/use'; // Public API -- Utilities -export { cell } from './util/cell'; +export { cell } from './core/cell'; // Public Type Utilities export type { ResourceAPI } from './core/function-based'; From 7299e6562bcafecbdab67a75fab6761d5038dbb7 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 7 Aug 2023 20:22:26 -0400 Subject: [PATCH 4/5] Update ci --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) 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 From 0958ae1660604292d93845ca703df63e5850fb62 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 7 Aug 2023 20:31:13 -0400 Subject: [PATCH 5/5] Revert "Refactor" This reverts commit 071323e6255905c90107c0f2588402f8d29aa6be. --- ember-resources/package.json | 2 +- ember-resources/src/core/function-based/manager.ts | 2 +- ember-resources/src/core/use.ts | 2 +- ember-resources/src/index.ts | 2 +- ember-resources/src/{core => util}/cell.ts | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) rename ember-resources/src/{core => util}/cell.ts (96%) diff --git a/ember-resources/package.json b/ember-resources/package.json index 5999c8673..830b64785 100644 --- a/ember-resources/package.json +++ b/ember-resources/package.json @@ -16,7 +16,7 @@ "./service": "./dist/service.js", "./modifier": "./dist/modifier/index.js", "./util": "./dist/util/index.js", - "./util/cell": "./dist/core/cell.js", + "./util/cell": "./dist/util/cell.js", "./util/keep-latest": "./dist/util/keep-latest.js", "./util/fps": "./dist/util/fps.js", "./util/map": "./dist/util/map.js", diff --git a/ember-resources/src/core/function-based/manager.ts b/ember-resources/src/core/function-based/manager.ts index 9c7252c1b..80681d533 100644 --- a/ember-resources/src/core/function-based/manager.ts +++ b/ember-resources/src/core/function-based/manager.ts @@ -8,7 +8,7 @@ import { invokeHelper } from '@ember/helper'; import { capabilities as helperCapabilities } from '@ember/helper'; import { dependencySatisfies, importSync, macroCondition } from '@embroider/macros'; -import { ReadonlyCell } from '../cell'; +import { ReadonlyCell } from '../../util/cell'; import { CURRENT, INTERNAL } from './types'; import type { diff --git a/ember-resources/src/core/use.ts b/ember-resources/src/core/use.ts index 6cb77498f..397e6929b 100644 --- a/ember-resources/src/core/use.ts +++ b/ember-resources/src/core/use.ts @@ -8,7 +8,7 @@ import { associateDestroyableChild } from '@ember/destroyable'; // @ts-ignore import { invokeHelper } from '@ember/helper'; -import { ReadonlyCell } from './cell'; +import { ReadonlyCell } from '../util/cell'; import { INTERNAL } from './function-based/types'; import { normalizeThunk } from './utils'; diff --git a/ember-resources/src/index.ts b/ember-resources/src/index.ts index a256f187d..ef90a965e 100644 --- a/ember-resources/src/index.ts +++ b/ember-resources/src/index.ts @@ -4,7 +4,7 @@ export { resource, resourceFactory } from './core/function-based'; export { use } from './core/use'; // Public API -- Utilities -export { cell } from './core/cell'; +export { cell } from './util/cell'; // Public Type Utilities export type { ResourceAPI } from './core/function-based'; diff --git a/ember-resources/src/core/cell.ts b/ember-resources/src/util/cell.ts similarity index 96% rename from ember-resources/src/core/cell.ts rename to ember-resources/src/util/cell.ts index 18990cf6f..e36f209e6 100644 --- a/ember-resources/src/core/cell.ts +++ b/ember-resources/src/util/cell.ts @@ -142,9 +142,9 @@ export function cell(initialValue?: Value): Cell { // @ts-ignore import { capabilities as helperCapabilities, setHelperManager } from '@ember/helper'; -import { CURRENT } from './function-based/types'; +import { CURRENT } from '../core/function-based/types'; -import type { GlintRenderable, Reactive } from './function-based/types'; +import type { GlintRenderable, Reactive } from '../core/function-based/types'; class CellManager { capabilities = helperCapabilities('3.23', {