Skip to content

Commit

Permalink
feat(function-resource): support encapsulated tracked state
Browse files Browse the repository at this point in the history
This is based on ideas from https://wycats.github.io/polaris-sketchwork/reactivity.html
Where a function-based resource _may_ return an arrow function instead
of a non-function-value. This allows the function-based resource to
conusme and update its own tracked state without invalidating itself.

Previously, in order for function-based resources to have their own
tracked state, an object had to be returned, and it was up to the
consumer to consume that tracked state via property access.

With this arrow-function return, we can provide a single reactive value
via the resource as well as encapsulated state.
  • Loading branch information
NullVoxPopuli committed Jun 19, 2022
1 parent 9da7574 commit 9800c14
Show file tree
Hide file tree
Showing 4 changed files with 363 additions and 155 deletions.
83 changes: 56 additions & 27 deletions ember-resources/src/util/function-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export function resource<Value>(context: object, setup: ResourceFunction<Value>)
export function resource<Value>(
context: object | ResourceFunction<Value>,
setup?: ResourceFunction<Value>
): Value | InternalIntermediate<Value> | ResourceFunction<Value> {
): Value | InternalIntermediate<Value> | ResourceFn<Value> {
if (!setup) {
assert(
`When using \`resource\` with @use, ` +
Expand All @@ -175,7 +175,7 @@ export function resource<Value>(
*/
(context as any)[INTERNAL] = true;

return context as ResourceFunction<Value>;
return context as ResourceFn<Value>;
}

assert(
Expand Down Expand Up @@ -226,9 +226,9 @@ function wrapForPlainUsage<Value>(context: object, setup: ResourceFunction<Value

/**
* This proxy takes everything called on or accessed on "target"
* and forwards it along to target.value (where the actual resource instance is)
* and forwards it along to target[INTERMEDIATE_VALUE] (where the actual resource instance is)
*
* It's important to only access .value within these proxy-handler methods so that
* It's important to only access .[INTERMEDIATE_VALUE] within these proxy-handler methods so that
* consumers "reactively entangle with" the Resource.
*/
return new Proxy(target, {
Expand Down Expand Up @@ -287,8 +287,21 @@ export type Hooks = {
cleanup: (destroyer: Destructor) => void;
};
};

/**
* Type of the callback passed to `resource`
*/
type ResourceFunction<Value = unknown> = (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<Value = unknown> = (hooks: Hooks) => Value;

type Destructor = () => void;
type ResourceFunction<Value = unknown> = (hooks: Hooks) => Value;
type Cache = object;

/**
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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);
Expand All @@ -513,7 +542,7 @@ export function use(_prototype: object, key: string, descriptor?: Descriptor): v

type ResourceInitializer = {
[INTERNAL]: true;
};
} & ResourceFunction<unknown>;

function isResourceInitializer(obj: unknown): obj is ResourceInitializer {
return typeof obj === 'function' && obj !== null && INTERNAL in obj;
Expand Down
182 changes: 182 additions & 0 deletions testing/ember-app/tests/utils/function-resource/clock-test.ts
Original file line number Diff line number Diff line change
@@ -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`
<time>{{this.clock}}</time>
`);

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`
<time>
{{#let (hash start=this.date locale=this.locale) as |options|}}
{{this.Clock options}}
{{/let}}
</time>
`);
} else {
await render(hbs`
<time>{{this.Clock (hash start=this.date locale=this.locale)}}</time>
`);
}

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');
});
});
});
Loading

0 comments on commit 9800c14

Please sign in to comment.