diff --git a/docs/pages/docs.md b/docs/pages/docs.md
index 13eee6b1..c7f18c6c 100644
--- a/docs/pages/docs.md
+++ b/docs/pages/docs.md
@@ -161,7 +161,8 @@ Even more experimental than WCC is the option to author a rendering function for
### Example
-Below is an example of what is possible right now [demonstrated](https://github.com/thescientist13/greenwood-counter-jsx) through a Counter component.
+Below is an example of what is possible right now demonstrated through a [Counter component](https://github.com/thescientist13/greenwood-counter-jsx).
+
```jsx
export default class Counter extends HTMLElement {
constructor() {
@@ -173,6 +174,11 @@ export default class Counter extends HTMLElement {
this.render();
}
+ increment() {
+ this.count += 1;
+ this.render();
+ }
+
render() {
const { count } = this;
@@ -180,7 +186,7 @@ export default class Counter extends HTMLElement {
You have clicked {count} times
-
+
);
}
@@ -189,16 +195,58 @@ export default class Counter extends HTMLElement {
customElements.define('wcc-counter', Counter);
```
-There is an [active discussion tracking features](https://github.com/ProjectEvergreen/wcc/discussions/84) and [issues in progress](https://github.com/ProjectEvergreen/wcc/issues?q=is%3Aopen+is%3Aissue+label%3AJSX) to continue iterating on this, so please feel free to try it out and give us your feedback!
+A couple things to observe in the above example:
+- The `this` reference is correctly bound to the `` element's state. This works for both `this.count` and the event handler, `this.increment`.
+- Event handlers need to manage their own render function updates.
+- `this.count` will know it is a member of the ``'s state, and so will re-run `this.render` automatically in the compiled output.
+
+> There is an [active discussion tracking features](https://github.com/ProjectEvergreen/wcc/discussions/84) and [issues in progress](https://github.com/ProjectEvergreen/wcc/issues?q=is%3Aopen+is%3Aissue+label%3AJSX) to continue iterating on this, so please feel free to try it out and give us your feedback!
### Prerequisites
There are of couple things you will need to do to use WCC with JSX:
-1. NodeJS version needs to be `16.x`
+1. NodeJS version needs to be >= `16.x`
1. You will need to use the _.jsx_ extension
1. Requires the `--experimental-loaders` flag when invoking NodeJS
```js
$ node --experimental-loader ./node_modules/wc-compiler/src/jsx-loader.js server.js
```
-> _See our [example's page](/examples#jsx) for some usages of WCC + JSX._ 👀
\ No newline at end of file
+> _See our [example's page](/examples#jsx) for some usages of WCC + JSX._ 👀
+
+### (Inferred) Attribute Observability
+
+An optional feature supported by JSX based compilation is a feature called `inferredObservability`. With this enabled, WCC will read any `this` member references in your component's `render` function and map each member instance to
+- an entry in the `observedAttributes` array
+- automatically handle `attributeChangedCallback` update (by calling `this.render()`)
+
+So taking the above counter example, and opting in to this feature, we just need to enable the `inferredObservability` option in the component
+```jsx
+export const inferredObservability = true;
+
+export default class Counter extends HTMLElement {
+ ...
+
+ render() {
+ const { count } = this;
+
+ return (
+
+
+ You have clicked {count} times
+
+
+ );
+ }
+}
+```
+
+And so now when the attribute is set on this component, the component will re-render automatically, no need to write out `observedAttributes` or `attributeChangedCallback`!
+```html
+
+```
+
+Some notes / limitations:
+- Please be aware of the above linked discussion which is tracking known bugs / feature requests to all things WCC + JSX.
+- We consider the capability of this observability to be "coarse grained" at this time since WCC just re-runs the entire `render` function, replacing of the `innerHTML` for the host component. Thought it is still WIP, we are exploring a more ["fine grained" approach](https://github.com/ProjectEvergreen/wcc/issues/108) that will more efficient than blowing away all the HTML, a la in the style of [**lit-html**](https://lit.dev/docs/templates/overview/) or [**Solid**'s Signals](https://www.solidjs.com/tutorial/introduction_signals).
+- This automatically _reflects properties used in the `render` function to attributes_, so YMMV.
\ No newline at end of file
diff --git a/docs/pages/examples.md b/docs/pages/examples.md
index 3c73ed23..4e3c58d2 100644
--- a/docs/pages/examples.md
+++ b/docs/pages/examples.md
@@ -259,4 +259,9 @@ export async function handler() {
## JSX
-A current example of a Todo App can be seen in [this repo](https://github.com/thescientist13/todo-app), which is a fork of [this Greenwood based Todo App which uses LitElement](https://github.com/ProjectEvergreen/todo-app). It can compile JSX for _**the client or the server**_ using [Greenwood](https://www.greenwoodjs.io/), and can even be used with great testing tools like [**@web/test-runner**](https://modern-web.dev/docs/test-runner/overview/)! 💪
\ No newline at end of file
+A couple examples of using WCC + JSX are available for reference and reproduction:
+
+* [Counter](https://github.com/thescientist13/greenwood-counter-jsx)
+* [Todo App](https://github.com/thescientist13/todo-app)
+
+Both of these examples can compile JSX for _**the client or the server**_ using [Greenwood](https://www.greenwoodjs.io/), and can even be used with great testing tools like [**@web/test-runner**](https://modern-web.dev/docs/test-runner/overview/)! 💪
\ No newline at end of file
diff --git a/package.json b/package.json
index 0cefe01c..bb111d08 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,7 @@
"build": "node ./build.js",
"serve": "node ./build.js && http-server ./dist --open",
"start": "npm run develop",
- "test": "mocha --exclude \"./test/cases/jsx/**\" --exclude \"./test/cases/custom-extension/**\" \"./test/**/**/*.spec.js\"",
+ "test": "mocha --exclude \"./test/cases/jsx*/**\" --exclude \"./test/cases/custom-extension/**\" \"./test/**/**/*.spec.js\"",
"test:exp": "c8 node --experimental-loader ./test-exp-loader.js ./node_modules/mocha/bin/mocha \"./test/**/**/*.spec.js\"",
"test:tdd": "npm run test -- --watch",
"test:tdd:exp": "npm run test:exp -- --watch",
diff --git a/src/jsx-loader.js b/src/jsx-loader.js
index 3c0277a5..2d484c10 100644
--- a/src/jsx-loader.js
+++ b/src/jsx-loader.js
@@ -197,14 +197,52 @@ function parseJsxElement(element, moduleContents = '') {
return string;
}
+// TODO handle if / else statements
+// https://github.com/ProjectEvergreen/wcc/issues/88
+function findThisReferences(context, statement) {
+ const references = [];
+ const isRenderFunctionContext = context === 'render';
+ const { expression, type } = statement;
+ const isConstructorThisAssignment = context === 'constructor'
+ && type === 'ExpressionStatement'
+ && expression.type === 'AssignmentExpression'
+ && expression.left.object.type === 'ThisExpression';
+
+ if (isConstructorThisAssignment) {
+ // this.name = 'something'; // constructor
+ references.push(expression.left.property.name);
+ } else if (isRenderFunctionContext && type === 'VariableDeclaration') {
+ statement.declarations.forEach(declaration => {
+ const { init, id } = declaration;
+
+ if (init.object && init.object.type === 'ThisExpression') {
+ // const { description } = this.todo;
+ references.push(init.property.name);
+ } else if (init.type === 'ThisExpression' && id && id.properties) {
+ // const { description } = this.todo;
+ id.properties.forEach((property) => {
+ references.push(property.key.name);
+ });
+ }
+ });
+ }
+
+ return references;
+}
+
export function parseJsx(moduleURL) {
const moduleContents = fs.readFileSync(moduleURL, 'utf-8');
- string = '';
-
- const tree = acorn.Parser.extend(jsx()).parse(moduleContents, {
+ // would be nice if we could do this instead, so we could know ahead of time
+ // const { inferredObservability } = await import(moduleURL);
+ // however, this requires making parseJsx async, but WCC acorn walking is done sync
+ const hasOwnObservedAttributes = undefined;
+ let inferredObservability = false;
+ let observedAttributes = [];
+ let tree = acorn.Parser.extend(jsx()).parse(moduleContents, {
ecmaVersion: 'latest',
sourceType: 'module'
});
+ string = '';
walk.simple(tree, {
ClassDeclaration(node) {
@@ -212,29 +250,46 @@ export function parseJsx(moduleURL) {
const hasShadowRoot = moduleContents.slice(node.body.start, node.body.end).indexOf('this.attachShadow(') > 0;
for (const n1 of node.body.body) {
- if (n1.type === 'MethodDefinition' && n1.key.name === 'render') {
- for (const n2 in n1.value.body.body) {
- const n = n1.value.body.body[n2];
-
- if (n.type === 'ReturnStatement' && n.argument.type === 'JSXElement') {
- const html = parseJsxElement(n.argument, moduleContents);
- const elementTree = getParse(html)(html);
- const elementRoot = hasShadowRoot ? 'this.shadowRoot' : 'this';
-
- applyDomDepthSubstitutions(elementTree, undefined, hasShadowRoot);
-
- const finalHtml = serialize(elementTree);
- const transformed = acorn.parse(`${elementRoot}.innerHTML = \`${finalHtml}\`;`, {
- ecmaVersion: 'latest',
- sourceType: 'module'
- });
-
- n1.value.body.body[n2] = transformed;
+ if (n1.type === 'MethodDefinition') {
+ const nodeName = n1.key.name;
+ if (nodeName === 'render') {
+ for (const n2 in n1.value.body.body) {
+ const n = n1.value.body.body[n2];
+
+ if (n.type === 'VariableDeclaration') {
+ observedAttributes = [
+ ...observedAttributes,
+ ...findThisReferences('render', n)
+ ];
+ } else if (n.type === 'ReturnStatement' && n.argument.type === 'JSXElement') {
+ const html = parseJsxElement(n.argument, moduleContents);
+ const elementTree = getParse(html)(html);
+ const elementRoot = hasShadowRoot ? 'this.shadowRoot' : 'this';
+
+ applyDomDepthSubstitutions(elementTree, undefined, hasShadowRoot);
+
+ const finalHtml = serialize(elementTree);
+ const transformed = acorn.parse(`${elementRoot}.innerHTML = \`${finalHtml}\`;`, {
+ ecmaVersion: 'latest',
+ sourceType: 'module'
+ });
+
+ n1.value.body.body[n2] = transformed;
+ }
}
}
}
}
}
+ },
+ ExportNamedDeclaration(node) {
+ const { declaration } = node;
+
+ if (declaration && declaration.type === 'VariableDeclaration' && declaration.kind === 'const' && declaration.declarations.length === 1) {
+ if (declaration.declarations[0].id.name === 'inferredObservability') {
+ inferredObservability = Boolean(node.declaration.declarations[0].init.raw);
+ }
+ }
}
}, {
// https://github.com/acornjs/acorn/issues/829#issuecomment-1172586171
@@ -242,6 +297,68 @@ export function parseJsx(moduleURL) {
JSXElement: () => {}
});
+ // TODO - signals: use constructor, render, HTML attributes? some, none, or all?
+ if (inferredObservability && observedAttributes.length > 0 && !hasOwnObservedAttributes) {
+ let insertPoint;
+ for (const line of tree.body) {
+ // test for class MyComponent vs export default class MyComponent
+ if (line.type === 'ClassDeclaration' || (line.declaration && line.declaration.type) === 'ClassDeclaration') {
+ const children = !line.declaration
+ ? line.body.body
+ : line.declaration.body.body;
+ for (const method of children) {
+ if (method.key.name === 'constructor') {
+ insertPoint = method.start - 1;
+ break;
+ }
+ }
+ }
+ }
+
+ let newModuleContents = escodegen.generate(tree);
+
+ // TODO better way to determine value type?
+ /* eslint-disable indent */
+ newModuleContents = `${newModuleContents.slice(0, insertPoint)}
+ static get observedAttributes() {
+ return [${[...observedAttributes].map(attr => `'${attr}'`).join(',')}]
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ function getValue(value) {
+ return value.charAt(0) === '{' || value.charAt(0) === '['
+ ? JSON.parse(value)
+ : !isNaN(value)
+ ? parseInt(value, 10)
+ : value === 'true' || value === 'false'
+ ? value === 'true' ? true : false
+ : value;
+ }
+ if (newValue !== oldValue) {
+ switch(name) {
+ ${observedAttributes.map((attr) => {
+ return `
+ case '${attr}':
+ this.${attr} = getValue(newValue);
+ break;
+ `;
+ }).join('\n')}
+ }
+
+ this.render();
+ }
+ }
+
+ ${newModuleContents.slice(insertPoint)}
+ `;
+ /* eslint-enable indent */
+
+ tree = acorn.Parser.extend(jsx()).parse(newModuleContents, {
+ ecmaVersion: 'latest',
+ sourceType: 'module'
+ });
+ }
+
return tree;
}
diff --git a/test/cases/jsx-coarse-grained/fixtures/attribute-changed-callback.txt b/test/cases/jsx-coarse-grained/fixtures/attribute-changed-callback.txt
new file mode 100644
index 00000000..3801a391
--- /dev/null
+++ b/test/cases/jsx-coarse-grained/fixtures/attribute-changed-callback.txt
@@ -0,0 +1,13 @@
+attributeChangedCallback(name, oldValue, newValue) {
+ function getValue(value) {
+ return value.charAt(0) === '{' || value.charAt(0) === '[' ? JSON.parse(value) : !isNaN(value) ? parseInt(value, 10) : value === 'true' || value === 'false' ? value === 'true' ? true : false : value;
+ }
+ if (newValue !== oldValue) {
+ switch (name) {
+ case 'count':
+ this.count = getValue(newValue);
+ break;
+ }
+ this.render();
+ }
+}
\ No newline at end of file
diff --git a/test/cases/jsx-coarse-grained/fixtures/get-observed-attributes.txt b/test/cases/jsx-coarse-grained/fixtures/get-observed-attributes.txt
new file mode 100644
index 00000000..4421db23
--- /dev/null
+++ b/test/cases/jsx-coarse-grained/fixtures/get-observed-attributes.txt
@@ -0,0 +1,3 @@
+static get observedAttributes() {
+ return['count'];
+}
\ No newline at end of file
diff --git a/test/cases/jsx-coarse-grained/jsx-coarse-grained.spec.js b/test/cases/jsx-coarse-grained/jsx-coarse-grained.spec.js
new file mode 100644
index 00000000..756b6ee2
--- /dev/null
+++ b/test/cases/jsx-coarse-grained/jsx-coarse-grained.spec.js
@@ -0,0 +1,52 @@
+/*
+ * Use Case
+ * Run wcc against a custom element using JSX render function with inferredObservability enabled
+ *
+ * User Result
+ * Should return the expected JavaScript output.
+ *
+ * User Workspace
+ * src/
+ * counter.jsx
+ */
+import chai from 'chai';
+import fs from 'fs/promises';
+import { renderToString } from '../../../src/wcc.js';
+
+const expect = chai.expect;
+
+describe('Run WCC For ', function() {
+ const LABEL = 'Single Custom Element using JSX';
+ let fixtureAttributeChangedCallback;
+ let fixtureGetObservedAttributes;
+ let meta;
+
+ before(async function() {
+ const { metadata } = await renderToString(new URL('./src/counter.jsx', import.meta.url));
+
+ meta = metadata;
+
+ fixtureAttributeChangedCallback = await fs.readFile(new URL('./fixtures/attribute-changed-callback.txt', import.meta.url), 'utf-8');
+ fixtureGetObservedAttributes = await fs.readFile(new URL('./fixtures/get-observed-attributes.txt', import.meta.url), 'utf-8');
+ });
+
+ describe(LABEL, function() {
+
+ describe(' component w/ and Inferred Observability', function() {
+
+ it('should infer observability by generating a get observedAttributes method', () => {
+ const actual = meta['wcc-counter-jsx'].source.replace(/ /g, '').replace(/\n/g, '');
+ const expected = fixtureGetObservedAttributes.replace(/ /g, '').replace(/\n/g, '');
+
+ expect(actual).to.contain(expected);
+ });
+
+ it('should infer observability by generating an attributeChangedCallback method', () => {
+ const actual = meta['wcc-counter-jsx'].source.replace(/ /g, '').replace(/\n/g, '');
+ const expected = fixtureAttributeChangedCallback.replace(/ /g, '').replace(/\n/g, '');
+
+ expect(actual).to.contain(expected);
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/test/cases/jsx-coarse-grained/src/counter.jsx b/test/cases/jsx-coarse-grained/src/counter.jsx
new file mode 100644
index 00000000..b09ead8a
--- /dev/null
+++ b/test/cases/jsx-coarse-grained/src/counter.jsx
@@ -0,0 +1,40 @@
+export const inferredObservability = true;
+
+export default class Counter extends HTMLElement {
+ constructor() {
+ super();
+ this.count = 0;
+ }
+
+ increment() {
+ this.count += 1;
+ this.render();
+ }
+
+ decrement() {
+ this.count -= 1;
+ this.render();
+ }
+
+ connectedCallback() {
+ this.render();
+ }
+
+ render() {
+ const { count } = this;
+
+ return (
+
+
+
Counter JSX
+
+
+ You have clicked {count} times
+
+
+
+ );
+ }
+}
+
+customElements.define('wcc-counter-jsx', Counter);
\ No newline at end of file
diff --git a/test/cases/jsx/jsx.spec.js b/test/cases/jsx/jsx.spec.js
index 5e5de4ce..981e4297 100644
--- a/test/cases/jsx/jsx.spec.js
+++ b/test/cases/jsx/jsx.spec.js
@@ -3,14 +3,13 @@
* Run wcc against a nested custom elements using JSX render function
*
* User Result
- * Should return the expected HTML output.
+ * Should return the expected HTML and JavaScript output.
*
* User Workspace
* src/
* badge.jsx
* counter.jsx
*/
-
import chai from 'chai';
import { JSDOM } from 'jsdom';
import { renderToString } from '../../../src/wcc.js';
@@ -18,7 +17,7 @@ import { renderToString } from '../../../src/wcc.js';
const expect = chai.expect;
describe('Run WCC For ', function() {
- const LABEL = 'Single Custom Element using JSX and Declarative Shadow DOM';
+ const LABEL = 'Single Custom Element using JSX';
let dom;
let meta;
@@ -31,7 +30,7 @@ describe('Run WCC For ', function() {
describe(LABEL, function() {
- describe(' component', function() {
+ describe(' component w/ ', function() {
let buttons;
before(async function() {
@@ -56,21 +55,21 @@ describe('Run WCC For ', function() {
expect(span.getAttribute('class')).to.be.equal('unmet');
expect(span.textContent).to.be.equal('0');
});
+ });
- describe('Event Handling', () => {
- //
- it('should handle a this expression', () => {
- const element = Array.from(buttons).find(button => button.getAttribute('id') === 'evt-this');
+ describe('Event Handling', () => {
+ //
+ it('should handle a this expression', () => {
+ const element = Array.from(buttons).find(button => button.getAttribute('id') === 'evt-this');
+
+ expect(element.getAttribute('onclick')).to.be.equal('this.parentElement.parentElement.decrement()');
+ });
- expect(element.getAttribute('onclick')).to.be.equal('this.parentElement.parentElement.decrement()');
- });
-
- //
- it('should handle an assignment expression with implicit reactivity using this.render', () => {
- const element = Array.from(buttons).find(button => button.getAttribute('id') === 'evt-assignment');
+ //
+ it('should handle an assignment expression with implicit reactivity using this.render', () => {
+ const element = Array.from(buttons).find(button => button.getAttribute('id') === 'evt-assignment');
- expect(element.getAttribute('onclick')).to.be.equal('this.parentElement.parentElement.count-=1; this.parentElement.parentElement.render();');
- });
+ expect(element.getAttribute('onclick')).to.be.equal('this.parentElement.parentElement.count-=1; this.parentElement.parentElement.render();');
});
});
@@ -82,6 +81,15 @@ describe('Run WCC For ', function() {
expect(element.textContent).to.be.equal('0');
});
});
+
+ describe('Inferred Observability', () => {
+ it('should not infer observability by default', () => {
+ const actual = meta['wcc-counter-jsx'].source.replace(/ /g, '').replace(/\n/g, '');
+
+ expect(actual).to.not.contain('staticgetobservedAttributes()');
+ expect(actual).to.not.contain('attributeChangedCallback');
+ });
+ });
});
});
});
\ No newline at end of file