Skip to content

Commit

Permalink
feat(angular): use new targetDOMNode to render docs
Browse files Browse the repository at this point in the history
Rework the renderer to have 2 renderers : CanvasRenderer and DocsRenderer
The RendererService becomes deprecated with the ElementRendererService and can be removed soon
  • Loading branch information
ThibaudAV committed May 24, 2021
1 parent f28c88e commit 6e6ce8c
Show file tree
Hide file tree
Showing 12 changed files with 589 additions and 105 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/t
import { addSerializer } from 'jest-specific-snapshot';
import { getStorybookModuleMetadata } from '@storybook/angular/renderer';
import { BehaviorSubject } from 'rxjs';
import { getPlatform } from '@angular/core';

addSerializer(HTMLCommentSerializer);
addSerializer(AngularSnapshotSerializer);
Expand All @@ -13,7 +14,12 @@ function getRenderedTree(story: any) {
const currentStory = story.render();

const moduleMeta = getStorybookModuleMetadata(
{ storyFnAngular: currentStory, parameters: story.parameters },
{
storyFnAngular: currentStory,
parameters: story.parameters,
// TODO : To change with the story Id in v7. Currently keep with static id to avoid changes in snapshots
targetSelector: 'storybook-wrapper',
},
new BehaviorSubject(currentStory.props)
);

Expand Down
7 changes: 7 additions & 0 deletions app/angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@storybook/api": "6.3.0-alpha.25",
"@storybook/core": "6.3.0-alpha.25",
"@storybook/core-common": "6.3.0-alpha.25",
"@storybook/core-events": "6.3.0-alpha.25",
"@storybook/node-logger": "6.3.0-alpha.25",
"@types/webpack-env": "^1.16.0",
"autoprefixer": "^9.8.6",
Expand Down Expand Up @@ -108,8 +109,14 @@
"zone.js": "^0.8.29 || ^0.9.0 || ^0.10.0 || ^0.11.0"
},
"peerDependenciesMeta": {
"@angular/elements": {
"optional": true
},
"@nrwl/workspace": {
"optional": true
},
"@webcomponents/custom-elements": {
"optional": true
}
},
"engines": {
Expand Down
193 changes: 193 additions & 0 deletions app/angular/src/client/preview/angular-beta/AbstractRenderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/* eslint-disable no-undef */
import { enableProdMode, NgModule, PlatformRef } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { BehaviorSubject, Subject } from 'rxjs';
import { ICollection, StoryFnAngularReturnType } from '../types';
import { Parameters } from '../types-6-0';
import { createStorybookModule, getStorybookModuleMetadata } from './StorybookModule';

type StoryRenderInfo = {
storyFnAngular: StoryFnAngularReturnType;
moduleMetadataSnapshot: string;
};

// platform must be init only if the render is called at least once
let platformRef: PlatformRef;
function getPlatform(newPlatform?: boolean): PlatformRef {
if (!platformRef || newPlatform) {
platformRef = platformBrowserDynamic();
}
return platformRef;
}

export abstract class AbstractRenderer {
/**
* Wait and destroy the platform
*/
protected static resetPlatformBrowserDynamic() {
return new Promise<void>((resolve) => {
resolve();
if (platformRef && !platformRef.destroyed) {
platformRef.onDestroy(async () => {
await AbstractRenderer.resetCompiledComponents();
resolve();
});
// Destroys the current Angular platform and all Angular applications on the page.
// So call each angular ngOnDestroy and avoid memory leaks
platformRef.destroy();
return;
}
resolve();
}).then(() => {
getPlatform(true);
});
}

/**
* Reset compiled components because we often want to compile the same component with
* more than one NgModule.
*/
protected static resetCompiledComponents = async () => {
try {
// Clear global Angular component cache in order to be able to re-render the same component across multiple stories
//
// References:
// https://github.com/angular/angular-cli/blob/master/packages/angular_devkit/build_angular/src/webpack/plugins/hmr/hmr-accept.ts#L50
// https://github.com/angular/angular/blob/2ebe2bcb2fe19bf672316b05f15241fd7fd40803/packages/core/src/render3/jit/module.ts#L377-L384
const { ɵresetCompiledComponents } = await import('@angular/core');
ɵresetCompiledComponents();
} catch (e) {
/**
* noop catch
* This means angular removed or modified ɵresetCompiledComponents
*/
}
};

protected previousStoryRenderInfo: StoryRenderInfo;

// Observable to change the properties dynamically without reloading angular module&component
protected storyProps$: Subject<ICollection | undefined>;

constructor(public storyId: string, public targetDOMNode: HTMLElement) {
if (typeof NODE_ENV === 'string' && NODE_ENV !== 'development') {
try {
// platform should be set after enableProdMode()
enableProdMode();
} catch (e) {
// eslint-disable-next-line no-console
console.debug(e);
}
}
}

protected abstract beforeFullRender(): Promise<void>;

protected get targetSelector(): string {
return `${this.targetDOMNode.id}`;
}

/**
* Bootstrap main angular module with main component or send only new `props` with storyProps$
*
* @param storyFnAngular {StoryFnAngularReturnType}
* @param forced {boolean} If :
* - true render will only use the StoryFn `props' in storyProps observable that will update sotry's component/template properties. Improves performance without reloading the whole module&component if props changes
* - false fully recharges or initializes angular module & component
* @param parameters {Parameters}
*/
public async render({
storyFnAngular,
forced,
parameters,
}: {
storyFnAngular: StoryFnAngularReturnType;
forced: boolean;
parameters: Parameters;
}) {
const newStoryProps$ = new BehaviorSubject<ICollection>(storyFnAngular.props);
const moduleMetadata = getStorybookModuleMetadata(
{ storyFnAngular, parameters, targetSelector: this.targetSelector },
newStoryProps$
);

if (
!this.fullRendererRequired({
storyFnAngular,
moduleMetadata,
forced,
})
) {
this.storyProps$.next(storyFnAngular.props);

return;
}
await this.beforeFullRender();

// Complete last BehaviorSubject and set a new one for the current module
if (this.storyProps$) {
this.storyProps$.complete();
}
this.storyProps$ = newStoryProps$;

this.initAngularRootElement();

await getPlatform().bootstrapModule(createStorybookModule(moduleMetadata));
}

protected initAngularRootElement() {
// Adds DOM element that angular will use as bootstrap component
this.targetDOMNode.innerHTML = '';
this.targetDOMNode.appendChild(document.createElement(this.targetSelector));
}

protected reset(): void {
this.previousStoryRenderInfo = undefined;
if (this.storyProps$) this.storyProps$.unsubscribe();
this.storyProps$ = undefined;
}

private fullRendererRequired({
storyFnAngular,
moduleMetadata,
forced,
}: {
storyFnAngular: StoryFnAngularReturnType;
moduleMetadata: NgModule;
forced: boolean;
}) {
const { previousStoryRenderInfo } = this;

const currentStoryRender = {
storyFnAngular,
moduleMetadataSnapshot: JSON.stringify(moduleMetadata),
};

this.previousStoryRenderInfo = currentStoryRender;

if (
// check `forceRender` of story RenderContext
!forced ||
// if it's the first rendering and storyProps$ is not init
!this.storyProps$
) {
return true;
}

// force the rendering if the template has changed
const hasChangedTemplate =
!!storyFnAngular?.template &&
previousStoryRenderInfo?.storyFnAngular?.template !== storyFnAngular.template;
if (hasChangedTemplate) {
return true;
}

// force the rendering if the metadata structure has changed
const hasChangedModuleMetadata =
currentStoryRender?.moduleMetadataSnapshot !==
previousStoryRenderInfo?.moduleMetadataSnapshot;

return hasChangedModuleMetadata;
}
}
19 changes: 19 additions & 0 deletions app/angular/src/client/preview/angular-beta/CanvasRenderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { AbstractRenderer } from './AbstractRenderer';
import { StoryFnAngularReturnType } from '../types';
import { Parameters } from '../types-6-0';

export class CanvasRenderer extends AbstractRenderer {
public async render(options: {
storyFnAngular: StoryFnAngularReturnType;
forced: boolean;
parameters: Parameters;
}) {
await super.render(options).then(async () => {
await CanvasRenderer.resetCompiledComponents();
});
}

async beforeFullRender(): Promise<void> {
await CanvasRenderer.resetPlatformBrowserDynamic();
}
}
39 changes: 39 additions & 0 deletions app/angular/src/client/preview/angular-beta/DocsRenderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import addons from '@storybook/addons';
import Events from '@storybook/core-events';
import { AbstractRenderer } from './AbstractRenderer';
import { StoryFnAngularReturnType } from '../types';
import { Parameters } from '../types-6-0';

export class DocsRenderer extends AbstractRenderer {
public async render(options: {
storyFnAngular: StoryFnAngularReturnType;
forced: boolean;
parameters: Parameters;
}) {
// Note : no optimization on rendering when only args change
// the doc empties the html container every time, so we have to reset renderer
this.reset();

const channel = addons.getChannel();

/**
* Destroy and recreate the PlatformBrowserDynamic of angular
* For several stories to be rendered in the same docs we should
* not destroy angular between each rendering but do it when the
* rendered stories are not needed anymore.
*
* Note for improvement: currently there is one event per story
* rendered in the doc. But one event could be enough for the whole docs
*
*/
channel.once(Events.DOCS_TARGETTED_DESTROY, async () => {
await DocsRenderer.resetPlatformBrowserDynamic();
});

await super.render(options);
}

async beforeFullRender(): Promise<void> {
await DocsRenderer.resetCompiledComponents();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ import { Parameters } from '../types-6-0';
import { getStorybookModuleMetadata } from './StorybookModule';
import { RendererService } from './RendererService';

const customElementsVersions: Record<string, number> = {};

/**
* Bootstrap angular application to generate a web component with angular element
*/
Expand All @@ -29,62 +27,31 @@ export class ElementRendererService {
public async renderAngularElement({
storyFnAngular,
parameters,
targetDOMNode,
}: {
storyFnAngular: StoryFnAngularReturnType;
parameters: Parameters;
targetDOMNode: HTMLElement;
}): Promise<void> {
const id = `${targetDOMNode.id}`;

// Upgrade story version in order that the next defined component has a unique key
customElementsVersions[id] =
customElementsVersions[id] !== undefined ? customElementsVersions[id] + 1 : 0;

const targetSelector = `${id}_${customElementsVersions[id]}`;

}): Promise<CustomElementConstructor> {
const ngModule = getStorybookModuleMetadata(
{ storyFnAngular, parameters },
{ storyFnAngular, parameters, targetSelector: RendererService.SELECTOR_STORYBOOK_WRAPPER },
new BehaviorSubject<ICollection>(storyFnAngular.props)
);

await this.rendererService.newPlatformBrowserDynamic();

this.rendererService.initAngularRootElement(targetDOMNode);

await this.rendererService.platform
.bootstrapModule(createElementsModule(ngModule, targetSelector))
.then(async (m) => {
await this.rendererService.destroyPlatformBrowserDynamic();

/** Hack :
* After `destroyPlatformBrowserDynamic` we add the customElements previously created
* Note: If it is added before the `destroyPlatformBrowserDynamic` it will be deleted with it :/
* Not sure if this is the best way to do it.
* /!\ Does not work with ivy
*/
// eslint-disable-next-line no-param-reassign
targetDOMNode.innerHTML = '';
// eslint-disable-next-line no-undef
targetDOMNode.appendChild(document.createElement(targetSelector));
});
return this.rendererService
.newPlatformBrowserDynamic()
.bootstrapModule(createElementsModule(ngModule))
.then((m) => m.instance.ngEl);
}
}

const createElementsModule = (
ngModule: NgModule,
targetSelector: string
): Type<{ ngEl: CustomElementConstructor }> => {
@NgModule({ ...ngModule, entryComponents: [] })
const createElementsModule = (ngModule: NgModule): Type<{ ngEl: CustomElementConstructor }> => {
@NgModule({ ...ngModule })
class ElementsModule {
public ngEl: NgElementConstructor<unknown>;

constructor(private injector: Injector) {
this.ngEl = createCustomElement(ngModule.bootstrap[0] as Type<unknown>, {
injector: this.injector,
});
// eslint-disable-next-line no-undef
customElements.define(targetSelector, this.ngEl);
}

ngDoBootstrap() {}
Expand Down

0 comments on commit 6e6ce8c

Please sign in to comment.