diff --git a/docs-app/public/docs/6-utils/createAsyncService.md b/docs-app/public/docs/6-utils/createAsyncService.md new file mode 100644 index 000000000..de35d442c --- /dev/null +++ b/docs-app/public/docs/6-utils/createAsyncService.md @@ -0,0 +1,85 @@ +# createAsyncService + +This utility will create a service using a class definition similar to what is described in [RFC#502](https://github.com/emberjs/rfcs/pull/502) for explicit service injection -- no longer using strings. This allows module graphs to shake out services that aren't used until they are _needed_. + +The difference with `createService` is that `createAsyncService` takes a _stable reference_ to a function that will eventually return a class definition. + +This can be useful for importing services that may themselves import many things, or large dependencies. + + +## Setup + +```bash +pnpm add ember-primitives +``` + +Introduced in [0.47.0](https://github.com/universal-ember/ember-primitives/releases/tag/v0.47.0-ember-primitives) + + +## Usage + +```js +import { createAsyncService } from 'ember-primitives/service'; + +// This function is the key that all consumers should use to get the same instance +const getService = async () => { + let module = await import('./service/from/somewhere'); + return module.MyState; +} + +class Demo extends Component { + state = createAsyncService(this, getService); +} +``` + +### With Arguments + +```js +import { createAsyncService } from 'ember-primitives/service'; + +class MyState { + constructor(/* .. */ ) { /* ... */ } +} + +// in another file + +// This function is the key that all consumers should use to get the same instance +const getService = async (/* args here */ ) => { + let module = await import('./service/from/somewhere'); + return () => new module.MyState(/* args */ ); +} + +class Demo extends Component { + state = createAsyncService(this, () => getService(/* ... */)); +} +``` + + +### Accessing Services and handling cleanup + +Like with [`link`][reactiveweb-link], use of services and `registerDestructor` is valid: +```js +import { service } from '@ember/service'; +import { createAsyncService } from 'ember-primitives/service'; + +class MyState { + @service router; + + constructor(/* .. */) { + registerDestructor(this, () => { + // cleanup runs when Demo is torn down + }); + } +} + +// in another file +class Demo extends Component { + state = createAsyncService(this, getService); +} +``` + +However, note that the same restrictions as with `link` apply: services may not be accessed in the constructor. + +And even that caveat can be undone if what you need is passed in to your service's constructor. + +[reactiveweb-link]: https://reactive.nullvoxpopuli.com/functions/link.link.html diff --git a/docs-app/public/docs/6-utils/createService.md b/docs-app/public/docs/6-utils/createService.md new file mode 100644 index 000000000..596e2e7dd --- /dev/null +++ b/docs-app/public/docs/6-utils/createService.md @@ -0,0 +1,80 @@ +# createService + +This utility will create a service using a class definition similar to what is described in [RFC#502](https://github.com/emberjs/rfcs/pull/502) for explicit service injection -- no longer using strings. This allows module graphs to shake out services that aren't used until they are _needed_. + + +## Setup + +```bash +pnpm add ember-primitives +``` + +Introduced in [0.47.0](https://github.com/universal-ember/ember-primitives/releases/tag/v0.47.0-ember-primitives) + + +## Usage + +```js +import { createService } from 'ember-primitives/service'; + +class MyState { + // @services are allowed here +} + +class Demo extends Component { + // lazyily created upon access of `foo` + get foo() { + return createService(this, MyState); + } + + // or eagrly created when `Demo` is created + state = createService(this, MyState); +} +``` + +### With Arguments + +```js +import { createService } from 'ember-primitives/service'; + +class MyState { + constructor(/* .. */ ) { /* ... */ } +} + +class Demo extends Component { + get state() { + return createService(this, () => new MyState(1, 2)); + } +} +``` + +### Accessing Services and handling cleanup + +Like with [`link`][reactiveweb-link], use of services and `registerDestructor` is valid: +```js +import { service } from '@ember/service'; +import { createService } from 'ember-primitives/service'; + +class MyState { + @service router; + + constructor(/* .. */) { + registerDestructor(this, () => { + // cleanup runs when Demo is torn down + }); + } +} + +class Demo extends Component { + // or + get foo() { + return createService(this, () => new MyState(/* ... */)); + } +} +``` + +However, note that the same restrictions as with `link` apply: services may not be accessed in the constructor. + +And even that caveat can be undone if what you need is passed in to your service's constructor. + +[reactiveweb-link]: https://reactive.nullvoxpopuli.com/functions/link.link.html diff --git a/ember-primitives/src/service.ts b/ember-primitives/src/service.ts new file mode 100644 index 000000000..446abccc0 --- /dev/null +++ b/ember-primitives/src/service.ts @@ -0,0 +1,103 @@ +import { assert } from '@ember/debug'; + +import { getPromiseState } from 'reactiveweb/get-promise-state'; + +import { createStore } from './store.ts'; +import { findOwner } from './utils.ts'; + +import type { Newable } from './type-utils.ts'; + +/* +import type { Newable } from './type-utils.ts'; +import type { Registry } from '@ember/service'; +import type Service from '@ember/service'; + +type Decorator = ReturnType; + +// export function service( +// context: object, +// serviceName: Key +// ): Registry[Key] & Service; +export function service( + context: object, + serviceDefinition: Newable +): Class; +export function service(serviceDefinition: Newable): Decorator; +export function service(serviceName: Key): Decorator; +export function service(prototype: object, name: string | symbol, descriptor: unknown): void; +export function service( + context: object, + fn: Parameters>[0] +): ReturnType>; +export function service( + fn: Parameters>[0] +): Decorator; +*/ + +/** + * Instantiates a class once per application instance. + * + * + */ +export function createService( + context: object, + theClass: Newable | (() => Instance) +): Instance { + const owner = findOwner(context); + + assert( + `Could not find owner / application instance. Cannot create a instance tied to the application lifetime without the application`, + owner + ); + + return createStore(owner, theClass); +} + +const promiseCache = new WeakMap<() => any, unknown>(); + +/** + * Lazily instantiate a service. + * + * This is a replacement / alternative API for ember's `@service` decorator from `@ember/service`. + * + * For example + * ```js + * import { service } from 'ember-primitives/service'; + * + * const loader = () => { + * let module = await import('./foo/file/with/class.js'); + * return () => new module.MyState(); + * } + * + * class Demo extends Component { + * state = createAsyncService(this, loader); + * } + * ``` + * + * The important thing is for repeat usage of `createAsyncService` the second parameter, + * (loader in this case), must be shared between all usages. + * + * This is an alternative to using `createStore` inside an await'd component, + * or a component rendered with [`getPromiseState`](https://reactive.nullvoxpopuli.com/functions/get-promise-state.getPromiseState.html) + * ``` + */ +export function createAsyncService( + context: object, + theClass: () => Promise | (() => Instance)> +): ReturnType> { + let existing = promiseCache.get(theClass); + + if (!existing) { + existing = async () => { + const result = await theClass(); + + // Pay no attention to the lies, I don't know what the right type is here + return createStore(context, result as Newable); + }; + + promiseCache.set(theClass, existing); + } + + // Pay no attention to the TS inference crime here + return getPromiseState(existing); +} diff --git a/test-app/tests/service/create-async-service-test.gts b/test-app/tests/service/create-async-service-test.gts new file mode 100644 index 000000000..20fedce1e --- /dev/null +++ b/test-app/tests/service/create-async-service-test.gts @@ -0,0 +1,138 @@ +import { renderSettled } from '@ember/renderer'; +import { render, settled } from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; + +import { createAsyncService } from 'ember-primitives/service'; + +import type { Sample } from './sample.ts'; + +module('createAsyncService', function (hooks) { + setupRenderingTest(hooks); + + test('is singleton', async function (assert) { + let id = 0; + + class State { + foo = ++id; + + constructor() { + assert.step('created'); + } + } + + let resolve: (v?: any) => any; + let promise: Promise; + + async function factory() { + promise = new Promise((r) => { + resolve = r; + }); + await promise; + + return State; + } + + const a = createAsyncService(this, factory); + const b = createAsyncService(this, factory); + + render( + + ); + + await renderSettled(); + + assert.verifySteps([]); + + assert.dom('#a').hasNoText(); + assert.dom('#b').hasNoText(); + + // @ts-expect-error + resolve(null); + // @ts-expect-error + await promise; + await settled(); + + assert.verifySteps(['created']); + assert.dom('#a').hasText('1'); + assert.dom('#b').hasText('1'); + }); + + test('is singleton with await import', async function (assert) { + async function factory() { + const module = await import('./sample.ts'); + + return module.Sample; + } + + const a = createAsyncService(this, factory); + const b = createAsyncService(this, factory); + + render( + + ); + + await renderSettled(); + + assert.verifySteps([]); + + assert.dom('#a').hasNoText(); + assert.dom('#b').hasNoText(); + + await settled(); + + assert.dom('#a').hasText('1'); + assert.dom('#b').hasText('1'); + }); + + test('is singleton with await import and new', async function (assert) { + async function factory(): Promise<() => Sample> { + const module = await import('./sample.ts'); + + return () => new module.Sample(); + } + + const a = createAsyncService(this, factory); + const b = createAsyncService(this, factory); + + render( + + ); + + await renderSettled(); + + assert.verifySteps([]); + + assert.dom('#a').hasNoText(); + assert.dom('#b').hasNoText(); + + await settled(); + + assert.dom('#a').hasText('1'); + assert.dom('#b').hasText('1'); + }); +}); diff --git a/test-app/tests/service/create-service-test.ts b/test-app/tests/service/create-service-test.ts new file mode 100644 index 000000000..ee2883bc4 --- /dev/null +++ b/test-app/tests/service/create-service-test.ts @@ -0,0 +1,54 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +import { createService } from 'ember-primitives/service'; + +module('createService', function (hooks) { + setupTest(hooks); + + test('class is singleton', function (assert) { + let id = 0; + + class State { + foo = ++id; + + constructor() { + assert.step('created'); + } + } + + const a = createService(this, State); + + assert.verifySteps(['created']); + + const b = createService(this, State); + + assert.verifySteps([]); + assert.strictEqual(a.foo, b.foo); + assert.strictEqual(a.foo, 1); + }); + + test('function is singleton', function (assert) { + let id = 0; + + class State { + foo = ++id; + + constructor() { + assert.step('created'); + } + } + + const make = () => new State(); + + const a = createService(this, make); + + assert.verifySteps(['created']); + + const b = createService(this, make); + + assert.verifySteps([]); + assert.strictEqual(a.foo, b.foo); + assert.strictEqual(a.foo, 1); + }); +}); diff --git a/test-app/tests/service/sample.ts b/test-app/tests/service/sample.ts new file mode 100644 index 000000000..5f014c121 --- /dev/null +++ b/test-app/tests/service/sample.ts @@ -0,0 +1,3 @@ +export class Sample { + foo = 1; +} diff --git a/test-app/tests/service/unit-test.ts b/test-app/tests/service/unit-test.ts new file mode 100644 index 000000000..3dd3babf5 --- /dev/null +++ b/test-app/tests/service/unit-test.ts @@ -0,0 +1,128 @@ +// import { module, test } from 'qunit'; +// import { setupTest } from 'ember-qunit'; +// +// // import { service } from 'ember-primitives/service'; +// import { link } from 'reactiveweb/link'; + +// module('service()', function (hooks) { +// setupTest(hooks); +// +// module('(old) string-based service lookup', function () { +// test('implicit name', function (assert) { +// class State { +// foo = 2; +// } +// class Demo { +// @service declare state: State; +// } +// +// const instance = link(new Demo(), this); +// +// assert.ok(instance.state instanceof State); +// assert.strictEqual(instance.state.foo, 2); +// }); +// +// test('explicit name', function (assert) { +// class State { +// foo = 2; +// } +// class Demo { +// @service('state') declare x: State; +// } +// +// const instance = link(new Demo(), this); +// +// assert.ok(instance.state instanceof State); +// assert.strictEqual(instance.state.foo, 2); +// }); +// +// test('non-decorator', function (assert) { +// class State { +// foo = 2; +// } +// class Demo { +// state = service(this, 'state'); +// } +// +// const instance = link(new Demo(), this); +// +// assert.ok(instance.state instanceof State); +// assert.strictEqual(instance.state.foo, 2); +// }); +// }); +// +// module('(new) explicit service usage', function () { +// test('decorator', function (assert) { +// class State { +// foo = 2; +// } +// class Demo { +// @service(State) declare state: State; +// } +// +// const instance = link(new Demo(), this); +// +// assert.ok(instance.state instanceof State); +// assert.strictEqual(instance.state.foo, 2); +// }); +// +// test('non-decorator', function (assert) { +// class State { +// foo = 2; +// } +// class Demo { +// state = service(this, State); +// } +// +// const instance = link(new Demo(), this); +// +// assert.ok(instance.state instanceof State); +// assert.strictEqual(instance.state.foo, 2); +// }); +// +// test('non-decorator: lazily imported service', async function (assert) { +// class State { +// foo = 2; +// } +// class Demo { +// state = service(this, async () => { +// await Promise.resolve(); +// +// return new State(); +// }); +// } +// +// const instance = link(new Demo(), this); +// +// assert.notOk(instance.state instanceof State); +// assert.ok(instance.state.isLoading); +// await instance.state.promise; +// assert.notOk(instance.state.isLoading); +// assert.ok(instance.state.value instanceof State); +// assert.strictEqual(instance.state.foo, 2); +// }); +// +// test('decorator: lazily imported service', async function (assert) { +// class State { +// foo = 2; +// } +// class Demo { +// @service(async () => { +// await Promise.resolve(); +// +// return new State(); +// }) +// declare state: State; +// } +// +// const instance = link(new Demo(), this); +// +// assert.notOk(instance.state instanceof State); +// assert.ok(instance.state.isLoading); +// await instance.state.promise; +// assert.notOk(instance.state.isLoading); +// assert.ok(instance.state.value instanceof State); +// assert.strictEqual(instance.state.foo, 2); +// }); +// }); +// });