Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

de-mystify resource internals #1166

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
189 changes: 189 additions & 0 deletions ember-resources/src/intermediate-representation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// @ts-ignore
import { createCache, getValue } from '@glimmer/tracking/primitives/cache';
import { assert } from '@ember/debug';
import { associateDestroyableChild, destroy, registerDestructor } from '@ember/destroyable';
// @ts-ignore
import { invokeHelper, setHelperManager } from '@ember/helper';

import { ReadonlyCell } from './cell.ts';
import { ResourceManagerFactory } from './resource-manager.ts';
import { INTERNAL } from './types.ts';
import { registerUsable, TYPE_KEY } from './use.ts';
import { shallowFlat } from './utils.ts';

import type { Destructor, Reactive, ResourceFunction } from './types.ts';
import type Owner from '@ember/owner';

export const CREATE_KEY = Symbol.for('__configured-resource-key__');
export const DEBUG_NAME = Symbol.for('DEBUG_NAME');
export const RESOURCE_CACHE = Symbol.for('__resource_cache__');

import { compatOwner } from './ember-compat.ts';

const getOwner = compatOwner.getOwner;
const setOwner = compatOwner.setOwner;

/**
* The return value from resource()
*
* This is semi-public API, and is meant to de-magic the intermediary
* value returned from resource(), allowing us to both document how to
* - manually create a resource (instance)
* - explain how the helper manager interacts with the methods folks
* can use to manually create a resource
*
*
* With an owner, you can manually create a resource this way:
* ```js
* import { destroy } from '@ember/destroyable';
* import { resource } from 'ember-resources';
*
* const builder = resource(() => {}); // builder can be invoked multiple times
* const owner = {};
* const state = builder.create(owner); // state can be created any number of times
*
* state.current // the current value
* destroy(state); // some time later, calls cleanup
* ```
*/
export class Builder<Value> {
#fn: ResourceFunction<Value>;

[TYPE_KEY] = TYPE;

constructor(fn: ResourceFunction<Value>, key: Symbol) {
assert(
`Cannot instantiate ConfiguredResource without using the resource() function.`,
key === CREATE_KEY,
);

this.#fn = fn;
}

create() {
return new Resource(this.#fn);
}
}

const TYPE = 'function-based';

registerUsable(TYPE, (context: object, config: Builder<unknown>) => {
let instance = config.create();

instance.link(context);

return instance[RESOURCE_CACHE];
});

/**
* TODO:
*/
export class Resource<Value> {
#originalFn: ResourceFunction<Value>;
#owner: Owner | undefined;
#previousFn: object | undefined;
#usableCache = new WeakMap<object, ReturnType<typeof invokeHelper>>();
#cache: ReturnType<typeof invokeHelper>;

constructor(fn: ResourceFunction<Value>) {
/**
* 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.
*/
this.#originalFn = fn.bind(null);

this.#cache = createCache(() => {
if (this.#previousFn) {
destroy(this.#previousFn);
}

let currentFn = this.#originalFn.bind(null);

associateDestroyableChild(this.#originalFn, currentFn);
this.#previousFn = currentFn;

assert(
`Cannot create a resource without an owner. Must have previously called .link()`,
this.#owner,
);

let maybeValue = currentFn({
on: {
cleanup: (destroyer: Destructor) => {
registerDestructor(currentFn, destroyer);
},
},
use: (usable) => {
assert(
`Expected the resource's \`use(...)\` utility to have been passed an object, but a \`${typeof usable}\` was passed.`,
typeof usable === 'object',
);
assert(
`Expected the resource's \`use(...)\` utility to have been passed a truthy value, instead was passed: ${usable}.`,
usable,
);
assert(
`Expected the resource's \`use(...)\` utility to have been passed another resource, but something else was passed.`,
INTERNAL in usable || usable instanceof Builder,
);

let previousCache = this.#usableCache.get(usable);

if (previousCache) {
destroy(previousCache);
}

let nestedCache = invokeHelper(this.#cache, usable);

associateDestroyableChild(currentFn, nestedCache as object);

this.#usableCache.set(usable, nestedCache);

return new ReadonlyCell<any>(() => {
let cache = this.#usableCache.get(usable);

assert(`Cache went missing while evaluating the result of a resource.`, cache);

return getValue(cache);
});
},
owner: this.#owner,
});

return maybeValue;
});
}
link(context: object) {
let owner = getOwner(context);

assert(`Cannot link without an owner`, owner);

this.#owner = owner;

associateDestroyableChild(context, this.#cache);
associateDestroyableChild(context, this.#originalFn);

setOwner(this.#cache, this.#owner);
}

get [RESOURCE_CACHE](): unknown {
return this.#cache;
}

get fn() {
return this.#originalFn;
}

get current() {
return shallowFlat(this.#cache);
}

[DEBUG_NAME]() {
return `Resource Function`;
}
}

setHelperManager(ResourceManagerFactory, Builder.prototype);
120 changes: 13 additions & 107 deletions ember-resources/src/resource-manager.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,10 @@
// @ts-ignore
import { createCache, getValue } from '@glimmer/tracking/primitives/cache';
import { assert } from '@ember/debug';
import { associateDestroyableChild, destroy, registerDestructor } from '@ember/destroyable';
// @ts-ignore
import { invokeHelper } from '@ember/helper';
// @ts-ignore
import { capabilities as helperCapabilities } from '@ember/helper';

import { ReadonlyCell } from './cell.ts';
import { compatOwner } from './ember-compat.ts';
import { CURRENT, INTERNAL } from './types.ts';

import type {
Destructor,
InternalFunctionResourceConfig,
Reactive,
ResourceFunction,
} from './types.ts';
import type { Builder, Resource } from './intermediate-representation.ts';
import type Owner from '@ember/owner';

const setOwner = compatOwner.setOwner;
Expand All @@ -25,119 +13,37 @@ const setOwner = compatOwner.setOwner;
* Note, a function-resource receives on object, hooks.
* We have to build that manually in this helper manager
*/
class FunctionResourceManager {
class FunctionResourceManager<Value> {
capabilities: ReturnType<typeof helperCapabilities> = helperCapabilities('3.23', {
hasValue: true,
hasDestroyable: true,
});

constructor(protected owner: Owner) {}
constructor(protected owner: Owner) {
setOwner(this, owner);
}

/**
* Resources do not take args.
* However, they can access tracked data
*/
createHelper(config: InternalFunctionResourceConfig): {
fn: InternalFunctionResourceConfig['definition'];
cache: ReturnType<typeof invokeHelper>;
} {
let { definition: fn } = config;
/**
* 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;
let usableCache = new WeakMap<object, ReturnType<typeof invokeHelper>>();
let owner = this.owner;

let cache = createCache(() => {
if (previousFn) {
destroy(previousFn);
}

let currentFn = thisFn.bind(null);

associateDestroyableChild(thisFn, currentFn);
previousFn = currentFn;

let maybeValue = currentFn({
on: {
cleanup: (destroyer: Destructor) => {
registerDestructor(currentFn, destroyer);
},
},
use: (usable) => {
assert(
`Expected the resource's \`use(...)\` utility to have been passed an object, but a \`${typeof usable}\` was passed.`,
typeof usable === 'object',
);
assert(
`Expected the resource's \`use(...)\` utility to have been passed a truthy value, instead was passed: ${usable}.`,
usable,
);
assert(
`Expected the resource's \`use(...)\` utility to have been passed another resource, but something else was passed.`,
INTERNAL in usable,
);

let previousCache = usableCache.get(usable);

if (previousCache) {
destroy(previousCache);
}

let nestedCache = invokeHelper(cache, usable);

associateDestroyableChild(currentFn, nestedCache as object);
createHelper(builder: Builder<Value>): Resource<Value> {
let instance = builder.create();

usableCache.set(usable, nestedCache);
instance.link(this);

return new ReadonlyCell<any>(() => {
let cache = usableCache.get(usable);

assert(`Cache went missing while evaluating the result of a resource.`, cache);

return getValue(cache);
});
},
owner: this.owner,
});

return maybeValue;
});

setOwner(cache, owner);

return { fn: thisFn, cache };
return instance;
}

getValue({ cache }: { fn: ResourceFunction; cache: ReturnType<typeof invokeHelper> }) {
let maybeValue = getValue(cache);

if (typeof maybeValue === 'function') {
return maybeValue();
}

if (isReactive(maybeValue)) {
return maybeValue[CURRENT];
}

return maybeValue;
getValue(state: Resource<Value>) {
return state.current;
}

getDestroyable({ fn }: { fn: ResourceFunction }) {
return fn;
getDestroyable(state: Resource<Value>) {
return state.fn;
}
}

function isReactive<Value>(maybe: unknown): maybe is Reactive<Value> {
return typeof maybe === 'object' && maybe !== null && CURRENT in maybe;
}

export const ResourceManagerFactory = (owner: Owner | undefined) => {
assert(`Cannot create resource without an owner`, owner);

Expand Down
Loading
Loading