question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. ItĀ collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

[RFC] @lifecycleHooks decorator for sharing lifecycle hook logic

See original GitHub issue

šŸ’¬ 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

TLDR (what does the API look like?)

// 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
  • Vue: mixins
  • 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).
// 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.
  • LifecycleHooks are scoped singletons, just like ValueConverters and BindingBehaviors, 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:

// 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' })
// 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
// 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)

// 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
// 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)
// 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
// 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
// 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();

Issue Analytics

  • State:open
  • Created 3 years ago
  • Reactions:4
  • Comments:17 (17 by maintainers)

github_iconTop GitHub Comments

3reactions
3cpcommented, Dec 4, 2020

Loving it.

I think @lifecycleHooks({ order: 'before' | 'after' | 'parallel' }) could be popular enough to justify a simpler api that works with conventions too.

export class SomeLifecycleHooks {
  static order = 'before';
}
1reaction
fkleuvercommented, Dec 7, 2020

I had another thought on order: 'before' | 'after' | 'parallel', I think this order should be controllable per method, not per class.

For example, a plugin might provide a hook to setup and wire down some dom setup in attached and detaching, naturally it would want use ā€œbeforeā€ on attached, and ā€œafterā€ on detaching. (Or maybe the other way around)

Put the hooks in two separate classes and export them as a single record?

Read more comments on GitHub >

github_iconTop Results From Across the Web

Angular lifecycle hooks explained - LogRocket Blog
A guide to lifecycle hooks in Angular, including what they do, and how to use them to gain more control over your applications....
Read more >
Introducing Hooks - React
Hooks are a new addition in React 16.8. They let you use state and other React features without writing a class. This new...
Read more >
Summary - Ember RFCs
Despite no longer endorsing lifecycle hook arguments, trying to communicate such could have the reverse effect by pointing a spotlight at them.
Read more >
blog | Introducing @use - pzuraq
At the end of last year, I submitted an RFC, written by Yehuda Katz ... Leading up to Octane, most of the use...
Read more >
Avoiding Lifecycle in Components - nullvoxpopuli.com
In many cases, classic lifecycle hooks like didInsertElement can be ... that logic at all, in which case it's worth revisiting the design...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found