-
Notifications
You must be signed in to change notification settings - Fork 37
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Resolves #622 Experiment for: emberjs/ember.js#20095
- Loading branch information
1 parent
a91bd40
commit 41ea3c2
Showing
6 changed files
with
336 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,206 @@ | ||
// @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'; | ||
|
||
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: ResourceType) { | ||
/** | ||
* 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. | ||
*/ | ||
|
||
// 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 | ||
); | ||
|
||
if (macroCondition(isTesting() || isDevelopingApp())) { | ||
let cachedReplacements = ensureCaches(owner, REPLACEMENTS); | ||
|
||
let replacement = cachedReplacements.get(resource); | ||
|
||
if (replacement) { | ||
resource = replacement; | ||
} | ||
} | ||
|
||
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; | ||
} | ||
|
||
interface RegisterOptions { | ||
/** | ||
* The original service to replace. | ||
*/ | ||
original: ResourceType; | ||
/** | ||
* The replacement service to use. | ||
*/ | ||
replacement: ResourceType; | ||
} | ||
|
||
/** | ||
* | ||
*/ | ||
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(`Cannot re-register service after it has been accessed.`, !caches.has(original)); | ||
|
||
let replacementCache = ensureCaches(owner, REPLACEMENTS); | ||
|
||
replacementCache.set(original, replacement); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
import Component from '@glimmer/component'; | ||
import { module, test } from 'qunit'; | ||
import { render, find } from '@ember/test-helpers'; | ||
import { setupRenderingTest } from 'ember-qunit'; | ||
|
||
import { cell, Resource, resource } from 'ember-resources'; | ||
import { service, serviceOverride } from 'ember-resources/service'; | ||
|
||
import type Owner from '@ember/owner'; | ||
|
||
// This export is marked as @internal, so it is not present in | ||
// the built d.ts files. | ||
// @ts-expect-error | ||
import { __secret_service_cache__ } from 'ember-resources/service'; | ||
|
||
const CACHE = __secret_service_cache__ as WeakMap<Owner, Map<object, any>>; | ||
|
||
module('@service | rendering', function (hooks) { | ||
setupRenderingTest(hooks); | ||
|
||
const Clock = resource(({ on }) => { | ||
let time = cell(new Date()); | ||
let interval = setInterval(() => { | ||
time.current = new Date(); | ||
}, 1000); | ||
|
||
|
||
on.cleanup(() => { | ||
clearInterval(interval); | ||
}); | ||
|
||
return () => time.current; | ||
}); | ||
|
||
let counter = 0; | ||
|
||
class APIWrapper extends Resource { | ||
/** | ||
* Adding a counter here allows us to roughly measure | ||
* that the service is only created once for a given test. | ||
*/ | ||
hello = 'world' + counter; | ||
} | ||
|
||
let asString = (x: unknown) => `${x}`; | ||
|
||
test('it works', async function (assert) { | ||
class Demo extends Component { | ||
@service(Clock) declare clock: Date; | ||
@service(APIWrapper) declare api: APIWrapper; | ||
|
||
<template> | ||
<time>{{asString this.clock}}</time> | ||
<out>{{this.api.hello}}</out> | ||
</template> | ||
} | ||
|
||
await render( | ||
<template> | ||
<div id="one"><Demo /></div> | ||
<div id="two"><Demo /></div> | ||
</template> | ||
); | ||
|
||
// Ensure that #one and #two have the same text | ||
let helloText = find('#one out')?.textContent?.trim() || `<no text found!!>`; | ||
let clockText = find('#one time')?.textContent?.trim() || `<no text found!!>`; | ||
|
||
assert.dom('#two time').hasText(clockText); | ||
assert.dom('#two out').hasText(helloText); | ||
|
||
assert.strictEqual(CACHE.get(this.owner)?.size, 2, 'only two services were created'); | ||
}); | ||
|
||
test('Sub classing works', async function (assert) { | ||
class AuthenticatedAPI extends APIWrapper { | ||
hello = 'there'; | ||
} | ||
|
||
class Demo extends Component { | ||
@service(APIWrapper) declare api: APIWrapper; | ||
|
||
<template> | ||
<out>{{this.api.hello}}</out> | ||
</template> | ||
} | ||
|
||
serviceOverride(this.owner, { | ||
replacement: AuthenticatedAPI, | ||
original: APIWrapper | ||
}); | ||
|
||
await render( | ||
<template> | ||
<div id="one"><Demo /></div> | ||
<div id="two"><Demo /></div> | ||
</template> | ||
); | ||
|
||
// Ensure that #one and #two have the same text | ||
let helloText = find('#one out')?.textContent?.trim() || `<no text found!!>`; | ||
|
||
assert.dom('#one out').hasText('there'); | ||
assert.dom('#two out').hasText(helloText); | ||
|
||
assert.strictEqual(CACHE.get(this.owner)?.size, 1, 'only one service(s) were created'); | ||
}); | ||
}); |