Skip to content

Commit

Permalink
Implement @service
Browse files Browse the repository at this point in the history
Resolves #622
Experiment for: emberjs/ember.js#20095
  • Loading branch information
NullVoxPopuli committed Mar 8, 2023
1 parent a91bd40 commit 1808762
Show file tree
Hide file tree
Showing 6 changed files with 361 additions and 13 deletions.
4 changes: 4 additions & 0 deletions ember-resources/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"./core": "./dist/core/index.js",
"./core/class-based": "./dist/core/class-based/index.js",
"./core/function-based": "./dist/core/function-based/index.js",
"./service": "./dist/service.js",
"./util": "./dist/util/index.js",
"./util/cell": "./dist/util/cell.js",
"./util/keep-latest": "./dist/util/keep-latest.js",
Expand All @@ -32,6 +33,9 @@
"core": [
"dist/core/index.d.ts"
],
"service": [
"dist/service.d.ts"
],
"util": [
"dist/util/index.d.ts"
],
Expand Down
14 changes: 14 additions & 0 deletions ember-resources/src/core/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { INTERNAL } from './function-based/types';
import type { Thunk } from './types/thunk';

export * from './types/base';
export * from './types/signature-args';
export * from './types/thunk';
Expand All @@ -15,3 +18,14 @@ export interface Cache<T = unknown> {
export interface Helper {
/* no clue what's in here */
}

export interface Stage1DecoratorDescriptor {
initializer: () => unknown;
}

export interface ClassResourceConfig {
thunk: Thunk;
definition: unknown;
type: 'class-based';
[INTERNAL]: true;
}
15 changes: 2 additions & 13 deletions ember-resources/src/core/use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,7 @@ import { INTERNAL } from './function-based/types';
import { normalizeThunk } from './utils';

import type { InternalFunctionResourceConfig } from './function-based/types';
import type { Thunk } from '[core-types]';

interface Descriptor {
initializer: () => unknown;
}

interface ClassResourceConfig {
thunk: Thunk;
definition: unknown;
type: 'class-based';
[INTERNAL]: true;
}
import type { ClassResourceConfig, Stage1DecoratorDescriptor } from '[core-types]';

type Config = ClassResourceConfig | InternalFunctionResourceConfig;

Expand Down Expand Up @@ -49,7 +38,7 @@ type Config = ClassResourceConfig | InternalFunctionResourceConfig;
* (new MyClass()).data === 2
* ```
*/
export function use(_prototype: object, key: string, descriptor?: Descriptor): void {
export function use(_prototype: object, key: string, descriptor?: Stage1DecoratorDescriptor): void {
if (!descriptor) return;

assert(`@use can only be used with string-keys`, typeof key === 'string');
Expand Down
231 changes: 231 additions & 0 deletions ember-resources/src/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// @ts-ignore
import { getValue } from '@glimmer/tracking/primitives/cache';
import { getOwner } from '@ember/application';
import { assert } from '@ember/debug';
import { associateDestroyableChild } from '@ember/destroyable';
// @ts-ignore
import { invokeHelper } from '@ember/helper';
import { isDevelopingApp, isTesting, macroCondition } from '@embroider/macros';

import { Resource } from './core';
import { INTERNAL } from './core/function-based/types';

import type { InternalFunctionResourceConfig } from './core/function-based/types';
import type { ClassResourceConfig, Stage1DecoratorDescriptor } from '[core-types]';
import type Owner from '@ember/owner';

/**
* Reminder:
* the return value of resource() is different from the returned type (for DX reasons).
* the return value is actually a type of InternalFunctionResourceConfig
*/
type ResourceType = InternalFunctionResourceConfig | typeof Resource;

/**
* In order for the same cache to be used for all references
* in an app, this variable needs to be in module scope.
*
* When the owner is destroyed, the cache is cleared
* (beacuse the WeakMap will see that nothing is referencing the key (owner) anymore)
*
* @internal
*/
export const __secret_service_cache__ = new WeakMap<Owner, Map<ResourceType, any>>();

/**
* For testing purposes, this allows us to replace a service with a "mock".
*/
const REPLACEMENTS = new WeakMap<Owner, Map<ResourceType, ResourceType>>();

/**
* An alternative to Ember's built in `@service` decorator.
*
* This decorator takes a resource and ties the resource's lifeime to the app / owner.
*
* The reason a resource is required, as opposed to allowing "any class", is that a
* resource already has implemented the concept of "teardown" or "cleanup",
* and native classes do not have this concept.
*
* Example:
*
* ```js
* import Component from '@glimmer/component';
* import { resource } from 'ember-resources';
* import { service } from 'ember-resources/service';
*
* class PlanetsAPI { ... }
*
* const Planets = resource(({ on, owner }) => {
* let api = new PlanetsAPI(owner); // for further injections
*
* // on cleanup, we want to cancel any pending requests
* on.cleanup(() => api.abortAll());
*
* return api;
* });
*
* class Demo extends Component {
* @service(Planets) planets;
* }
* ```
*
* For Stage 1 decorators and typescript, you'll need to manually declare the type:
* ```ts
* class Demo extends Component {
* @service(Planets) declare planets: Planets;
* }
* ```
*/
export function service(resource: unknown) {
/**
* In order for resources to be instantiated this way, we need to copy a little bit of code from
* `@use`, as we still need to rely on `invokeHelper`.
*
* The main difference being that instead of using `this` for the parent to `invokeHelper`,
* we use the owner.
*
* BIG NOTE RELATED TO TYPE SAFETY:
* - the `resource` argument is typed as `unknown` because the user-land types
* are lies so that DX is useful. The actual internal representation of a resource is an object
* with some properties with some hints for type narrowing
*/

// Deliberately separate comment so the above dev-comment doesn't make its way to
// consumers
// PropertyDecorator
return function legacyServiceDecorator(
_prototype: object,
key: string,
descriptor?: Stage1DecoratorDescriptor
) {
if (!descriptor) return;

assert(`@service(...) can only be used with string-keys`, typeof key === 'string');

assert(
`@service(...) may not be used with an initializer. For example, ` +
`\`@service(MyService) property;\``,
!descriptor.initializer
);

assert(
`Expected passed resource to be a valid resource definition.`,
typeof resource === 'function' || (typeof resource === 'object' && resource !== null)
);

return {
get(this: object) {
let owner = getOwner(this);

assert(
`owner was not found on instance of ${this.constructor.name}. ` +
`Has it been linked up correctly with setOwner?` +
`If this error has occured in a framework-controlled class, something has gone wrong.`,
owner
);

assert(`Resource definition is invalid`, isResourceType(resource));

if (macroCondition(isTesting() || isDevelopingApp())) {
let cachedReplacements = ensureCaches(owner, REPLACEMENTS);

let replacement = cachedReplacements.get(resource);

if (replacement) {
resource = replacement;

assert(`Replacement Resource definition is invalid`, isResourceType(resource));
}
}


let caches = ensureCaches(owner);
let cache = caches.get(resource);

if (!cache) {
if (INTERNAL in resource && 'type' in resource) {
assert(
`When using resources with @service(...), do not call .from() on class-based resources. ` +
`Resources used as services may not take arguments.`,
resource.type === 'function-based'
);

cache = invokeHelper(owner, resource);
caches.set(resource, cache);
associateDestroyableChild(owner, cache);
} else if ((resource as any).prototype instanceof Resource) {
assert(
`The .from() method on a type of Resource has been removed or altered. This is not allowed.`,
'from' in resource && resource.from === Resource.from
);

/**
* We do a lot of lying internally to make TypeScript nice for consumers.
* But it does mean that we have to cast in our own code.
*/
let { definition } = (resource as typeof Resource).from(
() => []
) as unknown as ClassResourceConfig;

cache = invokeHelper(owner, definition);
caches.set(resource, cache);
associateDestroyableChild(owner, cache);
}
}

return getValue(cache);
},
} as unknown as void /* thanks, TS. */;
};
}

function ensureCaches(owner: Owner, cache = __secret_service_cache__) {
let caches = cache.get(owner);

if (!caches) {
caches = new Map();
cache.set(owner, caches);
}

return caches;
}

function isResourceType(resource: unknown): resource is ResourceType {
// The internal representation of the passed resource will not match its type.
// A resource is always either a class definition, or the custom internal object.
// (See the helper managers for details)
return typeof resource === 'function' || (typeof resource === 'object' && resource !== null);
}

interface RegisterOptions {
/**
* The original service to replace.
*/
original: unknown;
/**
* The replacement service to use.
*/
replacement: unknown;
}

/**
*
*/
export function serviceOverride(owner: Owner, { original, replacement }: RegisterOptions) {
if (macroCondition(!isTesting() && !isDevelopingApp())) {
throw new Error(
'@service is experimental and `serviceOverride` is not available in production builds.'
);
}

let caches = ensureCaches(owner);

assert(`Original Resource definition is invalid`, isResourceType(original));
assert(`Replacement Resource definition is invalid`, isResourceType(replacement));

assert(`Cannot re-register service after it has been accessed.`, !caches.has(original));

let replacementCache = ensureCaches(owner, REPLACEMENTS);

replacementCache.set(original, replacement);
}
2 changes: 2 additions & 0 deletions ember-resources/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"declarationDir": "./dist",
"declaration": true,
"declarationMap": true,
// https://www.typescriptlang.org/tsconfig#stripInternal
"stripInternal": true,
// Build settings
"noEmitOnError": false,
"paths": {
Expand Down
Loading

0 comments on commit 1808762

Please sign in to comment.