Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(component-store): add OnStoreInit and OnStateInit lifecycle hooks #3368

Merged
merged 10 commits into from
May 20, 2022

Conversation

brandonroberts
Copy link
Member

@brandonroberts brandonroberts commented Apr 7, 2022

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

[ ] Bugfix
[x] Feature
[ ] Code style update (formatting, local variables)
[ ] Refactoring (no functional changes, no api changes)
[ ] Build related changes
[ ] CI related changes
[ ] Documentation content changes
[ ] Other... Please describe:

What is the current behavior?

Closes #3335

What is the new behavior?

Adds two new lifecycle hooks to ComponentStore along with their respective interfaces

  • OnStoreInit - is called after the store is created
    • This method throws an error when trying to access the .get() method because the state hasn't been set, even if done eagerly.
  • OnStateInit - is called after the state is initialized

Adds a new exported function provideComponentStore(ComponentStore) that is used to provide the ComponentStore, instantiate it, and run the lifecycle hooks if defined

import { Injectable } from '@angular/core';
import {
  ComponentStore,
  OnStateInit,
  OnStoreInit,
} from '@ngrx/component-store';

@Injectable()
export class LoginPageStore
  extends ComponentStore<{ test: boolean }>
  implements OnStoreInit, OnStateInit
{
  constructor() {
    super({ test: false });
  }

  ngrxOnStoreInit() {
    console.log('store init');
  }

  ngrxOnStateInit() {
    console.log('state init', this.get());
  }
}

Does this PR introduce a breaking change?

[ ] Yes
[x] No

Other information

  • Example App changes are only used for reference and will be reverted

@netlify
Copy link

netlify bot commented Apr 7, 2022

Deploy Preview for ngrx-io ready!

Name Link
🔨 Latest commit e7ac13e
🔍 Latest deploy log https://app.netlify.com/sites/ngrx-io/deploys/626ee527d9c09a000960db7f
😎 Deploy Preview https://deploy-preview-3368--ngrx-io.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site settings.

// State can be initialized either through constructor or setState.
if (defaultState) {
this.initState(defaultState);
}
}

private checkInitStoreHook() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: consider a different name:

  • triggerInitStoreEvent
  • callInitStoreHook

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good suggestions

@@ -18,6 +19,7 @@ describe('Login Page', () => {
imports: [NoopAnimationsModule, MaterialModule, ReactiveFormsModule],
declarations: [LoginPageComponent, LoginFormComponent],
providers: [
LoginPageStore,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: cover OnStateInit and OnStoreInit hooks with tests

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. This was for testing example code

@brandonroberts brandonroberts force-pushed the feat-component-store-lifecycle branch 2 times, most recently from 45c853f to 47ea64f Compare April 27, 2022 02:24
@brandonroberts brandonroberts force-pushed the feat-component-store-lifecycle branch 2 times, most recently from 7070b02 to e7ac13e Compare May 1, 2022 19:53
@brandonroberts
Copy link
Member Author

brandonroberts commented May 1, 2022

Code is refactored, tests are updated and green. I do have some concerns about the feature in general though. Because we are chaining off the constructor of the ComponentStore, it forces us or the developer to consider workarounds to use it effectively.

Consider the example below:

import { Injectable } from '@angular/core';
import {
  ComponentStore,
  OnStateInit,
  OnStoreInit,
} from '@ngrx/component-store';
import { delay, of, tap } from 'rxjs';

@Injectable()
export class LoginPageStore
  extends ComponentStore<{ test: boolean }>
  implements OnStoreInit, OnStateInit
{
  props = [];
  constructor() {
    super();
  }

  logEffect = this.effect(
    tap<void>(() => {
      console.log('effect');
    })
  );

  ngrxOnStoreInit() {
    this.logEffect(); // doesn't work because constructor isn't done
    console.log(this.props); // undefined
  }

  ngrxOnStateInit() {
    console.log('state init', this.get()); // works
    this.logEffect(); // blows up
  }
}

The examples below work

import { Injectable } from '@angular/core';
import {
  ComponentStore,
  OnStateInit,
  OnStoreInit,
} from '@ngrx/component-store';
import { delay, of, tap } from 'rxjs';

@Injectable()
export class LoginPageStore
  extends ComponentStore<{ test: boolean }>
  implements OnStoreInit, OnStateInit
{
props = []
  constructor() {
    super();
  }

  logEffect = this.effect(
    tap<void>(() => {
      console.log('effect');
    })
  );

  ngrxOnStoreInit() {
    of(null)
      .pipe(delay(0))
      .subscribe(() => {
        console.log('store init');
        console.log(this.props); // empty array
        this.logEffect(); // works
      });
  }

  ngrxOnStateInit() {
    setTimeout(() => {
      console.log('state init', this.get()); // works
      this.logEffect();
    });
  }
}

From my understanding, setTimeout is a macrotask that gets pushed into the queue, running on the next "tick" which allows it to run successfully. It would put the responsibility on the developer to run the code inside the hooks in a way that doesn't break. I'm open to other suggestions also internally we could make to make it more predictable without introducing race conditions.

If Angular had some sort of ELEMENT_INITIALIZER token, similar to the INJECTOR_INITIALIZER being introduced with standalone components, we could hook into that to run the hooks after instantiation, but no such thing exists today.

@aokrushk @markostanimirovic @timdeschryver @nartc

@brandonroberts brandonroberts changed the title WIP feat(component-store): add OnStoreInit and OnStateInit lifecycle hooks feat(component-store): add OnStoreInit and OnStateInit lifecycle hooks May 1, 2022
Copy link
Member

@markostanimirovic markostanimirovic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hoisting 😳 The behavior could be very confusing for users.

This will work for component stores that are explicitly provided in components, modules, or route config:

const WITH_HOOKS = new InjectionToken<ComponentStore<any>[]>(
  '@ngrx/component-store: ComponentStores with Hooks'
);

export function provideWithHooks(
  componentStoreClass: Type<ComponentStore<any>>
) {
  return [
    { provide: WITH_HOOKS, multi: true, useClass: componentStoreClass },
    {
      provide: componentStoreClass,
      useFactory: () => {
        const componentStore = inject(WITH_HOOKS).pop();

        if (isOnStoreInitDefined(componentStore)) {
          componentStore.ngrxOnStoreInit();
        }

        if (isOnStateInitDefined(componentStore)) {
          componentStore.state$
            .pipe(take(1), takeUntil(componentStore.destroy$))
            .subscribe(() => componentStore.ngrxOnStateInit());
        }

        return componentStore;
      },
    },
  ];
}

Example:

@Injectable()
class LifecycleStore
  extends ComponentStore<LifeCycle>
  implements OnStoreInit, OnStateInit
{
  constructor() {
    super({ init: true });

    console.log('constructor');
    this.logEffect('constructor effect');
  }

  logEffect = this.effect(
    tap<string>((message) => {
      console.log(message);
    })
  );

  ngrxOnStoreInit(): void {
    console.log('onStoreInit');
    this.logEffect('store init effect');
  };

  ngrxOnStateInit(): void {
    console.log('onStateInit');
    this.logEffect('state init effect');
  }
}

@Component({
  providers: [provideWithHooks(LifecycleStore)]
})
export class AppComponent {
  constructor(private readonly lifecycleStore: LifecycleStore) {}
}

// console:
// constructor
// constructor effect
// onStoreInit
// store init effect
// onStateInit
// state init effect

@brandonroberts
Copy link
Member Author

Nice, I thought of adding a type of provideComponentStore function also, but didn't think about using inject. I think it works for the primary use case, and if you want to use the providedIn you understand that restriction.

@brandonroberts brandonroberts force-pushed the feat-component-store-lifecycle branch from e7ac13e to c95d9d4 Compare May 4, 2022 02:38
@brandonroberts
Copy link
Member Author

I refactored the logic for the init checks to run after the constructor is complete, so the additional token isn't required and it runs consistently whether it's in providers or uses providedIn.

@brandonroberts brandonroberts force-pushed the feat-component-store-lifecycle branch 3 times, most recently from 3f36f5d to 96a7669 Compare May 5, 2022 20:23
modules/component-store/spec/component-store.spec.ts Outdated Show resolved Hide resolved
modules/component-store/src/component-store.ts Outdated Show resolved Hide resolved
modules/component-store/src/component-store.ts Outdated Show resolved Hide resolved
modules/component-store/src/component-store.ts Outdated Show resolved Hide resolved
@brandonroberts brandonroberts force-pushed the feat-component-store-lifecycle branch from 603b82f to 9f1a690 Compare May 7, 2022 20:28
@brandonroberts brandonroberts changed the title feat(component-store): add OnStoreInit and OnStateInit lifecycle hooks WIP feat(component-store): add OnStoreInit and OnStateInit lifecycle hooks May 10, 2022
@brandonroberts brandonroberts force-pushed the feat-component-store-lifecycle branch 2 times, most recently from a74cbbc to 3c81a95 Compare May 14, 2022 04:09
@brandonroberts brandonroberts changed the title WIP feat(component-store): add OnStoreInit and OnStateInit lifecycle hooks feat(component-store): add OnStoreInit and OnStateInit lifecycle hooks May 14, 2022
@brandonroberts brandonroberts force-pushed the feat-component-store-lifecycle branch from 3c81a95 to 39f3e52 Compare May 14, 2022 04:11
Copy link
Member

@markostanimirovic markostanimirovic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🎉 The only thing I would change is not to expose isStoreInitDefined and isStateInitDefined guards to the public API.

modules/component-store/src/lifecycle_hooks.ts Outdated Show resolved Hide resolved
export function provideComponentStore(
componentStoreClass: Type<ComponentStore<any>>
): Provider[] {
const CS_WITH_HOOKS = new InjectionToken<ComponentStore<any>>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const CS_WITH_HOOKS = new InjectionToken<ComponentStore<any>>(
const CS_WITH_HOOKS = new InjectionToken<ComponentStore<unknown>>(

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think unknown will fail extends object constraint on ComponentStore generics. If we want to absolutely avoid any, I think {} would do it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, {} or <object>. Just not any :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@brandonroberts brandonroberts force-pushed the feat-component-store-lifecycle branch from d68f4b7 to 247f6e0 Compare May 19, 2022 11:20
Copy link
Member

@timdeschryver timdeschryver left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM (again 😅)
Should we document somewhere why it's done like this instead of "just" adding the hooks to the Store class? (or will this be a part of the docs?).

@brandonroberts
Copy link
Member Author

@timdeschryver yes, I'm going to add a docs page in a follow-up PR. We're also looking at adding a warning if the lifecycle hooks are defined but it wasn't provided through the function.

@brandonroberts brandonroberts merged commit 0ffed02 into master May 20, 2022
@brandonroberts brandonroberts deleted the feat-component-store-lifecycle branch May 20, 2022 01:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants