Skip to content

Commit

Permalink
feat: function resources
Browse files Browse the repository at this point in the history
  • Loading branch information
NullVoxPopuli committed Jun 6, 2021
1 parent cef4396 commit fff7e0b
Show file tree
Hide file tree
Showing 8 changed files with 3,300 additions and 2,131 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ The args thunk accepts the following data shapes:
when an array is passed, inside the Resource, `this.args.named` will be empty
and `this.args.positional` will contain the result of the thunk.

_for function resources, this is the only type of thunk allowed._

#### An object of named args

when an object is passed where the key `named` is not present,
Expand All @@ -70,6 +72,8 @@ This is the same shape of args used throughout Ember's Helpers, Modifiers, etc

### `useTask`

_Coming soon_

This is a utility wrapper like `useResource`, but can be passed an ember-concurrency task
so that the ember-concurrency task can reactively be re-called whenever args change.
This largely eliminates the need to start concurrency tasks from the constructor, modifiers,
Expand Down
71 changes: 71 additions & 0 deletions addon/-private/resources/function-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { tracked } from '@glimmer/tracking';
import { isDestroyed, isDestroying } from '@ember/destroyable';
import { waitForPromise } from '@ember/test-waiters';

import { LifecycleResource } from './lifecycle';

import type { ArgsWrapper } from '../types';

export const FUNCTION_TO_RUN = Symbol('FUNCTION TO RUN');

// type UnwrapAsync<T> = T extends Promise<infer U> ? U : T;
// type GetReturn<T extends () => unknown> = UnwrapAsync<ReturnType<T>>;
export type ResourceFn<Return = unknown, Args extends unknown[] = unknown[]> = (
previous: Return | undefined,
...args: Args
) => Return | Promise<Return>;

export interface BaseArgs<FnArgs extends unknown[]> extends ArgsWrapper {
positional: FnArgs;
}

export class FunctionRunner<
Return = unknown,
Args extends unknown[] = unknown[],
Fn extends ResourceFn<Return, Args> = ResourceFn<Return, Args>
> extends LifecycleResource<BaseArgs<Args>> {
// Set when using useResource
declare [FUNCTION_TO_RUN]: Fn;

@tracked _asyncValue: Return | undefined;
declare _syncValue: Return | undefined;

get value(): Return | undefined {
return this._asyncValue || this._syncValue;
}

get funArgs() {
return this.args.positional;
}

setup() {
this.update();
}

update() {
/**
* All positional args are consumed
*/
let result = this[FUNCTION_TO_RUN](this.value, ...this.funArgs);

if (typeof result === 'object') {
if ('then' in result) {
const recordValue = (value: Return) => {
if (isDestroying(this) || isDestroyed(this)) {
return;
}

this._asyncValue = value;
};

waitForPromise(result);

result.then(recordValue);

return;
}
}

this._syncValue = result;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { associateDestroyableChild, registerDestructor } from '@ember/destroyabl
// @ts-ignore
import { capabilities as helperCapabilities, setHelperManager } from '@ember/helper';

import type { ArgsWrapper, Cache } from './types';
import type { ArgsWrapper, Cache } from '../types';

export declare interface LifecycleResource<T extends ArgsWrapper> {
args: T;
Expand Down
112 changes: 105 additions & 7 deletions addon/-private/use-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,26 @@ import { getValue } from '@glimmer/tracking/primitives/cache';
// @ts-ignore
import { invokeHelper } from '@ember/helper';

import { FUNCTION_TO_RUN, FunctionRunner } from './resources/function-runner';
import { LifecycleResource } from './resources/lifecycle';

import type { ResourceFn } from './resources/function-runner';
import type { ArgsWrapper, Cache } from './types';

interface Constructable<T> {
interface Constructable<T = unknown> {
new (...args: unknown[]): T;
}

type Thunk =
// vanilla array
| (() => ArgsWrapper['positional'])
// plain array / positional args
| (() => Required<ArgsWrapper>['positional'])
// plain named args
| (() => ArgsWrapper['named'])
| (() => Required<ArgsWrapper>['named'])
// both named and positional args... but why would you choose this? :upsidedownface:
| (() => ArgsWrapper);

const DEFAULT_THUNK = () => [];

function normalizeThunk(thunk: Thunk): ArgsWrapper {
let args = thunk();

Expand Down Expand Up @@ -65,18 +71,110 @@ function useUnproxiedResource<Instance = unknown>(
};
}

const FUNCTION_CACHE = new WeakMap<ResourceFn<unknown, unknown[]>, Constructable<FunctionRunner>>();

/**
* The function is wrapped in a bespoke resource per-function definition
* because passing a vanilla function to invokeHelper would trigger a
* different HelperManager, which we want to work a bit differently.
* See:
* - function HelperManager in ember-could-get-used-to-this
* - Default Managers RFC
*
*/
function buildUnproxiedFunctionResource<Return, ArgsList extends unknown[]>(
context: object,
fn: ResourceFn<Return, ArgsList>,
thunk: () => ArgsList
): { value: Return } {
let resource: Cache<Return>;

let klass: Constructable<FunctionRunner>;

let existing = FUNCTION_CACHE.get(fn);

if (existing) {
klass = existing;
} else {
klass = class AnonymousFunctionRunner extends FunctionRunner<Return, ArgsList> {
[FUNCTION_TO_RUN] = fn;
};

FUNCTION_CACHE.set(fn, klass);
}

return {
get value(): Return {
if (!resource) {
resource = invokeHelper(context, klass, () => {
return normalizeThunk(thunk);
}) as Cache<Return>;
}

return getValue<Return>(resource);
},
};
}

/**
* For use in the body of a class.
* Constructs a cached Resource that will reactively respond to tracked data changes
*
*/
export function useResource<Instance extends object>(
export function useResource<Return, Args extends unknown[]>(
context: object,
fn: ResourceFn<Return, Args>,
thunk?: () => Args
): { value: Return };
export function useResource<Instance extends LifecycleResource<any>>(
context: object,
klass: Constructable<Instance>,
thunk: Thunk
thunk?: Thunk
): Instance;

export function useResource<Instance extends object, Args extends unknown[]>(
context: object,
klass: Constructable<Instance> | ResourceFn<Instance, Args>,
thunk?: Thunk | (() => Args)
): Instance {
const target = useUnproxiedResource<Instance>(context, klass, thunk);
let target: { value: Instance };

if (isLifecycleResource(klass)) {
target = useUnproxiedResource<Instance>(context, klass, thunk || DEFAULT_THUNK);

return proxyClass(target);
}

target = buildUnproxiedFunctionResource<Instance, Args>(
context,
klass,
(thunk || DEFAULT_THUNK) as () => Args
);

return proxyFunction(target);
}

function isLifecycleResource(classOrFn: Constructable | ResourceFn): classOrFn is Constructable {
return classOrFn.prototype instanceof LifecycleResource;
}

function proxyFunction<Instance extends object>(target: { value: Instance }) {
return new Proxy(target, {
get(target, key): unknown {
const instance = target.value as any;

return Reflect.get(instance, key, instance);
},
ownKeys(target): (string | symbol)[] {
return Reflect.ownKeys(target.value);
},
getOwnPropertyDescriptor(target, key): PropertyDescriptor | undefined {
return Reflect.getOwnPropertyDescriptor(target.value, key);
},
}) as never as Instance;
}

function proxyClass<Instance extends object>(target: { value: Instance }) {
return new Proxy(target, {
get(target, key): unknown {
const instance = target.value;
Expand Down
2 changes: 1 addition & 1 deletion addon/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Public API
export { useTask } from './-private/ember-concurrency';
export { LifecycleResource } from './-private/resource';
export { LifecycleResource } from './-private/resources/lifecycle';
export { useResource } from './-private/use-resource';

// Public Type Utilities
Expand Down
Loading

0 comments on commit fff7e0b

Please sign in to comment.