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