Re-render component after change in custom Storybook add-on

Viewed 1961

I have a custom add-on using @storybook/angular. Everytime some value changes within the add-on, the Angular component inside the current story should re-render, hence it should re-initialize completely.

I tried to use forceReRender() from @storybook/angular, but it seems to do nothing. When I choose another story and open the previous story again, the changes are applied since the component is re-initialized. Is there a possibility to achieve this from inside the add-on as well?

1 Answers

I had a similar problem with a decorator + toolbar setup. Whenever my decorator changed a value inside the props, I needed to force the initialization of the component instead of only updating the bindings and calling change-detection.

So I added a wrapper component to force the re-initialization of the story:

// noinspection AngularMissingOrInvalidDeclarationInModule
/**
 * force the re-rendering of the story's components.
 */
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: 'sb-force-rendering',
  template: '<ng-container #outlet></ng-container>',
})
export class ForceRenderingComponent implements AfterViewInit, OnDestroy {
  @ContentChild(TemplateRef) public template: TemplateRef<any>
  @ViewChild('outlet', { read: ViewContainerRef }) outletRef: ViewContainerRef

  public static render$: Subject<void> = new Subject<void>()
  private subscription: Subscription = new Subscription()

  constructor(private ngZone: NgZone) {}

  public ngAfterViewInit(): void {
    this.doRender()
    this.subscription.add(ForceRenderingComponent.render$.subscribe(() => this.doRender()))
  }

  public ngOnDestroy(): void {
    this.subscription.unsubscribe()
  }

  private doRender(): void {
    // storybook re-rendering can be triggered outside from the angular zone
    // we need to re-enter the angular zone to ensure the components are still interactive
    this.ngZone.run(() => {
      this.outletRef.clear()
      this.outletRef.createEmbeddedView(this.template)
    })
  }
}

And this is how you would use the component in a decorator:

export const withSiteSwitch = (story: any, context: any) => {
  const site = context.globals.site
  const { moduleMetadata = {}, template = '', props = {}, ...rest } = story()
  const { declarations = [] } = moduleMetadata
  moduleMetadata.declarations = [...declarations, ForceRenderingComponent]
  document.documentElement.setAttribute('site', site)
  ForceRenderingComponent.render$.next() // triggers re-initialization
  return {
    ...rest,
    moduleMetadata,
    props: { ...props, site },
    template: `<sb-force-rendering><ng-template>${template}</ng-template></sb-force-rendering>`,
  }
}
Related