<!---
Thanks for filing an issue 😄 ! Before you submit, please read the followi…ng:
Search open/closed issues before submitting since someone might have asked the same thing before!
-->
# 💬 RFC
### NOTE: This proposal is deliberately minimalistic and intended as an MVP to be included in alpha
This RFC proposes a unified API to share common logic between components, intended to be run at specific lifecycle hook timings.
The `@lifecycleHooks` decorator will be a direct replacement for v1's `@viewEngineHooks`, as well as v1 router's pipeline slots, with improved consistency and leaving room for (potentially unknown) future enhancements.
Unlike `@viewEngineHooks`, there is not a pre-determined set of hooks that can be tapped into. This allows users and plugin authors to define new hooks if they want to, in much the same way that Aurelia itself (for starters the runtime and the router) would be utilizing it.
### Might address these issues and/or is related to
- https://github.com/aurelia/aurelia/issues/978
- https://github.com/aurelia/aurelia/issues/1037
### TLDR (what does the API look like?)
```js
// do-stuff.js
export class DoStuffLifecycleHooks {
binding(vm, ...args) {}
attaching(vm, ...args) {}
detaching(vm, ...args) {}
// etc
}
// consumer.js
export class ConsumerCustomElement {
static dependencies = [DoStuff]; // Or register globally
}
```
## 🔦 Context
### Use cases
A common requirement in apps is to run the same bit of logic in the same lifecycle hook in multiple components. Some examples:
1) `*`: Conditionally log each lifecycle hook invocation for advanced troubleshooting.
2) `binding`, `load`: Lazy load data from the server only once, but initiate this from whichever component (out of several that need it) happens to be rendered first.
3) `define`, `hydrating`, `hydrated`, `created`: Apply CSS to components matching arbitrary/dynamic criteria or other custom runtime conventions
4) `attaching`, `detaching`: Run the same enter/leave animations for all routed components.
5) `canLoad`: Run auth guards for all components or a (potentially dynamic) subset thereof.
6) `canUnload`: Run an "unsaved changes" dialog guard for all components that have an `isDirty` getter on them.
### Prior art
This API carries some similarities from concepts from other frameworks:
- React: [higher-order components](https://reactjs.org/docs/higher-order-components.html)
- Vue: [mixins](https://vuejs.org/v2/guide/mixins.html)
- In Angular and Aurelia 1, you would do this sort of thing either via TypeScript mixins, or manual aggregation ("composition over inheritance").
### Resource semantics
When `@lifecycleHooks` is applied to a class, that class becomes a "resource" (it's registered to the `Protocol.resource` metadata api just like `@customElement`, `@valueConverter`, etc), giving it resource-specific semantics for DI and the runtime module loader/analyzer:
- Registering it in the root DI container (e.g. via `Aurelia.register`) turns it into a global resource and causes it to be invoked for *every* component
- Adding it to the `dependencies` list of a specific component, causes it to be invoked for only that component.
- It's recognized by the module analyzer, meaning it can lazy loaded (natively or with help of webpack / other bundlers) with relative ease.
- It will be supported by conventions (by adding the `LifecycleHooks` suffix to the class name).
```ts
// explicit
@lifecycleHooks()
export class SharedStuff { ... }
// conventions:
export class SharedStuffLifecycleHooks { ... }
```
The above are pretty much freebies for each API that adheres to the resource protocol, and `@lifecycleHooks` seems like a logical consumer of this existing infrastructure.
### Contracts & behavior
- The first argument passed-in is always the ViewModel, followed by the arguments that are normally passed-in to these hooks.
- `LifecycleHook`s are *scoped singletons*, just like `ValueConverter`s and `BindingBehavior`s, meaning globally registered ones are global singletons, whereas locally registered ones are singletons only within their registered context (typically one instance per type)
- Shared hooks run *before* (and if async, in parallel with) their component instance counterparts.
### Future extensions
Depending on the feedback during alpha, we may add some quality-of-life configuration options to the `@lifecycleHooks` to enable more use cases and/or improve performance. For example:
```ts
// Control whether the hook runs before, after, or in parallel with the component instance hook.
// This would default to 'parallel' (which is the current only behavior), making this a non-breaking change.
@lifecycleHooks({ order: 'before' | 'after' | 'parallel' })
```
```ts
// Apply a compile-time filter that, when it returns true, determines whether or not this hook will run for the specified component.
// Strictly speaking this does not add a new feature, but it could counteract the potentially significant performance impact
// of many shared hooks across an app that are only meant to run on a few components
@lifecycleHooks({ match: (definition: IResourceDefinition) => boolean })
```
## 💻 Examples
Let's go through the aforementioned use cases, now with example implementations using this new API:
### 1) `*`
Conditionally log each lifecycle hook invocation for advanced troubleshooting.
- Registration scope: global
```js
// lifecycle-logger.js
@lifecycleHooks()
export class LifecycleLogger {
constructor(@ILogger logger) { this.logger = logger; }
define(vm) { this.trace('define', vm); }
hydrating(vm) { this.trace('hydrating', vm); }
hydrated(vm) { this.trace('hydrated', vm); }
created(vm) { this.trace('created', vm); }
binding(vm) { this.trace('binding', vm); }
bound(vm) { this.trace('bound', vm); }
attaching(vm) { this.trace('attaching', vm); }
attached(vm) { this.trace('attached', vm); }
detaching(vm) { this.trace('detaching', vm); }
unbinding(vm) { this.trace('unbinding', vm); }
canLoad(vm) { this.trace('canLoad', vm); return true; }
load(vm) { this.trace('load', vm); }
canUnload(vm) { this.trace('canUnload', vm); return true; }
unload(vm) { this.trace('unload', vm); }
trace(hook, vm) { this.logger.trace(`${hook} ${vm.$controller.definition.name}`); }
}
// index.js
Aurelia
.register(
LifecycleLogger,
...,
)
.app(...)
.start();
```
### 2) `binding`, `load`
Lazy load data from the server only once, but initiate this from whichever component (out of several that need it) happens to be rendered first.
- Registration scope: local (added by hand to `dependencies`)
(in this example, we use it for a dropdown that needs some server-side data that only needs to be loaded once)
```js
// things-initializer.js
export const ICommonThings = DI.createInterface().withDefault(x => x.singleton(CommonThings));
export class CommonThings {
constructor(@IHttpClient http) { this.http = http; }
init() {
if (!this.isInitialized) {
return this.initPromise ??= (async () => {
this.data = await this.http.get('api/common-things');
this.isInitialized = true;
})();
}
}
}
@lifecycleHooks
export class ThingsInitializer {
constructor(@ICommonThings things) { this.things = things; }
binding() { return this.things.init(); }
load() { return this.things.init(); }
}
// some-dropdown.js
@customElement({
name: 'some-dropdown',
template: '<select :value="thingId"><option repeat.for="thing of things.data" :model="thing.id">${thing.name}</option></select>',
dependencies: [ThingsInitializer],
})
export class SomeDropdown {
constructor(@ICommonThings things) { this.things = things; }
}
```
### 3) `define`, `hydrating`, `hydrated`, `created`
Apply CSS to components matching arbitrary/dynamic criteria or other custom runtime conventions
- Registration scope: global
```js
// ripple-effect-applicator.js
@lifecycleHooks
export class RippleEffectApplicator {
created(vm) {
if (vm.$controller.definition.name.startsWith('bs-') && vm.someCondition) {
// (note: this definition.instructions stuff will become easier to use soon)
vm.$controller.definition.instructions[0].push(new HydrateAttributeInstruction('ripple', []));
}
}
}
// index.js
Aurelia
.register(
RippleEffectApplicator,
...,
)
.app(...)
.start();
```
### 4) `attaching`, `detaching`:
Run the same enter/leave animations for all routed components.
- Registration scope: local (added by custom decorator)
```js
// animations.js
const duration = 300;
export class SlideInOutAnimations {
attaching(vm) {
return vm.$controller.host.animate([{ transform: 'translate(-100vw, 0)' }, { transform: 'translate(0, 0)' }], { duration, easing: 'ease-in' }).finished;
}
detaching(vm) {
return vm.$controller.host.animate([{ transform: 'translate(0, 0)' }, { transform: 'translate(100vw, 0)' }], { duration, easing: 'ease-out' }).finished;
}
}
export class FadeInOutAnimations {
attaching(vm) {
return vm.$controller.host.animate([{ opacity: 0 }, { opacity: 1 }], { duration }).finished;
}
detaching(vm) {
return vm.$controller.host.animate([{ opacity: 1 }, { opacity: 0 }], { duration }).finished;
}
}
// let's make a convenient decorator for ourselves this time:
export function pageTransitions(target) {
(target.dependencies ??= []).push(SlideInOutAnimations, FadeInOutAnimations);
}
// home-page.js
@pageTransitions
export class HomePage {}
// settings-page.js
@pageTransitions
export class SettingsPage {}
```
### 5) `canLoad`:
Run auth guards for all components or a (potentially dynamic) subset thereof.
- Registration scope: global
```js
// auth-guard.js
@lifecycleHooks
export class AuthGuard {
constructor(@IAuthService authService) { this.authService = authService; }
// (note: router hook signatures are still subject to change)
async canLoad(vm, params, next, current) {
if (!current.data.auth || await this.authService.isAuthorized(current.data.auth.scopes)) {
return true;
}
return `/login(reason=${current.data.auth.scopes})`;
}
}
// settings-page.js
@route({ data: { auth: { scopes: ['settings'] } } })
export class SettingsPage {}
// profile-page.js
@route({ data: { auth: { scopes: ['profile'] } } })
export class ProfilePage {}
// index.js
Aurelia
.register(
AuthGuard,
...,
)
.app(...)
.start();
```
### 6) `canUnload`
Run an "unsaved changes" dialog guard for all components that have an `isDirty` getter on them.
- Registration scope: global
```js
// dirty-state-guard.js
@lifecycleHooks
export class DirtyStateGuard {
constructor(@IDialogService dialog) { this.dialog = dialog; }
async canUnload(vm, params, next, current) {
if (vm.isDirty) {
return this.dialog.promptUnsavedChanges();
}
return true;
}
}
// index.js
Aurelia
.register(
DirtyStateGuard,
...,
)
.app(...)
.start();
```
<!-- Love Aurelia? Please consider supporting our collective:
👉 https://opencollective.com/aurelia -->