aurelia-hot-module-reload
Version:
Tools designed to enable HMR for Aurelia's loaders.
296 lines (250 loc) • 12.6 kB
text/typescript
import { Loader } from 'aurelia-loader'
import { DOM } from 'aurelia-pal';
import { Origin } from 'aurelia-metadata'
import { ViewEngine, ViewCompileInstruction } from 'aurelia-templating';
import { Container } from 'aurelia-dependency-injection';
import { _createCSSResource, CSSResource } from './hmr-css-resource';
import { ResourceModuleCorrect, AU, AUController, ViewFactoryWithTemplate } from './_typings';
import { TraversalInfo, traverseController } from './view-model-traverse-controller';
import { getElementsToRerender } from './view-traverse-controller';
import { rerenderMatchingSlotChildren, rerenderController } from './render-utils';
const UndefinedResourceModule = { id: null, mainResource: { metadata: {}, value: undefined } } as ResourceModuleCorrect;
export function getAuElements() {
return Array.from(DOM.querySelectorAll('.au-target')) as Array<Element & AU>;
}
export function getControllersWithClassInstances(oldPrototype: any) {
// get visible elements to re-render:
const auElements = getAuElements();
/* NOTE: viewless components like blur-image do not have el.au.controller set */
const controllersLists = auElements.map(el => el.au && Object.values(el.au) || []);
// list of unique controllers
const controllers = Array.from(
new Set<AUController>(([] as Array<any>).concat(...controllersLists))
);
const previouslyTraversed = new Set<any>();
const traversalInfo = ([] as Array<TraversalInfo>).concat(...controllers.map(parentController =>
traverseController(oldPrototype, parentController, {
previouslyTraversed,
parentController
})
));
return traversalInfo;
}
export class HmrContext {
viewEngine = Container.instance.get(ViewEngine) as ViewEngine & { moduleAnalyzer: any, container: Container };
moduleAnalyzerCache: { [moduleId: string]: ResourceModuleCorrect } = this.viewEngine.moduleAnalyzer.cache;
constructor (public loader: Loader & { moduleRegistry: Object, templateRegistry: Object }) {
const styleResourcePlugin = {
fetch: (moduleId: string) => {
return {
[moduleId]: _createCSSResource(moduleId)
};
},
hot: (moduleId: string) => {
this.reloadCss(moduleId);
}
};
['.css', '.less', '.sass', '.scss', '.styl'].forEach(ext => this.viewEngine.addResourcePlugin(ext, styleResourcePlugin));
}
/**
* Handles ViewModel changes
*/
async handleModuleChange(moduleId: string, hot: any) {
// get old version of the module:
const previousModule = (this.loader.moduleRegistry as any)[moduleId];
if (!previousModule) {
return;
}
console.log(`Running default HMR for ${moduleId}`);
// reload fresh module:
delete (this.loader.moduleRegistry as any)[moduleId];
const newModule = await this.loader.loadModule(moduleId);
const oldResourceModule = this.moduleAnalyzerCache[moduleId];
let newResourceModule: ResourceModuleCorrect;
if (oldResourceModule) {
// almost the same as: ViewEngine.importViewModelResource(moduleId, moduleMember);
const origin = Origin.get(newModule);
const normalizedId = origin.moduleId;
const moduleMember = origin.moduleMember;
newResourceModule = this.viewEngine.moduleAnalyzer.analyze(normalizedId, newModule, moduleMember) as ResourceModuleCorrect;
if (!newResourceModule.mainResource && !newResourceModule.resources) {
hot.decline(moduleId);
return;
}
if (newResourceModule.mainResource) {
newResourceModule.initialize(this.viewEngine.container);
}
// monkey patch old resource module:
// would be better to simply replace it everywhere
Object.assign(oldResourceModule, newResourceModule);
}
// TODO: kinda CompositionEngine.ensureViewModel()
// TODO: to replace - use closest container: childContainer.get(viewModelResource.value);
if (previousModule instanceof Object) {
// console.log(`Analysing ${moduleId} as a whole`);
// getControllersWithClassInstances(previousModule, undefined);
const keys = Object.keys(previousModule);
keys.forEach(key => {
const newExportValue = newModule[key];
if (!newExportValue) {
return;
}
const previousExportValue = previousModule[key];
const type = typeof previousExportValue;
if (type === 'function' || type === 'object') {
// these are the only exports we can reliably replace (classes, objects and functions)
console.log(`Analyzing ${moduleId}->${key}`);
const traversalInfo = getControllersWithClassInstances(previousExportValue);
// console.log(traversalInfo);
traversalInfo.forEach(info => {
if (info.propertyInParent === undefined) {
return;
}
if (info.instance) {
const entry = info.immediateParent[info.propertyInParent];
const newPrototype = newExportValue.prototype;
if (newPrototype) {
Object.setPrototypeOf(entry, newPrototype);
} else {
console.warn(`No new prototype for ${moduleId}->${key}`);
}
if (info.relatedView && info.relatedView.isBound) {
const {bindingContext, overrideContext} = info.relatedView;
info.relatedView.unbind();
info.relatedView.bind(bindingContext, overrideContext);
}
// if (info.parentController && info.parentController.isBound) {
// const scope = info.parentController.scope;
// info.parentController.unbind();
// info.parentController.bind(scope);
// }
} else {
console.log(`Replacing`, info.immediateParent[info.propertyInParent], `with`, newExportValue);
info.immediateParent[info.propertyInParent] = newExportValue;
}
});
}
});
}
// find all instances of the Classes
}
/**
* Handles Hot Reloading when a View changes
*
* TODO: make a queue of changes and handle after few ms multiple TOGETHER
*/
async handleViewChange(moduleId: string) {
const templateModuleId = this.loader.applyPluginToUrl(moduleId, 'template-registry-entry');
console.log(`Handling HMR for ${moduleId}`)
// get old entry:
let entry = this.loader.getOrCreateTemplateRegistryEntry(moduleId);
// delete it, and the module from caches:
delete (this.loader.templateRegistry as any)[moduleId];
delete (this.loader.moduleRegistry as any)[moduleId];
delete (this.loader.moduleRegistry as any)[templateModuleId];
// reload template (also done in loadViewFactory):
// await this.loader.templatethis.loader.loadTemplate(loader, entry);
const originalFactory = entry.factory as ViewFactoryWithTemplate; // htmlBehaviorResource.viewFactory
// just to be safe, lets patch up the old ViewFactory
if (!originalFactory) {
console.error(`Something's gone wrong, no original ViewFactory?!`)
return
}
const { mainResource, id: associatedModuleId } = this.getResourceModuleByTemplate(originalFactory.template);
const { metadata: htmlBehaviorResource, value: targetClass } = mainResource;
if (entry.factory !== htmlBehaviorResource.viewFactory) {
console.info(`Different origin factories`, entry.factory, htmlBehaviorResource.viewFactory)
}
// TODO: find a way to find CSS removed from templates and unload it
const compileInstruction = new ViewCompileInstruction(htmlBehaviorResource.targetShadowDOM, true);
compileInstruction.associatedModuleId = associatedModuleId;
const newViewFactory = (await this.viewEngine.loadViewFactory(moduleId, compileInstruction, null as any, targetClass)) as ViewFactoryWithTemplate;
// TODO: keep track of hidden Views, e.g.
// using beforeBind or mutation-observers https://dev.opera.com/articles/mutation-observers-tutorial/
// NOTES:
// the document-fragment in the newViewFactory has different numbers for the same resources:
// newViewFactory.instructions -- have different numbers than originalFactory
// newViewFactory.resources.elements -- contains the resources of children but not the SELF HtmlBehaviorResource
// monkey-patch the template just in case references to it are lying still around somewhere:
originalFactory.template = newViewFactory.template;
originalFactory.instructions = newViewFactory.instructions;
originalFactory.resources = newViewFactory.resources;
// TODO: it might be best to replace the instance of the ViewFactory in the HtmlBehaviorResource:
// but THIS CAUSES LOOPING WITH NESTED ELEMENTS:
// if (htmlBehaviorResource.viewFactory) {
// htmlBehaviorResource.viewFactory = newViewFactory;
// }
const elementsToReRender = getElementsToRerender(originalFactory.template);
const factoryToRenderWith = newViewFactory;
// const factoryToRenderWith = originalFactory;
elementsToReRender.slots.forEach(slot => rerenderMatchingSlotChildren(slot, factoryToRenderWith, originalFactory.template));
elementsToReRender.viewControllers.forEach(e => rerenderController(e, 'view', factoryToRenderWith));
elementsToReRender.scopeControllers.forEach(e => rerenderController(e, 'scope', factoryToRenderWith));
}
/**
* handles hot-reloading CSS modules
*/
reloadCss(moduleId: string) {
if (!(moduleId in this.loader.moduleRegistry)) {
return; // first load
}
const extensionIndex = moduleId.lastIndexOf('.');
const moduleExtension = moduleId.substring(extensionIndex + 1);
const pluginName = `${moduleExtension}-resource-plugin`;
const cssPluginModuleId = this.loader.applyPluginToUrl(moduleId, pluginName);
console.log(`Handling HMR for ${moduleId}`);
delete (this.loader.moduleRegistry as any)[moduleId];
delete (this.loader.moduleRegistry as any)[cssPluginModuleId];
const analyzedModule = this.moduleAnalyzerCache[cssPluginModuleId];
if (typeof analyzedModule === 'undefined') {
console.error(`Unable to find module, check the plugin exists and the module has been loaded with the expected plugin`);
return;
} else if (!analyzedModule.resources || !analyzedModule.resources.length) {
console.error(`Something's wrong, no resources for this CSS file ${moduleId}`);
return;
}
const mainResource = analyzedModule.resources[0];
const cssResource = mainResource.metadata as CSSResource;
if (cssResource._scoped && cssResource._scoped.injectedElements.length) {
console.error(`Hot Reloading scopedCSS is not yet supported!`);
return;
// cssResource._scoped.injectedElements.forEach(el => el.remove());
}
if (cssResource.injectedElement) {
cssResource.injectedElement.remove();
}
// reload resource
cssResource.load(Container.instance);
}
getResourceModuleByTemplate(template: any) {
// find the related ResourceModule (if any)
const relatedResourceModule = Object.values(this.moduleAnalyzerCache).find(resourceModule =>
resourceModule.mainResource &&
resourceModule.mainResource.metadata &&
resourceModule.mainResource.metadata.viewFactory &&
resourceModule.mainResource.metadata.viewFactory.template === template
)
return relatedResourceModule || UndefinedResourceModule;
}
getResourceModuleById(moduleId: string) {
return moduleId in this.moduleAnalyzerCache ? this.moduleAnalyzerCache[moduleId] : UndefinedResourceModule;
}
// these two helpers might be helpful in the future:
/*
getResourceModule(moduleId: string) {
// find the related ResourceModule (if any)
const relatedResourceModule = Object.values(this.moduleAnalyzerCache).find(resourceModule => {
const dependencies = resourceModule.loadContext && resourceModule.loadContext.dependencies;
return dependencies && Object.keys(dependencies).indexOf(moduleId) >= 0;
}) as ResourceModule
return relatedResourceModule || UndefinedResourceModule;
}
getResourceModuleByBehaviorResource(htmlBehaviorResource: any) {
// find the related ResourceModule (if any)
const relatedResourceModule = Object.values(this.moduleAnalyzerCache).find(resourceModule =>
resourceModule.mainResource && resourceModule.mainResource.metadata === htmlBehaviorResource
) as ResourceModule
return relatedResourceModule || UndefinedResourceModule;
}
*/
}