Angular NGRX enabling DevTools at runtime

Angular NGRX enabling DevTools at runtime

In this article, I will cover a problem that I was working on in one of my projects. With help from the NGRX team, I was able to solve it.  Shot-out to @brandontroberts , @tim_deschryver, @AlexOkrushko!

I hope it helps you when using NGRX, and you can also add it as part of your development process. 

Problem:

I wanted to be able to enable DevTools at runtime. The reason I want to do that is to be able to let users report his/her application experience and especially the problems they are experiencing. 

In other words, enable debugging reporting on the fly using NGRX framework.

Solution:

This article will be useful if you love Redux DevTools, but you don’t have them enabled in Production.

You would like to have your application to be able to switch to the reporting mode, even in Production.

I will cover the following steps:

  1. Adding a lazy-loaded Feature Module
  2. Dispatching an effect to create a StoreDevtoolsModule at runtime
  3. Extras:
    1. Use hidden F1 key to enable a link for debugging. 
    2. Enable third party logging/debugging tools  

Implementation:

We start by adding a feature; we will call it ‘debug’.  

It will contain:

  1.  debug.action – We will use an NGRX action from this file to dispatch from the component.
  2. debug.component. We will use Debug component to load our feature via a new route we will add to app.module. 
  3. debug.module – We will lazy-load this module that in the app.module, and it will contain a route to the component
  4. app.module – Register a new feature with a lazy-loaded route
  5. dev-tools.module – Module that will inject the StoreDevTools to kick-of the Redux tools registration from this module.
  6. debug.effect – We will use this NGRX effect to catch an action dispatched. This effect will be responsible for the runt-time enabling of the dev tools.
  7. root-store.module – Register new Effect in RootModule
  8. app.componet – Add a hiddent button that can be turned visible by F1- hot key

debug.action.ts

Add a new action to be dispatch to the store about the state change.

We prefix the type of the action with the name of the page that will dispatch this action.

import { createAction } from '@ngrx/store';

export const enableDebug = createAction('[Debug Page] enable');

debug.component.ts

Add a new component. If you like, you can keep template and styles inline. Both are not used in this setup, so can easily be excluded.

I have an empty template because its not needed.

When component is loaded it will dispatch an action that will enable DevTools (line 14). Effect is below, I promise!

in terminal:

ng generate component debug--inlineTemplate=true --inlineStyle=true

in new file debug.component.ts:

import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { RootState } from '../+state/root.selectors';
import { enableDebug } from './debug.actions';

@Component({
  template: ``,
  selector: 'app-debug'
})
export class DebugComponent implements OnInit {
  constructor(private readonly store: Store<RootState>) {}

  ngOnInit(): void {
    this.store.dispatch(enableDebug());
  }
}

debug.module.ts

This module is lazy-loaded so it will be registering the child routes.

debug.module.ts:

import { NgModule } from '@angular/core';
import { DebugComponent } from './debug.component';
import { RouterModule } from '@angular/router';
@NgModule({
  imports: [ 
      RouterModule.forChild([{ path:'',component:DebugComponent}])
  ],
  declarations: [DebugComponent],
  exports: [DebugComponent]

})
export class DebugModule {
  constructor(){}
}

app.module.ts

This module we will add a new route and lazy-load it with a feature module.

This is a code-snipet , so if you want to see full code check out my repository.

{
    path: 'debug',
    loadChildren: () =>
    import('./debug/debug.module').then(
      m => m.DebugModule
    )
      
  },

dev-tools.module.ts

This module will be compiled and created in the effect.

On line 14th you can refresh the tools. I didn’t see much of a difference in value having it execute.

import { NgModule } from '@angular/core';
import { StoreDevtoolsModule, StoreDevtools } from '@ngrx/store-devtools';
@NgModule({
  imports: [
    StoreDevtoolsModule.instrument({
      maxAge: 25, // Retains last 25 states
      logOnly: true
    })
  ]
})
export class DevToolsModule {
  constructor(private storeDevtools: StoreDevtools) {
    // storeDevtools.refresh();
  }
} 

debug.effect.ts

Effect class is responsible for reacting to the action dispatched from the component. It filters out actions that it is subscribing to and when enableDebug is dispatched, effect is using ‘exhaustMat’ operator to make sure that if same action is dispatched it will continue to execute. In other words dropping incoming request.

Next it uses injected compiler object to compile dev-tools.module and creates it on lines 21-24.

Once that is completed, we have devtools enabled and redux tools can be used to track our state changes.

We know that we will have a manual mechanism of turning debugging option on so we can redirect our user back to that location. In this case it’s root of the application or home. So the changeLink action is returned via this effect.

import { Injectable, Compiler, Injector } from '@angular/core';
import { createEffect, Actions, ofType } from '@ngrx/effects';

import * as DebugActions from './debug.actions';
import { exhaustMap } from 'rxjs/operators';
import { DevToolsModule } from './dev-tools-module'; 
import { changeLink } from '../+state/root.actions';

@Injectable()
export class DebugEffects {
  constructor(
    private actions$: Actions,
    private compiler: Compiler,
    private injector: Injector
  ) {}

  debugAction$ = createEffect(() =>
    this.actions$.pipe(
      ofType(DebugActions.enableDebug),
      exhaustMap(async () => {
        const m = this.compiler.compileModuleSync<DevToolsModule>(
          DevToolsModule
        );
        m.create(this.injector); 
        return changeLink({ link: '' });
      })
    )
  );
}

If you checkout my repository you will see that I just imported DevToolsModule into the DebugModule for simplicity. I kept the effect for other use case in Extra#2.

import { NgModule } from '@angular/core';
import { DebugComponent } from './debug.component';
import { RouterModule } from '@angular/router';
import { DevToolsModule } from './dev-tools-module';
@NgModule({
  imports: [ 
      RouterModule.forChild([{ path:'',component:DebugComponent}]),
      DevToolsModule
  ],
  declarations: [DebugComponent],
  exports: [DebugComponent]

})
export class DebugModule {
  constructor(){}
  
}

app.component.html

Add anywhere you would like to include a hidden button. So next time your user is having some unexplainable issues they can hit F1 and it will become visible.

Can you tell I am using material components? So if you this code make sure you import all necessary Modules for Material here.

<button *ngIf="isDebugVisible" (click)="onDebug()"mat-icon-button aria-label="debug">
  <mat-icon>bug_report</mat-icon>
</button>

app.component.ts

Extra #1: As promised I will give this little trick. I added hidden button to help user discover issues and be part of the debugging process.

We will add a function to make our button visible with HostListener, filtering out keys.

Next we will add a button click event that will redirect our user to the lazy-loaded module we just setup.

Remember: user will be redirected back here from the debug.effect?

So we can turn off the button – our devtools are enabled!

  onDebug() {
    this.store.dispatch(changeLink({ link: 'debug' }));
    this.isDebugVisible = false;
  }
  @HostListener('document:keydown', ['$event'])
  keypress(e: KeyboardEvent) {
    if (e.key === 'F1') {
      this.isDebugVisible = true;
    }
  }

Extras #2

If you haven’t heard of LogRocket yet. Let me Introduce you. www.logrocket.com This is a very cool product that you can plugin to the application to see a full path of your user experience with visual snap shots.

How cool is that?

If you follow their instruction it is pretty straight forward to include and get started with in your application.

https://app.logrocket.com/<replace-with-yourcode>/settings/integrations/

It is a paid product, if you exceed the number of requests that are free!

But let see if we can utilize a free version by only enabling the setup from our debug lazy-loaded module dev-tools.module?

update debug.effect.ts

import { Injectable, Compiler, Injector } from '@angular/core';
import { createEffect, Actions, ofType } from '@ngrx/effects';

import * as DebugActions from './debug.actions';
import { exhaustMap } from 'rxjs/operators';
import { DevToolsModule, LOGROCKET_INIT_KEY } from './dev-tools-module';
import * as LogRocket from 'logrocket'; 
import { changeLink } from '../+state/root.actions';

@Injectable()
export class DebugEffects {
  constructor(
    private actions$: Actions,
    private compiler: Compiler,
    private injector: Injector
  ) {}

  debugAction$ = createEffect(() =>
    this.actions$.pipe(
      ofType(DebugActions.enableDebug),
      exhaustMap(async () => {
        const m = this.compiler.compileModuleSync<DevToolsModule>(
          DevToolsModule
        );
        m.create(this.injector);
        LogRocket.init(LOGROCKET_INIT_KEY);
        return changeLink({ link: '' });
      })
    )
  );
}

update dev-tool.module.ts

import { NgModule } from '@angular/core';
import { StoreDevtoolsModule, StoreDevtools } from '@ngrx/store-devtools';
import { USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
import * as LogRocket from 'logrocket';
import createNgrxMiddleware from 'logrocket-ngrx';
import { MetaReducer, State } from '@ngrx/store'; 
import { RootState } from '../+state/root.selectors';
const logrocketMiddleware = createNgrxMiddleware(LogRocket, {});

export const metaReducers: MetaReducer<any>[] = [];

export function getMetaReducers(): MetaReducer<RootState>[] {
  return metaReducers.concat([logrocketMiddleware]);
}
export const LOGROCKET_INIT_KEY = '<replace-with-your-code';

@NgModule({
  imports: [
    StoreDevtoolsModule.instrument({
      maxAge: 25, // Retains last 25 states
      logOnly: true
    })
  ],
  providers: [
  
    {
      provide: USER_PROVIDED_META_REDUCERS,
      useFactory: getMetaReducers
    }
  ]}
)
export class DevToolsModule {
  constructor(private storeDevtools: StoreDevtools) {
    // storeDevtools.refresh();
  }
}

Now that we have this setup, we can see our requests are only feeding into our LogRocket account after we enabled debug!

I hope you enjoyed this article! Tweet you questions or comments here.

I will be glad to connect too!

If you would like to see a repository where I added this implementation, follow this github link:

https://github.com/katesky/saraphan-radio/tree/azure/apps/shell-app/src/app/debug