Skip to content

Commit

Permalink
Merge pull request #624 from namecheap/fix/broken_css_after_unmount
Browse files Browse the repository at this point in the history
fix(ilc): fix unmount css for embedded application by adding timeout
  • Loading branch information
m2broth authored Nov 12, 2024
2 parents 653e5e9 + 45ac1e4 commit c94b984
Show file tree
Hide file tree
Showing 8 changed files with 280 additions and 188 deletions.
8 changes: 3 additions & 5 deletions ilc/client/BundleLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,9 @@ export class BundleLoader {
});
const application =
typeof applicationConfig.cssBundle === 'string' && injectGlobalCss !== false
? new CssTrackedApp(
rawCallbacks,
applicationConfig.cssBundle,
this.#delayCssRemoval,
).getDecoratedApp()
? new CssTrackedApp(rawCallbacks, applicationConfig.cssBundle, {
delayCssRemoval: this.#delayCssRemoval,
}).getDecoratedApp()
: rawCallbacks;
return application;
});
Expand Down
170 changes: 0 additions & 170 deletions ilc/client/CssTrackedApp.js

This file was deleted.

204 changes: 204 additions & 0 deletions ilc/client/CssTrackedApp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import ilcEvents from './constants/ilcEvents';
import { CreateNewArgs, CreateNewReturnType, ILCAdapter } from './types/ILCAdapter';
import { CssTrackedOptions } from './types/CssTrackedOptions';
import { DecoratedApp } from './types/DecoratedApp';
import { LifeCycleFn } from 'single-spa';

type RouteChangeCallback = () => void;

export class CssTrackedApp {
static readonly linkUsagesAttribute: string = 'data-ilc-usages';
static readonly markedForRemovalAttribute: string = 'data-ilc-remove';

private originalApp: ILCAdapter;
private cssLinkUri: string;
// used to prevent removing CSS immediately after unmounting
private isRouteChanged: boolean = false;
private routeChangeListener?: RouteChangeCallback;
private options: Required<CssTrackedOptions>;

constructor(originalApp: ILCAdapter, cssLink: string, options: CssTrackedOptions = {}) {
const defaultOptions: Required<CssTrackedOptions> = {
removeCssTimeout: 300,
delayCssRemoval: false,
};

this.originalApp = originalApp;
// this assumes that we always have 1 link to CSS from one application
// real life might differ at some time
this.cssLinkUri = cssLink;
this.options = { ...defaultOptions, ...options };

if (!this.options.delayCssRemoval) {
// While CSS for an application rendered by another application is not always immediately necessary upon unmount,
// there is a non-trivial case to consider:
// - When the route changes and a spinner is enabled in the registry, the root application is unmounted and destroyed.
// - ILC then shows a copy of the previously rendered DOM node.
// - Which leads to a situation where both the root and inner applications unmount synchronously.
// Despite being unmounted, their styles are still required until the route transition is complete.
this.addRouteChangeListener();
}
}

getDecoratedApp = (): DecoratedApp => {
return {
...this.originalApp,
createNew: typeof this.originalApp.createNew === 'function' ? this.createNew : this.originalApp.createNew,
mount: this.mount,
unmount: this.unmount,
update: this.update,
__CSS_TRACKED_APP__: true,
};
};

createNew = (...args: CreateNewArgs): CreateNewReturnType => {
if (!this.originalApp.createNew) {
return undefined;
}

const newInstanceResult = this.originalApp.createNew(...args);
// if createNew does not return Promise it is not expected for dynamic apps
if (typeof newInstanceResult.then !== 'function') {
return newInstanceResult;
}

const [{ appConfig: { removeCssTimeout } = {} } = {}] = args;

return newInstanceResult.then((newInstance: ILCAdapter) => {
const requiredMethods: (keyof ILCAdapter)[] = ['mount', 'unmount', 'bootstrap'];
const isIlcAdapter = requiredMethods.every((m) => typeof newInstance[m] === 'function');
if (!isIlcAdapter) {
return newInstance;
}

return new CssTrackedApp(newInstance, this.cssLinkUri, {
removeCssTimeout,
delayCssRemoval: this.options.delayCssRemoval,
}).getDecoratedApp();
});
};

mount = async (...args: Parameters<LifeCycleFn<any>>): Promise<any> => {
const link = this.findLink();
if (link === null) {
await this.appendCssLink();
} else {
const numberOfUsages = this.getNumberOfLinkUsages(link);
link.setAttribute(CssTrackedApp.linkUsagesAttribute, (numberOfUsages + 1).toString());
link.removeAttribute(CssTrackedApp.markedForRemovalAttribute);
}

return this.callLifeCycleFn(this.originalApp.mount, ...args);
};

unmount = async (...args: Parameters<LifeCycleFn<any>>): Promise<any> => {
try {
return this.callLifeCycleFn(this.originalApp.unmount, ...args);
} finally {
const link = this.findLink();
if (link != null) {
this.decrementOrRemoveCssUsages(link);
}
this.removeRouteChangeListener();
}
};

update = async (...args: Parameters<LifeCycleFn<any>>): Promise<any | undefined> => {
if (!this.originalApp.update) {
return undefined;
}

return this.callLifeCycleFn(this.originalApp.update, ...args);
};

static removeAllNodesPendingRemoval(): void {
const allNodes = document.querySelectorAll(`link[${CssTrackedApp.markedForRemovalAttribute}]`);
Array.from(allNodes).forEach((node) => node.remove());
}

private appendCssLink(): Promise<void> {
return new Promise((resolve, reject) => {
const newLink = document.createElement('link');
newLink.rel = 'stylesheet';
newLink.href = this.cssLinkUri;
newLink.setAttribute(CssTrackedApp.linkUsagesAttribute, '1');
newLink.onload = () => resolve();
newLink.onerror = () => reject();
document.head.appendChild(newLink);
});
}

private decrementOrRemoveCssUsages(link: HTMLLinkElement): void {
const numberOfUsages = this.getNumberOfLinkUsages(link);
if (numberOfUsages <= 1) {
this.handleLinkRemoval(link);
} else {
link.setAttribute(CssTrackedApp.linkUsagesAttribute, (numberOfUsages - 1).toString());
}
}

private handleLinkRemoval(link: HTMLLinkElement): void {
if (this.shouldDelayRemoval()) {
this.markLinkForRemoval(link);
} else {
/**
* Embedded app might be wrapped by HOC that creates a clone of elements during transitions.
* We delay CSS removal to ensure both original and cloned elements
* are properly styled until the transition completes */
if (this.options.removeCssTimeout > 0) {
setTimeout(() => link.remove(), this.options.removeCssTimeout);
} else {
link.remove();
}
}
}

private shouldDelayRemoval(): boolean {
// If the route is changing, we should delay CSS removal to prevent visual glitches.
return this.options.delayCssRemoval || this.isRouteChanged;
}

private markLinkForRemoval(link: HTMLLinkElement): void {
link.removeAttribute(CssTrackedApp.linkUsagesAttribute);
link.setAttribute(CssTrackedApp.markedForRemovalAttribute, 'true');
}

private getNumberOfLinkUsages(link: HTMLLinkElement): number {
const existingValue = link.getAttribute(CssTrackedApp.linkUsagesAttribute);
return existingValue === null ? 0 : parseInt(existingValue, 10);
}

private findLink(): HTMLLinkElement | null {
return document.querySelector(`link[href="${this.cssLinkUri}"]`);
}

private handleRouteChange(): void {
this.isRouteChanged = true;
}

private addRouteChangeListener(): void {
if (!this.routeChangeListener) {
this.routeChangeListener = this.handleRouteChange.bind(this);
window.addEventListener(ilcEvents.BEFORE_ROUTING, this.routeChangeListener);
}
}

private removeRouteChangeListener(): void {
if (this.routeChangeListener) {
window.removeEventListener(ilcEvents.BEFORE_ROUTING, this.routeChangeListener);
this.routeChangeListener = undefined;
}
}

private async callLifeCycleFn<T>(
lifecycle: LifeCycleFn<T> | Array<LifeCycleFn<T>>,
...args: Parameters<LifeCycleFn<T>>
): Promise<any> {
if (Array.isArray(lifecycle)) {
// Map through lifecycle array and call each function with spread args
return Promise.all(lifecycle.map((fn) => fn(...args)));
}
// Call the single lifecycle function with spread args
return lifecycle(...args);
}
}
Loading

0 comments on commit c94b984

Please sign in to comment.