diff --git a/DOCS.md b/DOCS.md new file mode 100644 index 000000000..57349e41e --- /dev/null +++ b/DOCS.md @@ -0,0 +1,225 @@ +# Authoring Resources + +In this document, you'll learn about the core features of `ember-resources` +and how to decide which primitives to use, how to create, support, compose, and test with them. + +- [the primitives](#the-primitives) + - [function-based Resources](#function-based-resources) + - [Example: Clock](#example-clock) + - [class-based Resources](#class-based-resources) + - [Example: Clock](#example-class-based-clock) + +## the primitives + +There are two core abstractions to working with resources, +each with their own set of tradeoffs and capabilities +-- but ultimately are both summarized as "helpers with optional state and optional cleanup". + +| | class-based [`Resource`][docs-class-resource] | function-based [`resource`][docs-function-resource] | +| -- | ---------------------- | ------------------------- | +| supports direct invocation in [``][rfc-779] | yes | yes | +| supports [Glint][gh-glint] | soon | soon | +| provides a value | the instance of the class is the value[^1] | can represent a primitive value or complex object[^2] | +| can be invoked with arguments | yes, received via `modify`[^3] hook | only when wrapped with a function. changes to arguments will cause the resource to teardown and re-run | +| persisted state across argument changes | yes | no, but it's possible[^4] | +| can be used in the body of a class component | yes | yes | +| can be used in template-only components | yes[^5] | yes[^5] | +| requires decorator usage (`@use`) | `@use` optional | `@use` optional[^6] | + + +[rfc-779]: https://github.com/emberjs/rfcs/pull/779 +[gh-glint]: https://github.com/typed-ember/glint +[gh-ember-modifier]: https://github.com/ember-modifier/ember-modifier + +[docs-class-resource]: https://ember-resources.pages.dev/classes/core.Resource +[docs-function-resource]: https://ember-resources.pages.dev/modules/util_function_resource#resource + +[mdn-weakmap]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap + +[^1]: class-based resources _cannot_ be a single primitive value. APIs for support for this have been explored in the past, but it proved unergonomic and fine-grained reactivity _per accessed property_ (when an object was desired for "the value") was not possible. +[^2]: there are alternate ways to shape a function-resource depending on the behavior you want. These shapes and use cases are covered in the [function-based Resources](#function-based-resources). +[^3]: this is the same API / behavior as class-based modifiers in [ember-modifier][gh-ember-modifier]. +[^4]: persisting state across argument changes with function-based resources might require a [`WeakMap`][mdn-weakmap] and some stable object to reference as the key for the storage within that `WeakMap`. +[^5]: for `.hbs` files the resources will need to be globally available via `export default`s from the `app/helpers` directory. +[^6]: without `@use`, the function-based resource must represent a non-primitive value or object. + +### function-based resources + +[🔝 back to top](#authoring-resources) + +Function resources are good for both authoring encapsulated behaviors, +as well as inline / "on-demand" usage. + + +#### Example: Clock + +[🔝 back to top](#authoring-resources) + +Throughout these examples, we'll implement a locale-aware clock +and go over the tradeoffs / behavior differences between +each of the implementations and usages (from the consuming side). + +The goal if this implementation is to provide an easy abstraction that +"some consumer" could use to display the current time in their given locale. + +To start, we'll want to use [`setInterval`][mdn-setInterval] to update a value every second. +```js +// NOTE: this snippet has bugs and is incomplete, don't copy this (explained later) +// import { resource } from 'ember-resources'; // in V5 +import { resource } from 'ember-resources/util/function-resource'; +import { TrackedObject } from 'tracked-built-ins'; + +const clock = resource(() => { + let time = new TrackedObject({ current: new Date() }); + + setInterval(() => (time.current = new Date()), 1_000); + + return time.current; +}); +``` + +Usage of this resource would look like +```hbs + +``` + +But this is not feature-complete! We still need to handle cleanup to prevent memory leaks by using [`clearInterval`][mdn-clearInterval]. + +```diff +- const clock = resource(() => { ++ const clock = resource(({ on }) => { + let time = new TrackedObject({ current: new Date() }); + +- setInterval(() => (time.current = new Date()), 1_000); ++ let interval = setInterval(() => (time.current = new Date()), 1_000); ++ ++ on.cleanup(() => clearInteral(interval)) + + return time.current; +``` + +Now when the `resource` updates or is torn down, won't leave a bunch of `setInterval`s running. + +Lastly, adding in locale-aware formatting with [`Intl.DateTimeFormat`][mdn-DateTimeFormat]. +```diff + on.cleanup(() => clearInteral(interval)) + +- return time.current; ++ return new Intl.DateTimeFormat('en-US', { ++ hour: 'numeric', ++ minute: 'numeric', ++ second: 'numeric', ++ hour12: false, ++ }).format(time.current); +``` + + +However, there is a goofy behavior with this implementation. +By accessing `time.current`, we end up consuming tracaked data within the `resource` +callback function. When `setInterval` updates `time.current`, the reactivity system +detects that "tracked data that was consumed in the `resource` callback has changed, +and must re-evaluate". +This causes a _new_ `setInterval` and _new_ `TrackedObject` to be used, +rather than re-using the objects. + +To solve this, we need to enclose access to the tracked data via an arrow function. +```js +const clock = resource(({ on }) => { + let time = new TrackedObject({ current: new Date() }); + let interval = setInterval(() => (time.current = new Date()), 1_000); + + on.cleanup(() => clearInteral(interval)) + + let formatter = new Intl.DateTimeFormat('en-US', { /* ... ✂️ ...*/ }); + + return () => formatter.format(time.current); +}); +``` + +In this resource, consumed tracked data, when changed, only invalidates the enclosing function. + +Lastly, to support reactively changing the locale, we need to wrap the `resource` in a function. +```js +// import { resource, resourceFactory } from 'ember-resources'; // in V5 +import { resource, resourceFactory } from 'ember-resources/util/function-resource'; + +const Clock = resourceFactory((locale = 'en-US') => { + return resource(({ on }) => { + let time = new TrackedObject({ current: new Date() }); + let interval = setInterval(() => (time.current = new Date()), 1_000); + + on.cleanup(() => clearInteral(interval)) + + let formatter = new Intl.DateTimeFormat(locale, { /* ... ✂️ ...*/ }); + + return () => formatter.format(time.current); + }); +}); +``` + +Up until now, all we've needed in the template for these clocks to work is to have `{{clock}}` in our template. +But becasue we now need to pass data to a function, we need to invoke that function. The `resourceFactory` utility handles some framework-wiring so that the `Clock` function can immediately invoke the `resource` function. + +```hbs +{{ (Clock 'en-GB') }} +``` + + +[mdn-setInterval]: https://developer.mozilla.org/en-US/docs/Web/API/setInterval +[mdn-clearInterval]: https://developer.mozilla.org/en-US/docs/Web/API/clearInterval +[mdn-DateTimeFormat]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat + +### class-based resources + +[🔝 back to top](#authoring-resources) + +Class-based resources are good for object-oriented encapsulation of state, +giving access to the application container / owner for service injection, +and/or persistint state across argument changes. + + +Though, maybe a more pragmatic approach to the difference: + +_Class-based resources can be invoked with args_. +Function-based resources must be wrapped in another function to accept args. + +#### Example: class-based Clock + +Given the complete example of a `clock` above implemented in a function-based resource, +A complete implementation, as a class-based resource could look similar to this: + +```js +// import { Resource } from 'ember-resources'; // in V5 +import { Resource } from 'ember-resources/core' +import { tracked } from '@glimmer/tracking'; +import { registerDestructor } from '@ember/destroyable'; + +class Clock extends Resource { + @tracked current = new Date(); + + constructor(owner) { + super(owner); + + let interval = setInterval(() => (this.current = new Date()), 1_000); + + registerDestructor(this, () => clearInterval(interval)); + } + + get formatted() { + return this.formatter.format(this.current); + } + + modify([locale = 'en-US']) { + this.formatter = new Intl.DateTimeFormat(locale, { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hour12: false, + }); + } +} +``` + + + + diff --git a/ember-resources/src/util/function-resource.ts b/ember-resources/src/util/function-resource.ts index 6b38016bc..cea390fc1 100644 --- a/ember-resources/src/util/function-resource.ts +++ b/ember-resources/src/util/function-resource.ts @@ -150,7 +150,7 @@ export function resource(context: object, setup: ResourceFunction) export function resource( context: object | ResourceFunction, setup?: ResourceFunction -): Value | InternalIntermediate | ResourceFunction { +): Value | InternalIntermediate | ResourceFn { if (!setup) { assert( `When using \`resource\` with @use, ` + @@ -175,7 +175,7 @@ export function resource( */ (context as any)[INTERNAL] = true; - return context as ResourceFunction; + return context as ResourceFn; } assert( @@ -226,9 +226,9 @@ function wrapForPlainUsage(context: object, setup: ResourceFunction void; }; }; + +/** + * Type of the callback passed to `resource` + */ +type ResourceFunction = (hooks: Hooks) => Value | (() => Value); + +/** + * The perceived return value of `resource` + * This is a lie to TypeScript, because the effective value of + * of the resource is the result of the collapsed functions + * passed to `resource` + */ +type ResourceFn = (hooks: Hooks) => Value; + type Destructor = () => void; -type ResourceFunction = (hooks: Hooks) => Value; type Cache = object; /** @@ -308,35 +321,51 @@ class FunctionResourceManager { * However, they can access tracked data */ createHelper(fn: ResourceFunction) { + /** + * We have to copy the `fn` in case there are multiple + * usages or invocations of the function. + * + * This copy is what we'll ultimately work with and eventually + * destroy. + */ let thisFn = fn.bind(null); + let previousFn: object; - associateDestroyableChild(fn, thisFn); + let cache = createCache(() => { + if (previousFn) { + destroy(previousFn); + } - return thisFn; - } + let currentFn = thisFn.bind(null); - previousFn?: object; + associateDestroyableChild(thisFn, currentFn); + previousFn = currentFn; - getValue(fn: ResourceFunction) { - if (this.previousFn) { - destroy(this.previousFn); - } + let maybeValue = currentFn({ + on: { + cleanup: (destroyer: Destructor) => { + registerDestructor(currentFn, destroyer); + }, + }, + }); - let currentFn = fn.bind(null); + return maybeValue; + }); - associateDestroyableChild(fn, currentFn); - this.previousFn = currentFn; + return { fn: thisFn, cache }; + } - return currentFn({ - on: { - cleanup: (destroyer: Destructor) => { - registerDestructor(currentFn, destroyer); - }, - }, - }); + getValue({ cache }: { cache: Cache }) { + let maybeValue = getValue(cache); + + if (typeof maybeValue === 'function') { + return maybeValue(); + } + + return maybeValue; } - getDestroyable(fn: ResourceFunction) { + getDestroyable({ fn }: { fn: ResourceFunction }) { return fn; } } @@ -497,13 +526,13 @@ export function use(_prototype: object, key: string, descriptor?: Descriptor): v let fn = initializer.call(this); assert( - `Expected initialized value under @use to have used the resource wrapper function`, + `Expected initialized value under @use to have used the \`resource\` wrapper function`, isResourceInitializer(fn) ); cache = invokeHelper(this, fn); - caches.set(this as object, cache); + associateDestroyableChild(this, cache); } return getValue(cache); @@ -513,7 +542,7 @@ export function use(_prototype: object, key: string, descriptor?: Descriptor): v type ResourceInitializer = { [INTERNAL]: true; -}; +} & ResourceFunction; function isResourceInitializer(obj: unknown): obj is ResourceInitializer { return typeof obj === 'function' && obj !== null && INTERNAL in obj; diff --git a/testing/ember-app/tests/utils/function-resource/clock-test.ts b/testing/ember-app/tests/utils/function-resource/clock-test.ts new file mode 100644 index 000000000..9dc49927c --- /dev/null +++ b/testing/ember-app/tests/utils/function-resource/clock-test.ts @@ -0,0 +1,182 @@ +import { tracked } from '@glimmer/tracking'; +import { destroy } from '@ember/destroyable'; +import { clearRender, find, render, settled } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { module, test } from 'qunit'; +import { setupRenderingTest, setupTest } from 'ember-qunit'; + +import { dependencySatisfies, macroCondition } from '@embroider/macros'; +import { resource, resourceFactory, use } from 'ember-resources/util/function-resource'; +import { TrackedObject } from 'tracked-built-ins'; + +module('Examples | resource | Clock', function (hooks) { + let wait = (ms = 1_100) => new Promise((resolve) => setTimeout(resolve, ms)); + + hooks.beforeEach(function (assert) { + // timeout is too new for the types to know about + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + assert.timeout(3000); + }); + + // Wrapper functions are the only way to pass Args to a resource. + const Clock = resourceFactory(({ start, locale = 'en-US' }) => { + // For a persistent state across arg changes, `Resource` may be better` + let time = new TrackedObject({ current: start }); + let formatter = new Intl.DateTimeFormat(locale, { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hour12: false, + }); + + return resource(({ on }) => { + let interval = setInterval(() => { + time.current = new Date(); + }, 1000); + + on.cleanup(() => clearInterval(interval)); + + return () => formatter.format(time.current); + }); + }); + + module('js', function (hooks) { + setupTest(hooks); + + test('works with @use', async function (assert) { + class Test { + @tracked locale = 'en-US'; + + @use now = Clock(() => ({ locale: this.locale })); + } + + let foo = new Test(); + + let timeA = foo.now; + + await wait(); + + let timeB = foo.now; + + assert.notStrictEqual(timeA, timeB, `${timeB} is 1s after ${timeA}`); + + destroy(foo); + await settled(); + await wait(); + + let timeLast = foo.now; + + assert.strictEqual(timeB, timeLast, 'after stopping the clock, time is frozen'); + }); + }); + + module('rendering', function (hooks) { + setupRenderingTest(hooks); + + test('a clock can keep time', async function (assert) { + let steps: string[] = []; + let step = (msg: string) => { + steps.push(msg); + assert.step(msg); + }; + + const clock = resource(({ on }) => { + let time = new TrackedObject({ current: new Date() }); + let interval = setInterval(() => { + time.current = new Date(); + }, 1000); + + step(`setup ${interval}`); + + on.cleanup(() => { + step(`cleanup ${interval}`); + clearInterval(interval); + }); + + let formatter = new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hour12: false, + }); + + return () => formatter.format(time.current); + }); + + this.setProperties({ clock }); + + await render(hbs` + + `); + + let textA = find('time')?.innerText; + + assert.ok(textA, textA); + + await wait(); + + let textB = find('time')?.innerText; + + assert.ok(textB, textB); + assert.notStrictEqual(textA, textB, `${textB} is 1s after ${textA}`); + + await wait(); + + let textC = find('time')?.innerText; + + assert.ok(textC, textC); + assert.notStrictEqual(textB, textC, `${textC} is 1s after ${textB}`); + + await clearRender(); + + assert.verifySteps(steps); + assert.strictEqual(steps.length, 2, 'no extra setup/cleanup occurs'); + }); + + test('acceps arguments', async function (assert) { + this.setProperties({ Clock, date: new Date(), locale: 'en-US' }); + + /** + * Older ember had a bug where nested helpers were not invoked + * when using a dynamic helper (this.Clock) + */ + if (macroCondition(dependencySatisfies('ember-source', '~3.25.0 || ~3.26.0'))) { + await render(hbs` + + `); + } else { + await render(hbs` + + `); + } + + let textA = find('time')?.innerText; + + assert.ok(textA, textA); + + await wait(); + + let textB = find('time')?.innerText; + + assert.ok(textB, textB); + assert.notStrictEqual(textA, textB, `${textB} is 1s after ${textA}`); + + await wait(); + + let textC = find('time')?.innerText; + + assert.ok(textC, textC); + assert.notStrictEqual(textB, textC, `${textC} is 1s after ${textB}`); + + this.setProperties({ locale: 'en-CA' }); + await settled(); + + assert.strictEqual(textA, find('time')?.innerText, 'Time is reset'); + }); + }); +}); diff --git a/testing/ember-app/tests/utils/function-resource/js-test.ts b/testing/ember-app/tests/utils/function-resource/js-test.ts index 3eb4e3fe3..3cf11e62e 100644 --- a/testing/ember-app/tests/utils/function-resource/js-test.ts +++ b/testing/ember-app/tests/utils/function-resource/js-test.ts @@ -109,11 +109,83 @@ module('Utils | resource | js', function (hooks) { 'resource resolved 3 times' ); assert.strictEqual( - steps.filter((s) => s.includes('resolve')).length, + steps.filter((s) => s.includes('destroy')).length, 3, 'resource destroyed 3 times' ); }); + + test('with separate tracking frame', async function (assert) { + class Test { + constructor(private assert: QUnit['assert']) {} + // reminder that destruction is async + steps: string[] = []; + step = (msg: string) => { + this.steps.push(msg); + this.assert.step(msg); + }; + + @tracked outerCount = 1; + @tracked count = 1; + + // @use is required if a primitive is returned + @use data = resource(({ on }) => { + let outerCount = this.outerCount; + + on.cleanup(() => this.step(`destroy ${outerCount}`)); + + this.step(`setup ${outerCount}`); + + return () => { + this.step(`resolved ${outerCount}: ${this.count}`); + + return this.count; + }; + }); + } + + let foo = new Test(assert); + + assert.strictEqual(foo.data, 1); + + foo.count = 2; + await settled(); + + foo.outerCount++; + await settled(); + + assert.strictEqual(foo.data, 2); + + foo.count = 3; + await settled(); + + assert.strictEqual(foo.data, 3); + await settled(); + + destroy(foo); + await settled(); + + let steps = foo.steps; + + assert.verifySteps(steps); + + assert.strictEqual(steps.length, 7); + assert.strictEqual( + steps.filter((s) => s.includes('setup')).length, + 2, + 'resource setup 2 times' + ); + assert.strictEqual( + steps.filter((s) => s.includes('destroy')).length, + 2, + 'resource destroyed 3 times' + ); + assert.strictEqual( + steps.filter((s) => s.includes('resolve')).length, + 3, + 'resource resolved 3 times' + ); + }); }); module('with no teardown', function () { diff --git a/testing/ember-app/tests/utils/function-resource/rendering-test.ts b/testing/ember-app/tests/utils/function-resource/rendering-test.ts index f8df1c5c7..e9c7a58c8 100644 --- a/testing/ember-app/tests/utils/function-resource/rendering-test.ts +++ b/testing/ember-app/tests/utils/function-resource/rendering-test.ts @@ -1,13 +1,11 @@ import { tracked } from '@glimmer/tracking'; import { destroy } from '@ember/destroyable'; -import { clearRender, find, render, settled } from '@ember/test-helpers'; +import { clearRender, render, settled } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { dependencySatisfies, macroCondition } from '@embroider/macros'; -import { resource, resourceFactory, use } from 'ember-resources/util/function-resource'; -import { TrackedObject } from 'tracked-built-ins'; +import { resource, use } from 'ember-resources/util/function-resource'; module('Utils | resource | rendering', function (hooks) { setupRenderingTest(hooks); @@ -64,6 +62,55 @@ module('Utils | resource | rendering', function (hooks) { assert.verifySteps(steps); }); + test('with separate tracking frame', async function (assert) { + class Test { + @tracked num = 0; + } + + let foo = new Test(); + // reminder that destruction is async + let steps: string[] = []; + let step = (msg: string) => { + steps.push(msg); + assert.step(msg); + }; + + this.setProperties({ + theResource: resource(({ on }) => { + on.cleanup(() => step(`destroy`)); + + step(`setup`); + + return () => { + step(`computing ${foo.num}`); + + return foo.num; + }; + }), + }); + + await render(hbs`{{this.theResource}}`); + + assert.dom('out').containsText('0'); + + foo.num = 2; + await settled(); + + assert.dom('out').containsText('2'); + + foo.num = 7; + await settled(); + + assert.dom('out').containsText('7'); + + await clearRender(); + destroy(foo); + await settled(); + + assert.verifySteps(steps); + assert.strictEqual(steps.length, 5, 'setup + computing 3 times + destroy'); + }); + test('when gated by an if', async function (assert) { class Test { @tracked show = true; @@ -386,56 +433,7 @@ module('Utils | resource | rendering', function (hooks) { }); }); - module('persistent state', function () { - test('a clock can keep time', async function (assert) { - // timeout is too new for the types to know about - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - assert.timeout(3000); - - const clock = resource(({ on }) => { - let time = new TrackedObject({ current: new Date() }); - let interval = setInterval(() => { - time.current = new Date(); - }, 1000); - - on.cleanup(() => clearInterval(interval)); - - let now = time.current; - - return new Intl.DateTimeFormat('en-US', { - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - hour12: false, - }).format(now); - }); - - this.setProperties({ clock }); - - await render(hbs` - - `); - - let textA = find('time')?.innerText; - - assert.ok(textA, textA); - - await new Promise((resolve) => setTimeout(resolve, 1100)); - - let textB = find('time')?.innerText; - - assert.ok(textB, textB); - assert.notStrictEqual(textA, textB, `${textB} is 1s after ${textA}`); - - await new Promise((resolve) => setTimeout(resolve, 1100)); - - let textC = find('time')?.innerText; - - assert.ok(textC, textC); - assert.notStrictEqual(textB, textC, `${textC} is 1s after ${textB}`); - }); - }); + module('persistent state', function () {}); module('with a wrapper', function () { test('lifecycle', async function (assert) { @@ -489,78 +487,5 @@ module('Utils | resource | rendering', function (hooks) { 'destroy 7', ]); }); - - module('persistent state', function () { - test('a Clock can keep time', async function (assert) { - // timeout is too new for the types to know about - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - assert.timeout(3000); - - // Wrapper functions are the only way to pass Args to a resource. - const Clock = resourceFactory(({ start, locale = 'en-US' }) => { - // For a persistent state across arg changes, `Resource` may be better` - let time = new TrackedObject({ current: start }); - - return resource(({ on }) => { - let interval = setInterval(() => { - time.current = new Date(); - }, 1000); - - on.cleanup(() => clearInterval(interval)); - - return new Intl.DateTimeFormat(locale, { - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - hour12: false, - }).format(time.current); - }); - }); - - this.setProperties({ Clock, date: new Date(), locale: 'en-US' }); - - /** - * Older ember had a bug where nested helpers were not invoked - * when using a dynamic helper (this.Clock) - */ - if (macroCondition(dependencySatisfies('ember-source', '~3.25.0 || ~3.26.0'))) { - await render(hbs` - - `); - } else { - await render(hbs` - - `); - } - - let textA = find('time')?.innerText; - - assert.ok(textA, textA); - - await new Promise((resolve) => setTimeout(resolve, 1100)); - - let textB = find('time')?.innerText; - - assert.ok(textB, textB); - assert.notStrictEqual(textA, textB, `${textB} is 1s after ${textA}`); - - await new Promise((resolve) => setTimeout(resolve, 1100)); - - let textC = find('time')?.innerText; - - assert.ok(textC, textC); - assert.notStrictEqual(textB, textC, `${textC} is 1s after ${textB}`); - - this.setProperties({ locale: 'en-CA' }); - await settled(); - - assert.strictEqual(textA, find('time')?.innerText, 'Time is reset'); - }); - }); }); });