feat(dev-server-hmr): implement HMR using proxies

This commit is contained in:
Lars den Bakker
2020-12-21 20:33:08 +01:00
parent 5b774aab9f
commit cd44285549
29 changed files with 553 additions and 432 deletions

View File

@@ -1,5 +1,5 @@
---
"@open-wc/eslint-config": minor
'@open-wc/eslint-config': minor
---
Update eslint-plugin-lit to ^1.3.0

View File

@@ -0,0 +1,5 @@
---
'@open-wc/dev-server-hmr': patch
---
implement HMR using proxies

View File

@@ -6,11 +6,11 @@
Keeps track of web component definitions in your code, and updates them at runtime on change. This is faster than a full page reload and preserves the page's state.
HMR requires the web component base class to implement a `hotReplaceCallback`.
HMR requires the web component base class to implement a `hotReplacedCallback`.
## Installation
First install [@web/dev-server](https://modern-web.dev/docs/dev-server/overview/) if you don't already have this installed in your project.
First, install [@web/dev-server](https://modern-web.dev/docs/dev-server/overview/) if you don't already have this installed in your project.
Install the package:
@@ -32,15 +32,15 @@ export default {
};
```
Pick one of the presets below if needed, then start the dev server like normal. You don't need to make any changes to your code. If a component or one of it's dependencies is changed, the component is replaced. Otherwise the page is reloaded.
Pick one of the presets below if needed, then start the dev server like normal. You don't need to make any changes to your code. If a component or one of its dependencies is changed, the component is replaced. Otherwise, the page is reloaded.
> Make sure to start the dev server without `watch` mode, as this always forces a page reload on change.
> Make sure to start the dev server without `watch` mode, as this always forces a page to reload on change.
## Implementations
### Vanilla
For vanilla web component projects that don't implement any base class or library this plugin should detect your components correctly. Read more below on how to implement the `hotReplaceCallback`.
For vanilla web component projects that don't implement any base class or library this plugin should detect your components automatically. You need to implement a `hotReplacedCallback` on your element to trigger a re-render, read more about that below.
### LitElement
@@ -76,13 +76,50 @@ export default {
};
```
### Haunted
We have experimental support for Haunted using a small code patch included in the preset. This might not cover all use cases yet, let us know if you run into any issues!
```js
import { hmrPlugin, presets } from '@open-wc/dev-server-hmr';
export default {
plugins: [
hmrPlugin({
include: ['src/**/*'],
presets: [presets.haunted],
}),
],
};
```
### Other libraries
If you know any other libraries that work correctly with HMR we can add presets for them here. Presets help by configuring the detection of base classes, decorators, and/or runtime code patches.
## How it works
Web component HMR works by replacing class references and instance prototype chains with proxies. Whenever a class or a property on the prototype chain is accessed, the proxy will forward to the latest implementation of the web component class.
After updating a web component class, newly created elements will use the latest class and things work as expected from there.
For existing elements, the prototype chain is updated to reference the new class. This means that things like class methods are updated, but local class fields or properties are not. This is a feature because it retains component state, but also a limitation because newly added fields/properties are not available. The constructor is also not re-run for existing elements.
Web component HMR works best when editing HTML and CSS. Because we're overwriting and moving around code at runtime, assumptions you can normally make about how your code runs is broken. It's recommended to periodically do a full refresh of the page.
### Limitations
The following limitations should be kept in mind when working with open-wc HMR:
- For existing elements, constructors are not re-run when updating a class.
- For existing elements, newly added fields/properties are not available.
- A web component's `observedAttributes` list cannot be updated over time. Updates require a refresh.
> Did you run into other limitations? Let us know so we can improve this list.
## Detecting web components
To "hot replace" an edited web component we have to be able to detect component definitions in your code. By default we look for usage of `customElements.define` and extending from `HTMLElement`.
To "hot replace" an edited web component we have to be able to detect component definitions in your code. By default we look for usage of `customElements.define` and/or classes that from `HTMLElement` directly.
For other use cases, you can specify base classes or decorators to indicate component definitions.
@@ -148,83 +185,30 @@ hmrPlugin({
});
```
## Limitations
HMR workflows are not perfect. We're overwriting and moving around code at runtime. It breaks assumptions you normally make about your code. We recommended periodically doing a full refresh of the page, especially when you encounter strange behavior.
The following limitations should be kept in mind when working with open-wc HMR:
- Modules containing web components are re-imported under a new name and only the web component class is replaced. Side effects are triggered again but exported symbols are not updated.
- Constructors for already created elements are not re-run when a class is replaced. Otherwise it would reset the properties of your element. This does mean that newly added properties don't show up.
- Instance class fields act like properties defined in a constructor, and newly added or changed class fields are not hot replaced.
- Newly created elements do use the new constructors and class fields.
> Did you run into other limitations? Let us know so we can improve this list.
## Implementing HMR
When hot replacing a web component class we can't replace the actual class. The custom element registry doesn't allow re-registration and we want to preserve the state of already rendered components. Instead, we patch the initial class with the properties from the updates class.
This updating logic can be different for each base class, and it can be implemented using the `hotReplaceCallback`.
This is the default implementation:
To implement HMR your element or element's base class should implement one of the `hotReplacedCallback` methods. In your method
you can do custom updating logic, and kick off re-rendering of your element.
```js
function updateObjectMembers(currentObj, newObj) {
const currentProperties = new Set(Object.getOwnPropertyNames(hmrClass));
const newProperties = new Set(Object.getOwnPropertyNames(newClass));
// add new and overwrite existing properties/methods
for (const prop of Object.getOwnPropertyNames(newClass)) {
const descriptor = Object.getOwnPropertyDescriptor(newClass, prop);
if (descriptor && descriptor.configurable) {
Object.defineProperty(hmrClass, prop, descriptor);
}
}
// delete removed properties
for (const existingProp of currentProperties) {
if (!newProperties.has(existingProp)) {
try {
delete hmrClass[existingProp];
} catch {}
}
}
}
class MyElement extends HTMLElement {
// static callback, called once when a class updates
static hotReplaceCallback(newClass) {
updateObjectMembers(this, newClass);
updateObjectMembers(this.prototype, newClass.prototype);
static hotReplacedCallback() {
this.update();
}
// instance callback, called for each connected element
hotReplaceCallback() {
hotReplacedCallback() {
// this should kick off re-rendering
this.update();
this.rerender();
}
}
```
## HMR implementation
This plugin currently only works for web dev server. The approach should be compatible with other ESM-HMR implementations in other dev servers. This is something that can be explored.
This plugin currently only works for Web Dev Server. The approach should be compatible with other ESM-HMR implementations in other dev servers. This is something that can be explored.
Compatibility with non es modules HMR, such as webpack, is not currently a goal.
### Static callback
The static `hotReplaceCallback` callback is called once for each replacement on the initial class of the component. This is where you can copy over properties from the new class to the existing class.
Implementing this callback is not mandatory, by default we copy over properties of the new class to the existing class. If this is not sufficient, you can customize this logic.
### Instance callback
The instance callback is called on each connected element implementing the replaced class. Implementing this is necessary to do some work at the instance level, such as trigger a re-render or style update.
When the instance callback is called, all the class members (properties, methods, etc.) have already been updated. So it could be as simple as kicking off the regular updating/rendering pipeline.
### Patching
If you don't want to include the HMR code in your production code, you could patch in the callbacks externally:
@@ -232,11 +216,11 @@ If you don't want to include the HMR code in your production code, you could pat
```js
import { MyElement } from 'my-element';
MyElement.hotReplaceCallback = function hotReplaceCallback(newClass) {
MyElement.hotReplacedCallback = function hotReplacedCallback() {
// code for the static callback
};
MyElement.prototype.hotReplaceCallback = function hotReplaceCallback(newClass) {
MyElement.prototype.hotReplacedCallback = function hotReplacedCallback() {
// code for the instance callback
};
```
@@ -249,11 +233,11 @@ import { hmrPlugin } from '@open-wc/dev-server-hmr';
const myElementPatch = `
import { MyElement } from 'my-element';
MyElement.hotReplaceCallback = function hotReplaceCallback(newClass) {
MyElement.hotReplacedCallback = function hotReplacedCallback() {
// code for the static callback
};
MyElement.prototype.hotReplaceCallback = function hotReplaceCallback(newClass) {
MyElement.prototype.hotReplacedCallback = function hotReplacedCallback() {
// code for the instance callback
};
`;

View File

@@ -0,0 +1,6 @@
- make sure there is a unique name
Document shortcomings:
- added properties
- added attributeChangedCallbacks

View File

@@ -7,7 +7,7 @@ export default {
plugins: [
hmrPlugin({
exclude: ['**/*/node_modules/**/*'],
functions: [{ name: 'component', import: 'haunted' }],
presets: [presets.haunted],
}),
],
};

View File

@@ -65,7 +65,7 @@ function TodoList() {
.message=${item.message}
.checked=${item.checked}
data-i=${i}
@checked-changed=${this._onCheckedChanged}
@checked-changed=${onCheckedChanged}
@delete=${onDelete}
></todo-item>
</li>

View File

@@ -0,0 +1,17 @@
import { html, css, LitElement } from 'lit-element';
export class SharedClass extends LitElement {
static styles = css`
:host {
display: block;
border: 1px solid black;
margin: 16px 0;
padding: 0 8px;
width: 200px;
}
`;
sharedTemplate() {
return html`<p>Shared class</p>`;
}
}

View File

@@ -1,11 +1,20 @@
import { LitElement, html } from 'lit-element';
import { sharedTemplate } from './sharedTemplate.js';
import { sharedStyles } from './sharedStyles.js';
import './todo-list.js';
import './todo-shared-a.js';
import './todo-shared-b.js';
class TodoApp extends LitElement {
static styles = sharedStyles;
render() {
return html`
<h1>Todo app</h1>
<todo-list></todo-list>
${sharedTemplate}
<todo-shared-a></todo-shared-a>
<todo-shared-b></todo-shared-b>
`;
}
}

View File

@@ -1,12 +1,9 @@
import { LitElement, html, css } from 'lit-element';
import { sharedTemplate } from './sharedTemplate.js';
import { sharedStyles } from './sharedStyles.js';
import './todo-item.js';
class TodoList extends LitElement {
static get styles() {
return [
sharedStyles,
css`
ul {
list-style: none;
@@ -26,7 +23,9 @@ class TodoList extends LitElement {
}
static get properties() {
return { items: { type: Object } };
return {
items: { type: Object },
};
}
constructor() {
@@ -66,8 +65,6 @@ class TodoList extends LitElement {
Add
</button>
</div>
${sharedTemplate}
`;
}

View File

@@ -0,0 +1,24 @@
import { html, css } from 'lit-element';
import { SharedClass } from './SharedClass';
class TodoSharedA extends SharedClass {
static get styles() {
return [
super.styles,
css`
.shared-a {
color: red;
}
`,
];
}
render() {
return html`
<p class="shared-a">TODO Shared A</p>
${this.sharedTemplate()}
`;
}
}
customElements.define('todo-shared-a', TodoSharedA);

View File

@@ -0,0 +1,24 @@
import { html, css } from 'lit-element';
import { SharedClass } from './SharedClass';
class TodoSharedB extends SharedClass {
static get styles() {
return [
super.styles,
css`
.shared-b {
color: green;
}
`,
];
}
render() {
return html`
<p class="shared-b">TODO Shared B</p>
${this.sharedTemplate()}
`;
}
}
customElements.define('todo-shared-b', TodoSharedB);

View File

@@ -11,7 +11,7 @@ export class BaseClass extends HTMLElement {
this.update();
}
hotReplaceCallback() {
hotReplacedCallback() {
this.update();
}

View File

@@ -1,4 +1,6 @@
/** @typedef {import('@babel/core').PluginObj} PluginObj */
/** @typedef {import('@babel/types').ClassDeclaration} ClassDeclaration */
/** @typedef {import('@babel/types').Expression} Expression */
/** @template T @typedef {import('@babel/core').NodePath<T>} NodePath<T> */
/**
@@ -28,14 +30,13 @@
* @property {string[]} [patches]
*/
const { types } = require('@babel/core');
const path = require('path');
const { findDefinedCustomElement } = require('./customElementsDefine');
const { findDecoratedCustomElement } = require('./decorators');
const { injectRegisterClass, injectRuntime } = require('./inject');
const { parseOptions, singlePath, addToSet } = require('./utils');
const { findFunctionComponent } = require('./functions');
const { isFunctionComponent } = require('./functions');
const { getImportedVariableNames } = require('./getImportedVariableNames');
const { implementsBaseClass } = require('./class');
const { createError } = require('../utils');
@@ -58,22 +59,16 @@ function babelPluginWcHmr() {
const decoratorNames = new Set();
/** @type {Set<string>} */
const functionNames = new Set();
/** @type {Set<string>} */
const injectedClassNames = new Set();
let injectedRegister = false;
baseClassNames.add('HTMLElement');
/**
* @param {NodePath<any>} nodePath
* @param {string} name
* @param {boolean} insertAfter
* @param {NodePath<ClassDeclaration> | NodePath<Expression>} nodePath
*/
function maybeInjectRegister(nodePath, name, insertAfter = false) {
if (injectedClassNames.has(name)) {
return;
}
injectRegisterClass(nodePath, name, insertAfter);
function injectRegister(nodePath) {
injectRegisterClass(nodePath);
injectedRegister = true;
injectedClassNames.add(name);
}
// add decorators that don't require their import to be checked
@@ -108,14 +103,12 @@ function babelPluginWcHmr() {
});
program.traverse({
VariableDeclaration(varDecl) {
const name = findFunctionComponent(varDecl, functionNames);
if (name) {
maybeInjectRegister(varDecl, name);
}
},
CallExpression(callExpr) {
if (callExpr.isCallExpression() && isFunctionComponent(callExpr, functionNames)) {
injectRegister(/** @type {NodePath<Expression>} */ (callExpr));
return;
}
const callee = callExpr.get('callee');
const args = callExpr.get('arguments');
if (!singlePath(callee) || !Array.isArray(args)) {
@@ -129,30 +122,7 @@ function babelPluginWcHmr() {
return;
}
if (definedCustomElement.isIdentifier()) {
maybeInjectRegister(callExpr, definedCustomElement.node.name);
}
if (
definedCustomElement.isClassExpression() ||
definedCustomElement.isCallExpression()
) {
// take inline class expression out of the define so that it can be registered
const id = callExpr.scope.generateUidIdentifierBasedOnNode(
definedCustomElement.node,
);
const { name } = id;
if (!injectedClassNames.has(name)) {
callExpr.insertBefore(
types.variableDeclaration('const', [
types.variableDeclarator(id, definedCustomElement.node),
]),
);
definedCustomElement.replaceWith(id);
maybeInjectRegister(callExpr, name);
}
}
injectRegister(definedCustomElement);
return;
}
@@ -168,13 +138,7 @@ function babelPluginWcHmr() {
return;
}
if (decoratedCustomElement.isIdentifier()) {
let assignExpr = callExpr.parentPath;
while (assignExpr && assignExpr.isAssignmentExpression()) {
assignExpr = assignExpr.parentPath;
}
maybeInjectRegister(assignExpr, decoratedCustomElement.node.name);
}
injectRegister(decoratedCustomElement);
}
}
},
@@ -182,7 +146,7 @@ function babelPluginWcHmr() {
ClassDeclaration(classDecl) {
// this is a class declaration like class A extends HTMLElement {}
if (implementsBaseClass(classDecl, baseClassNames)) {
maybeInjectRegister(classDecl, classDecl.node.id.name, true);
injectRegister(classDecl);
}
},
@@ -205,8 +169,8 @@ function babelPluginWcHmr() {
}
// this is a class expression assignment like const A = class B extends HTMLElement {}
if (implementsBaseClass(classExpr, baseClassNames)) {
maybeInjectRegister(classExpr, id.node.name, true);
if (classExpr.isExpression() && implementsBaseClass(classExpr, baseClassNames)) {
injectRegister(classExpr);
}
},
});

View File

@@ -1,8 +1,11 @@
/* eslint-disable no-console */
/** @typedef {import('@babel/types').MemberExpression} MemberExpression */
/** @typedef {import('@babel/types').CallExpression} CallExpression */
/** @typedef {import('@babel/types').ClassDeclaration} ClassDeclaration */
/** @typedef {import('@babel/types').Expression} Expression */
/** @template T @typedef {import('@babel/core').NodePath<T>} NodePath<T> */
const { resolvePath, singlePath } = require('./utils');
const { resolvePath, findComponentDefinition, singlePath } = require('./utils');
const GLOBALS = ['window', 'self', 'globalThis'];
@@ -67,21 +70,13 @@ function isCallOnCustomElementObject(memberExpr) {
return false;
}
/** @param {NodePath<any>[]} args */
function getDefinedClass(args) {
if (!args || !Array.isArray(args)) {
return;
}
return args[1];
}
/**
* @param {NodePath<MemberExpression>} memberExpr
* @param {NodePath<any>[]} args
*/
function findDefinedCustomElement(memberExpr, args) {
if (isDefineCall(memberExpr, args) && isCallOnCustomElementObject(memberExpr)) {
return getDefinedClass(args);
return findComponentDefinition(args[1]);
}
}

View File

@@ -2,7 +2,7 @@
/** @typedef {import('./babelPluginWcHmr').Decorator} Decorator */
/** @template T @typedef {import('@babel/core').NodePath<T>} NodePath<T> */
const { singlePath } = require('./utils');
const { findComponentDefinition, singlePath } = require('./utils');
/**
* @param {Set<string>} decoratorNames
@@ -44,7 +44,9 @@ function findCompiledTsDecoratedCustomElement(decoratorNames, callee, args) {
if (!decoratorCall) {
return;
}
return decoratedClass;
const found = findComponentDefinition(decoratedClass);
return found;
}
/**

View File

@@ -1,4 +1,5 @@
/** @typedef {import('@babel/types').VariableDeclaration} VariableDeclaration */
/** @typedef {import('@babel/types').CallExpression} CallExpression */
/** @typedef {import('@babel/types').Identifier} Identifier */
/** @typedef {import('./babelPluginWcHmr').Decorator} Decorator */
/** @template T @typedef {import('@babel/core').NodePath<T>} NodePath<T> */
@@ -6,48 +7,15 @@
const { singlePath } = require('./utils');
/**
* @param {NodePath<any>} decl
* @param {NodePath<CallExpression>} callExpr
* @param {Set<string>} functionNames
* @returns {string | undefined}
*/
function getFunctionComponentName(decl, functionNames) {
if (!decl.isVariableDeclarator()) {
return;
}
const id = decl.get('id');
const init = decl.get('init');
if (!singlePath(id) || !id.isIdentifier() || !singlePath(init) || !init.isCallExpression()) {
return;
}
const callee = init.get('callee');
function isFunctionComponent(callExpr, functionNames) {
const callee = callExpr.get('callee');
if (!singlePath(callee) || !callee.isIdentifier()) {
return;
}
if (functionNames.has(callee.node.name)) {
return id.node.name;
return false;
}
return functionNames.has(callee.node.name);
}
/**
* @param {NodePath<VariableDeclaration>} varDecl
* @param {Set<string>} functionNames
* @returns {string | undefined}
*/
function findFunctionComponent(varDecl, functionNames) {
const declarations = varDecl.get('declarations');
if (!Array.isArray(declarations)) {
return;
}
for (const decl of declarations) {
const name = getFunctionComponentName(decl, functionNames);
if (name) {
return name;
}
}
}
module.exports = { findFunctionComponent };
module.exports = { isFunctionComponent };

View File

@@ -1,23 +1,74 @@
/** @typedef {import('@babel/types').Program} Program */
/** @typedef {import('@babel/types').ClassDeclaration} ClassDeclaration */
/** @typedef {import('@babel/types').Expression} Expression */
/** @typedef {import('./babelPluginWcHmr').BabelPluginWcHmrOptions} BabelPluginWcHmrOptions */
/** @template T @typedef {import('@babel/core').NodePath<T>} NodePath<T> */
const { parse } = require('@babel/core');
const { parse, types: t } = require('@babel/core');
const { WC_HMR_NAMESPACE, WC_HMR_MODULE_PATCH, WC_HMR_MODULE_RUNTIME } = require('../constants');
const { singlePath } = require('./utils');
const REGISTER_FN_NAME = 'register';
/**
* @template T
* @param {NodePath<T>} nodePath
* @param {string} name
* @param {boolean} insertAfter
* @param {NodePath<any>} callExpr
*/
function injectRegisterClass(nodePath, name, insertAfter) {
const toInject = parse(`${WC_HMR_NAMESPACE}.register(import.meta.url, ${name})`);
if (!toInject) throw new TypeError('Failed to parse');
if (insertAfter) {
nodePath.insertAfter(toInject);
} else {
nodePath.insertBefore(toInject);
function isRegisterCall(callExpr) {
if (!singlePath(callExpr) || !callExpr.isCallExpression()) return;
const callee = callExpr.get('callee');
if (!singlePath(callee) || !callee.isMemberExpression()) return;
const obj = callee.get('object');
const prop = callee.get('property');
if (!singlePath(obj) || !singlePath(prop)) return;
return (
obj.isIdentifier() &&
obj.node.name === WC_HMR_NAMESPACE &&
prop.isIdentifier() &&
prop.node.name === REGISTER_FN_NAME
);
}
/**
* @param {NodePath<ClassDeclaration> | NodePath<Expression>} clazz
*/
function isAlreadyRegistered(clazz) {
const callExpr = clazz.parentPath;
return isRegisterCall(clazz) || isRegisterCall(callExpr);
}
/**
* Wraps a class declaration or expression into a register call
* @param {NodePath<ClassDeclaration> | NodePath<Expression> } clazz
*/
function injectRegisterClass(clazz) {
if (isAlreadyRegistered(clazz)) return;
const callee = t.memberExpression(t.identifier(WC_HMR_NAMESPACE), t.identifier(REGISTER_FN_NAME));
const importMetaUrl = t.memberExpression(
t.metaProperty(t.identifier('import'), t.identifier('meta')),
t.identifier('url'),
);
const classExpr = clazz.isExpression()
? clazz.node
: t.classExpression(
clazz.node.id,
clazz.node.superClass,
clazz.node.body,
clazz.node.decorators,
);
const callExpr = t.callExpression(callee, [importMetaUrl, classExpr]);
if (clazz.isExpression()) {
clazz.replaceWith(callExpr);
} else if (clazz.isClassDeclaration()) {
const { name } = clazz.node.id;
const declarator = t.variableDeclarator(t.identifier(name), callExpr);
const declaration = t.variableDeclaration('let', [declarator]);
clazz.replaceWith(declaration);
}
}

View File

@@ -1,4 +1,7 @@
/** @typedef {import('./babelPluginWcHmr').BabelPluginWcHmrOptions} BabelPluginWcHmrOptions */
/** @typedef {import('@babel/types').CallExpression} CallExpression */
/** @typedef {import('@babel/types').ClassDeclaration} ClassDeclaration */
/** @typedef {import('@babel/types').Expression} Expression */
/** @template T @typedef {import('@babel/core').NodePath<T>} NodePath<T> */
const { createError } = require('../utils');
@@ -22,6 +25,41 @@ function resolvePath(nodePath) {
return /** @type {NodePath<unknown>} */ (pathCast.resolve());
}
/**
* @param {NodePath<any>} nodePath
*/
function findReferencedPath(nodePath) {
let toResolve = nodePath;
if (nodePath.isReferencedIdentifier()) {
const binding = nodePath.scope.getBinding(nodePath.node.name);
if (binding) {
toResolve = binding.path;
}
}
return resolvePath(toResolve);
}
/**
* @param {NodePath<any>} nodePath
* @returns {NodePath<ClassDeclaration> | NodePath<Expression> | undefined}
*/
function findComponentDefinition(nodePath) {
if (!nodePath) return;
if (nodePath.isIdentifier()) {
const reference = findReferencedPath(nodePath);
if (reference.isClassDeclaration() || reference.isExpression()) {
return reference;
}
return undefined;
}
if (nodePath.isExpression()) {
return nodePath;
}
}
/**
* @template T
* @param {NodePath<T> | NodePath<T>[]} nodePath
@@ -42,4 +80,4 @@ function addToSet(set, elements) {
}
}
module.exports = { parseOptions, resolvePath, singlePath, addToSet };
module.exports = { parseOptions, resolvePath, findComponentDefinition, singlePath, addToSet };

View File

@@ -39,11 +39,14 @@
const { getRequestFilePath, PluginSyntaxError } = require('@web/dev-server-core');
const { hmrPlugin: createBaseHmrPlugin } = require('@web/dev-server-hmr');
const fs = require('fs');
const path = require('path');
const { WC_HMR_MODULE_PREFIX, WC_HMR_MODULE_RUNTIME, WC_HMR_MODULE_PATCH } = require('./constants');
const { parseConfig, createMatchers, createError } = require('./utils');
const { babelTransform } = require('./babel/babelTransform');
const { wcHmrRuntime } = require('./wcHmrRuntime');
const wcHmrRuntime = fs.readFileSync(path.resolve(__dirname, 'wcHmrRuntime.js'), 'utf-8');
/**
* @param {WcHmrPluginConfig} pluginConfig
@@ -107,7 +110,7 @@ function hmrPlugin(pluginConfig) {
async transform(...args) {
const context = args[0];
if (!context.response.is('js')) {
if (!context.response.is('js') || context.path.startsWith('/__web-dev-server__')) {
return;
}

View File

@@ -1,9 +1,10 @@
const { hmrPlugin } = require('./hmrPlugin');
const { litElement } = require('./presets/litElement');
const { fastElement } = require('./presets/fastElement');
const { haunted } = require('./presets/haunted');
const { WC_HMR_MODULE_RUNTIME } = require('./constants');
const presets = { litElement, fastElement };
const presets = { litElement, fastElement, haunted };
module.exports = {
hmrPlugin,

View File

@@ -1,13 +1,15 @@
const { WC_HMR_MODULE_RUNTIME } = require('../constants');
const patch = `import { FASTElement, FASTElementDefinition } from '@microsoft/fast-element';
import { updateClassMembers } from '${WC_HMR_MODULE_RUNTIME}';
FASTElement.prototype.hotReplaceCallback = function hotReplaceCallback(newClass) {
const newDefinition = FASTElementDefinition.forType(newClass);
FASTElement.prototype.hotReplacedCallback = function hotReplacedCallback() {
const newDefinition = FASTElementDefinition.forType(this.constructor);
if (newDefinition) {
this.$fastController.styles = newDefinition.styles;
this.$fastController.template = newDefinition.template;
if (newDefinition.styles) {
this.$fastController.styles = newDefinition.styles;
} else {
// TODO: removed styles?
}
if (newDefinition.template) {
this.$fastController.template = newDefinition.template;
}
}
};`;

View File

@@ -1,12 +1,15 @@
// inject into node_modules/haunted/lib/component.js
// static __renderer__ = renderer;
const patch = `import { WebComponentHmr } from '/__web-dev-server__/wc-hmr/runtime.js';
// static hotReplaceCallback(newClass) {
// updateObjectMembers(Element, newClass);
// updateObjectMembers(Element.prototype, newClass.prototype);
// }
HTMLElement.prototype.hotReplacedCallback = function hotReplacedCallback() {
const temp = new this.constructor();
this._scheduler.renderer = temp._scheduler.renderer;
this._scheduler.update();
};
`;
// hotReplaceCallback(newClass) {
// this._scheduler.renderer = newClass.__renderer__;
// this._scheduler.update();
// }
const haunted = {
functions: [{ name: 'component', import: 'haunted' }],
patch,
};
module.exports = { haunted };

View File

@@ -1,21 +1,16 @@
const { WC_HMR_MODULE_RUNTIME } = require('../constants');
const patch = `import { LitElement } from 'lit-element';
import { updateClassMembers } from '${WC_HMR_MODULE_RUNTIME}';
const supportsAdoptingStyleSheets = (window.ShadowRoot) &&
(window.ShadyCSS === undefined || window.ShadyCSS.nativeShadow) &&
('adoptedStyleSheets' in Document.prototype) &&
('replace' in CSSStyleSheet.prototype);
// static callback
LitElement.hotReplaceCallback = function hotReplaceCallback(newClass) {
newClass.finalize();
updateClassMembers(this, newClass);
LitElement.hotReplacedCallback = function hotReplacedCallback() {
this.finalize();
};
// instance callback
LitElement.prototype.hotReplaceCallback = function hotReplaceCallback() {
LitElement.prototype.hotReplacedCallback = function hotReplacedCallback() {
if (!supportsAdoptingStyleSheets) {
const nodes = Array.from(this.renderRoot.children);
for (const node of nodes) {
@@ -25,6 +20,9 @@ LitElement.prototype.hotReplaceCallback = function hotReplaceCallback() {
}
}
// delete styles to ensure that they get recalculated, including picking up
// changes from parent classes
delete this.constructor._styles;
this.constructor._getUniqueStyles();
if (window.ShadowRoot && this.renderRoot instanceof window.ShadowRoot) {
this.adoptStyles();

View File

@@ -5,11 +5,18 @@ const { isAbsolute, posix, sep } = require('path');
/** @typedef {(path: string) => boolean} Matcher */
/** @typedef {import('./hmrPlugin').WcHmrPluginConfig} WcHmrPluginConfig */
/**
* @param {string} msg
*/
function createErrorMessage(msg) {
return `[@open-wc/dev-server-hmr] ${msg}`;
}
/**
* @param {string} msg
*/
function createError(msg) {
return new Error(`[@open-wc/dev-server-hmr] ${msg}`);
return new Error(createErrorMessage(msg));
}
/**
@@ -85,4 +92,4 @@ function createMatchers(rootDir, patterns) {
};
}
module.exports = { createMatcher, createMatchers, parseConfig, createError };
module.exports = { createMatcher, createMatchers, parseConfig, createErrorMessage, createError };

View File

@@ -1,4 +1,6 @@
const wcHmrRuntime = `
// @ts-nocheck
/* eslint-disable no-param-reassign */
// override global define to allow double registrations
const originalDefine = window.customElements.define;
window.customElements.define = (name, ...rest) => {
@@ -7,43 +9,12 @@ window.customElements.define = (name, ...rest) => {
}
};
const registry = new Map();
const proxiesForKeys = new Map();
const keysForClasses = new Map();
function createClassKey(importMetaUrl, className) {
function createClassKey(importMetaUrl, clazz) {
const modulePath = new URL(importMetaUrl).pathname;
return \`\${modulePath}:\${className}\`;
}
export function register(importMetaUrl, hmrClass) {
const key = createClassKey(importMetaUrl, hmrClass.name);
const hotReplaceCallback = registry.get(key);
if (hotReplaceCallback) {
// class is already registered, call the replace function registered below
hotReplaceCallback(hmrClass);
return;
}
// class is not yet registered, set it up and register an update callback
const connectedElements = trackConnectedElements(hmrClass);
// register a callback for this class later patch in updates to the class
registry.set(key, async newClass => {
// wait 1 microtask to allow the class definition to propagate
await 0;
if (hmrClass.hotReplaceCallback) {
// class has implemented a callback, use that
hmrClass.hotReplaceCallback(newClass);
} else {
// otherwise apply default update
updateClassMembers(hmrClass, newClass);
}
for (const element of connectedElements) {
if (element.hotReplaceCallback) {
element.hotReplaceCallback(newClass);
}
}
});
return `${modulePath}:${clazz.name}`;
}
function trackConnectedElements(hmrClass) {
@@ -66,31 +37,140 @@ function trackConnectedElements(hmrClass) {
return connectedElements;
}
const preserved = ['connectedCallback', 'disconnectedCallback', 'observedAttributes'];
const proxyMethods = [
'construct',
'defineProperty',
'deleteProperty',
'getOwnPropertyDescriptor',
'getPrototypeOf',
'setPrototypeOf',
'isExtensible',
'ownKeys',
'preventExtensions',
'has',
'get',
'set',
];
export function updateClassMembers(hmrClass, newClass) {
updateObjectMembers(hmrClass, newClass);
updateObjectMembers(hmrClass.prototype, newClass.prototype);
/**
* Creates a proxy for the given target, and fowards any calls to the most up to the latest
* version of the target. (ex. the latest hot replaced class).
*/
function createProxy(originalTarget, getCurrentTarget) {
const proxyHandler = {};
for (const method of proxyMethods) {
proxyHandler[method] = (_, ...args) => {
if (method === 'get' && args[0] === 'prototype') {
// prototype must always return original target value
return Reflect[method](originalTarget, ...args);
}
return Reflect[method](getCurrentTarget(), ...args);
};
}
return new Proxy(originalTarget, proxyHandler);
}
export function updateObjectMembers(hmrClass, newClass) {
const currentProperties = new Set(Object.getOwnPropertyNames(hmrClass));
const newProperties = new Set(Object.getOwnPropertyNames(newClass));
/**
* Replaces all prototypes in the inheritance chain with a proxy
* that references the latest implementation
*/
function replacePrototypesWithProxies(instance) {
let previous = instance;
let proto = Object.getPrototypeOf(instance);
for (const prop of Object.getOwnPropertyNames(newClass)) {
const descriptor = Object.getOwnPropertyDescriptor(newClass, prop);
if (descriptor && descriptor.configurable) {
Object.defineProperty(hmrClass, prop, descriptor);
while (proto && proto.constructor !== HTMLElement) {
const key = keysForClasses.get(proto.constructor);
if (key) {
// this is a prototype that might be hot-replaced later
const getCurrentProto = () => proxiesForKeys.get(key).currentClass.prototype;
Object.setPrototypeOf(previous, createProxy(proto, getCurrentProto));
}
previous = proto;
proto = Object.getPrototypeOf(proto);
}
}
export class WebComponentHmr extends HTMLElement {
constructor(...args) {
super(...args);
const key = keysForClasses.get(this.constructor);
const p = proxiesForKeys.get(key);
// replace the constructor with a proxy that references the latest implementation of this class
this.constructor = p.proxy;
// replace prototype chain with a proxy to the latest prototype implementation
replacePrototypesWithProxies(this);
}
}
window.WebComponentHmr = WebComponentHmr;
/**
* Injects the WebComponentHmr class into the inheritance chain
*/
function injectInheritsHmrClass(clazz) {
let parent = clazz;
let proto = Object.getPrototypeOf(clazz);
while (proto && proto !== HTMLElement) {
parent = proto;
proto = Object.getPrototypeOf(proto);
}
for (const existingProp of currentProperties) {
if (!preserved.includes(existingProp) && !newProperties.has(existingProp)) {
try {
delete hmrClass[existingProp];
} catch {}
}
if (proto !== HTMLElement) {
// not a web component
return;
}
}`;
if (parent === WebComponentHmr) {
// class already inherits WebComponentHmr
return;
}
Object.setPrototypeOf(parent, WebComponentHmr);
}
module.exports = { wcHmrRuntime };
/**
* Registers a web component class. Triggers a hot replacement if the
* class was already registered before.
*/
export function register(importMeta, clazz) {
const key = createClassKey(importMeta, clazz);
const existing = proxiesForKeys.get(key);
if (!existing) {
// this class was not yet registered,
// create a proxy that will forward to the latest implementation
const proxy = createProxy(clazz, () => proxiesForKeys.get(key).currentClass);
// inject a HMR class into the inheritance chain
injectInheritsHmrClass(clazz);
// keep track of all connected elements for this class
const connectedElements = trackConnectedElements(clazz);
proxiesForKeys.set(key, {
proxy,
originalClass: clazz,
currentClass: clazz,
connectedElements,
});
keysForClasses.set(clazz, key);
return proxy;
}
// class was already registered before
// register new class, all calls will be proxied to this class
existing.currentClass = clazz;
Promise.resolve().then(() => {
// call optional HMR on the class if they exist, after next microtask to ensure
// module bodies have executed fully
if (clazz.hotReplacedCallback) {
clazz.hotReplacedCallback();
}
for (const element of existing.connectedElements) {
if (element.hotReplacedCallback) {
element.hotReplacedCallback();
}
}
});
return existing.proxy;
}

View File

@@ -7,23 +7,17 @@ describe('babelPluginWcHmr - detecting base class', () => {
it('global base class', () => {
const code = `class Foo extends MyElement {}`;
const result = transform(code, { baseClasses: [{ name: 'MyElement' }] });
expect(result).to.equal(`${banner}
class Foo extends MyElement {}
__$wc_hmr$__.register(import.meta.url, Foo);`);
let Foo = __$wc_hmr$__.register(import.meta.url, class Foo extends MyElement {});`);
});
it('named import', () => {
const code = `import { MyElement } from 'my-element'; class Foo extends MyElement {}`;
const result = transform(code, { baseClasses: [{ name: 'MyElement', import: 'my-element' }] });
expect(result).to.equal(`${banner}
import { MyElement } from 'my-element';
class Foo extends MyElement {}
__$wc_hmr$__.register(import.meta.url, Foo);`);
let Foo = __$wc_hmr$__.register(import.meta.url, class Foo extends MyElement {});`);
});
it('unmatched import', () => {
@@ -41,45 +35,35 @@ class Foo extends MyElement {}`);
expect(result).to.equal(`${banner}
import BaseElement from 'base-element';
class Foo extends BaseElement {}
__$wc_hmr$__.register(import.meta.url, Foo);`);
let Foo = __$wc_hmr$__.register(import.meta.url, class Foo extends BaseElement {});`);
});
it('mixins', () => {
const code = `import { MyElement } from 'my-element'; class Foo extends MixA(MixB(MyElement)) {}`;
const result = transform(code, { baseClasses: [{ name: 'MyElement', import: 'my-element' }] });
expect(result).to.equal(`${banner}
import { MyElement } from 'my-element';
class Foo extends MixA(MixB(MyElement)) {}
__$wc_hmr$__.register(import.meta.url, Foo);`);
let Foo = __$wc_hmr$__.register(import.meta.url, class Foo extends MixA(MixB(MyElement)) {});`);
});
it('multiple classes', () => {
const code = `import { MyElement } from 'my-element';
class A extends MyElement {}
class B {}
class C extends HTMLElement {}
class C extends X {}
class D extends MyElement {}`;
const result = transform(code, { baseClasses: [{ name: 'MyElement', import: 'my-element' }] });
expect(result).to.equal(`${banner}
import { MyElement } from 'my-element';
class A extends MyElement {}
__$wc_hmr$__.register(import.meta.url, A);
let A = __$wc_hmr$__.register(import.meta.url, class A extends MyElement {});
class B {}
class C extends HTMLElement {}
class C extends X {}
class D extends MyElement {}
__$wc_hmr$__.register(import.meta.url, D);`);
let D = __$wc_hmr$__.register(import.meta.url, class D extends MyElement {});`);
});
it('multiple base class definitions', () => {
@@ -87,27 +71,22 @@ __$wc_hmr$__.register(import.meta.url, D);`);
import ElementB from 'element-b';
class A extends ElementA {}
class B extends ElementB {}
class C extends HTMLElement {}`;
class C extends X {}`;
const result = transform(code, {
baseClasses: [
{ name: 'ElementA', import: 'element-a' },
{ name: 'default', import: 'element-b' },
],
});
expect(result).to.equal(`${banner}
import { ElementA } from 'element-a';
import ElementB from 'element-b';
class A extends ElementA {}
let A = __$wc_hmr$__.register(import.meta.url, class A extends ElementA {});
__$wc_hmr$__.register(import.meta.url, A);
let B = __$wc_hmr$__.register(import.meta.url, class B extends ElementB {});
class B extends ElementB {}
__$wc_hmr$__.register(import.meta.url, B);
class C extends HTMLElement {}`);
class C extends X {}`);
});
it('base class with a specific import path', () => {
@@ -115,12 +94,9 @@ class C extends HTMLElement {}`);
const result = transform(code, {
baseClasses: [{ name: 'MyElement', import: path.join(rootDir, 'MyElement.js') }],
});
expect(result).to.equal(`${banner}
import { MyElement } from '../MyElement.js';
class Foo extends MyElement {}
__$wc_hmr$__.register(import.meta.url, Foo);`);
let Foo = __$wc_hmr$__.register(import.meta.url, class Foo extends MyElement {});`);
});
});

View File

@@ -8,9 +8,7 @@ describe('babelPluginWcHmr - detecting customElements.define', () => {
const result = transform(code);
expect(result).to.equal(
`${banner}
class Foo extends HTMLElement {}
__$wc_hmr$__.register(import.meta.url, Foo);
let Foo = __$wc_hmr$__.register(import.meta.url, class Foo extends HTMLElement {});
customElements.define('x-foo', Foo);`,
);
@@ -21,9 +19,7 @@ customElements.define('x-foo', Foo);`,
const result = transform(code);
expect(result).to.equal(
`${banner}
class Foo extends HTMLElement {}
__$wc_hmr$__.register(import.meta.url, Foo);
let Foo = __$wc_hmr$__.register(import.meta.url, class Foo extends HTMLElement {});
window.customElements.define('x-foo', Foo);`,
);
@@ -34,38 +30,67 @@ window.customElements.define('x-foo', Foo);`,
const result = transform(code);
expect(result).to.equal(
`${banner}
class Foo extends HTMLElement {}
__$wc_hmr$__.register(import.meta.url, Foo);
let Foo = __$wc_hmr$__.register(import.meta.url, class Foo extends HTMLElement {});
globalThis.customElements.define('x-foo', Foo);`,
);
});
it('injects registration when detecting a class expression', () => {
const code = `customElements.define('x-foo', class Foo extends HTMLElement {});`;
const result = transform(code);
expect(result).to.equal(`${banner}
customElements.define('x-foo', __$wc_hmr$__.register(import.meta.url, class Foo extends HTMLElement {}));`);
});
it('injects registration when detecting an anonymous class expression', () => {
const code = `customElements.define('x-foo', class extends HTMLElement {});`;
const result = transform(code);
expect(result).to.equal(`${banner}
customElements.define('x-foo', __$wc_hmr$__.register(import.meta.url, class extends HTMLElement {}));`);
});
it('injects registration on a class expression assigned to a variable', () => {
const code = `const Foo = class Foo extends HTMLElement {}\ncustomElements.define('x-foo', Foo);`;
const result = transform(code);
expect(result).to.equal(
`${banner}
const Foo = __$wc_hmr$__.register(import.meta.url, class Foo extends HTMLElement {});
customElements.define('x-foo', Foo);`,
);
});
it('injects registration on a reassigned class to a variable with the same name', () => {
const code = `let Foo = class Foo extends HTMLElement {}\n Foo = Foo;\n customElements.define('x-foo', Foo);`;
const result = transform(code);
console.log(result);
expect(result).to.equal(
`${banner}
let Foo = __$wc_hmr$__.register(import.meta.url, class Foo extends HTMLElement {});
Foo = Foo;
customElements.define('x-foo', Foo);`,
);
});
it('injects multiple registrations', () => {
const code =
"class Foo extends HTMLElement {}\ncustomElements.define('x-foo', Foo);\n" +
"class Bar extends HTMLElement {}\ncustomElements.define('x-bar', Bar);" +
"class Baz extends HTMLElement {}\ncustomElements.define('x-baz', Baz);";
// console.log(code);
const result = transform(code);
expect(result).to.equal(
`${banner}
class Foo extends HTMLElement {}
__$wc_hmr$__.register(import.meta.url, Foo);
let Foo = __$wc_hmr$__.register(import.meta.url, class Foo extends HTMLElement {});
customElements.define('x-foo', Foo);
class Bar extends HTMLElement {}
__$wc_hmr$__.register(import.meta.url, Bar);
let Bar = __$wc_hmr$__.register(import.meta.url, class Bar extends HTMLElement {});
customElements.define('x-bar', Bar);
class Baz extends HTMLElement {}
__$wc_hmr$__.register(import.meta.url, Baz);
let Baz = __$wc_hmr$__.register(import.meta.url, class Baz extends HTMLElement {});
customElements.define('x-baz', Baz);`,
);
@@ -77,73 +102,38 @@ customElements.define('x-baz', Baz);`,
expect(result).to.equal(
`import '/__web-dev-server__/wc-hmr/patch.js';
${banner}
class Foo extends HTMLElement {}
__$wc_hmr$__.register(import.meta.url, Foo);
let Foo = __$wc_hmr$__.register(import.meta.url, class Foo extends HTMLElement {});
customElements.define('x-foo', Foo);`,
);
});
it('injects registration when detecting a class expression', () => {
const code = `customElements.define('x-foo', class Foo extends HTMLElement {});`;
const result = transform(code);
expect(result).to.equal(`${banner}
const _Foo = class Foo extends HTMLElement {};
__$wc_hmr$__.register(import.meta.url, _Foo);
customElements.define('x-foo', _Foo);`);
});
it('injects registration when detecting an anonymous class expression', () => {
const code = `customElements.define('x-foo', class extends HTMLElement {});`;
const result = transform(code);
expect(result).to.equal(`${banner}
const _ref = class extends HTMLElement {};
__$wc_hmr$__.register(import.meta.url, _ref);
customElements.define('x-foo', _ref);`);
});
it('deconflicts variable names', () => {
const code = `const Foo = 1; const _Foo = 2; customElements.define('x-foo', class Foo extends HTMLElement {});`;
const result = transform(code);
expect(result).to.equal(`${banner}
const Foo = 1;
const _Foo = 2;
const _Foo2 = class Foo extends HTMLElement {};
__$wc_hmr$__.register(import.meta.url, _Foo2);
customElements.define('x-foo', _Foo2);`);
});
it('respects the custom element define scope', () => {
const code = `console.log("x"); (function () { customElements.define('x-foo', class extends HTMLElement {}); })();`;
const result = transform(code);
expect(result).to.equal(`${banner}
console.log("x");
(function () {
const _ref = class extends HTMLElement {};
__$wc_hmr$__.register(import.meta.url, _ref);
customElements.define('x-foo', _ref);
})();`);
});
it('handles function expression', () => {
const code = `customElements.define('x-foo', component(Foo));`;
const result = transform(code);
expect(result).to.equal(`${banner}
const _component = component(Foo);
customElements.define('x-foo', __$wc_hmr$__.register(import.meta.url, component(Foo)));`);
});
__$wc_hmr$__.register(import.meta.url, _component);
it('does not wrap an already handled class 1', () => {
const code = `let Foo = __$wc_hmr$__.register(import.meta.url, class Foo extends HTMLElement {});
customElements.define('x-foo', _component);`);
customElements.define('x-foo', Foo);`;
const result = transform(code);
expect(result).to.equal(
`${banner}
let Foo = __$wc_hmr$__.register(import.meta.url, class Foo extends HTMLElement {});
customElements.define('x-foo', Foo);`,
);
});
it('does not wrap an already handled class 2', () => {
const code = `customElements.define('x-foo', __$wc_hmr$__.register(import.meta.url, class Foo extends HTMLElement {}));`;
const result = transform(code);
expect(result).to.equal(
`${banner}
customElements.define('x-foo', __$wc_hmr$__.register(import.meta.url, class Foo extends HTMLElement {}));`,
);
});
});

View File

@@ -14,9 +14,7 @@ Foo = __decorate([customElement('x-foo')], Foo);`;
expect(result).to.equal(`${banner}
function __decorate() {}
let Foo = class extends HTMLElement {};
__$wc_hmr$__.register(import.meta.url, Foo);
let Foo = __$wc_hmr$__.register(import.meta.url, class extends HTMLElement {});
Foo = __decorate([customElement('x-foo')], Foo);`);
});
@@ -31,9 +29,7 @@ Foo = __decorate([x(), y(), customElement('x-foo')], Foo);`;
expect(result).to.equal(`${banner}
function __decorate() {}
let Foo = class extends HTMLElement {};
__$wc_hmr$__.register(import.meta.url, Foo);
let Foo = __$wc_hmr$__.register(import.meta.url, class extends HTMLElement {});
Foo = __decorate([x(), y(), customElement('x-foo')], Foo);`);
});
@@ -62,9 +58,7 @@ import { customElement } from 'my-package';
function __decorate() {}
let Foo = class extends HTMLElement {};
__$wc_hmr$__.register(import.meta.url, Foo);
let Foo = __$wc_hmr$__.register(import.meta.url, class extends HTMLElement {});
Foo = __decorate([customElement('x-foo')], Foo);`);
});
@@ -84,9 +78,7 @@ import customElement from 'my-package';
function __decorate() {}
let Foo = class extends HTMLElement {};
__$wc_hmr$__.register(import.meta.url, Foo);
let Foo = __$wc_hmr$__.register(import.meta.url, class extends HTMLElement {});
Foo = __decorate([customElement('x-foo')], Foo);`);
});
@@ -106,9 +98,7 @@ import { defineElement } from '../defineElement.js';
function __decorate() {}
let Foo = class extends HTMLElement {};
__$wc_hmr$__.register(import.meta.url, Foo);
let Foo = __$wc_hmr$__.register(import.meta.url, class extends HTMLElement {});
Foo = __decorate([defineElement('x-foo')], Foo);`);
});
@@ -123,9 +113,7 @@ Foo = Foo_1 = __decorate([customElement('x-foo')], Foo);`;
expect(result).to.equal(`${banner}
function __decorate() {}
let Foo = Foo_1 = class extends HTMLElement {};
__$wc_hmr$__.register(import.meta.url, Foo);
let Foo = __$wc_hmr$__.register(import.meta.url, Foo_1 = class extends HTMLElement {});
Foo = Foo_1 = __decorate([customElement('x-foo')], Foo);`);
});

View File

@@ -9,9 +9,7 @@ describe('babelPluginWcHmr - detecting function components', () => {
const result = transform(code, { functions: [{ name: 'component' }] });
expect(result).to.equal(`${banner}
__$wc_hmr$__.register(import.meta.url, MyElementClass);
const MyElementClass = component(MyElement);`);
const MyElementClass = __$wc_hmr$__.register(import.meta.url, component(MyElement));`);
});
it('handles multiple functions', () => {
@@ -19,18 +17,15 @@ const MyElementClass = component(MyElement);`);
const result = transform(code, { functions: [{ name: 'component' }] });
expect(result).to.equal(`${banner}
__$wc_hmr$__.register(import.meta.url, x);
const x = __$wc_hmr$__.register(import.meta.url, component(y));
const x = component(y);
__$wc_hmr$__.register(import.meta.url, a);
const a = component(b);`);
const a = __$wc_hmr$__.register(import.meta.url, component(b));`);
});
it('does not inject for a function with a different name', () => {
const code = 'const MyElementClass = notComponent(MyElement);';
const result = transform(code, { functions: [{ name: 'component' }] });
expect(result).to.equal(code);
});
@@ -44,9 +39,7 @@ const a = component(b);`);
expect(result).to.equal(`${banner}
import { component } from "my-package";
__$wc_hmr$__.register(import.meta.url, MyElementClass);
const MyElementClass = component(MyElement);`);
const MyElementClass = __$wc_hmr$__.register(import.meta.url, component(MyElement));`);
});
it('compiled decorator with a default import', () => {
@@ -59,9 +52,7 @@ const MyElementClass = component(MyElement);`);
expect(result).to.equal(`${banner}
import foo from "my-package";
__$wc_hmr$__.register(import.meta.url, MyElementClass);
const MyElementClass = foo(MyElement);`);
const MyElementClass = __$wc_hmr$__.register(import.meta.url, foo(MyElement));`);
});
it('compiled decorator with a specific import path', () => {
@@ -75,8 +66,6 @@ const MyElementClass = foo(MyElement);`);
expect(result).to.equal(`${banner}
import { component } from "../component.js";
__$wc_hmr$__.register(import.meta.url, MyElementClass);
const MyElementClass = component(MyElement);`);
const MyElementClass = __$wc_hmr$__.register(import.meta.url, component(MyElement));`);
});
});