From 66ff24d2d5da9ff6e8e59b5f372bae2c370c5c96 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Fri, 2 Sep 2022 12:33:37 -0400 Subject: [PATCH 01/13] basic this expression detection in contructor and render functions --- src/jsx-loader.js | 84 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/src/jsx-loader.js b/src/jsx-loader.js index 3c0277a5..2a114392 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -197,6 +197,33 @@ function parseJsxElement(element, moduleContents = '') { return string; } +// TODO handle if / else statements +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') { + // const name = this.name; + // TODO const { name } = this; + statement.declarations.forEach(declaration => { + const { init } = declaration; + if(init && init.object && init.object.type === 'ThisExpression') { + references.push(init.property.name); + } + }) + } + + return references; +} + export function parseJsx(moduleURL) { const moduleContents = fs.readFileSync(moduleURL, 'utf-8'); string = ''; @@ -205,6 +232,10 @@ export function parseJsx(moduleURL) { ecmaVersion: 'latest', sourceType: 'module' }); + const observedAttributes = { + constructor: [], + render: [] + }; walk.simple(tree, { ClassDeclaration(node) { @@ -212,24 +243,39 @@ 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 === 'constructor') { + n1.value.body.body.forEach((statement) => { + observedAttributes.constructor = [ + ...observedAttributes.constructor, + ...findThisReferences('constructor', statement) + ] + }) + } else if (nodeName === 'render') { + for (const n2 in n1.value.body.body) { + const n = n1.value.body.body[n2]; + + if(n.type === 'VariableDeclaration') { + observedAttributes.render = [ + ...observedAttributes.render, + ...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; + } } } } @@ -242,6 +288,8 @@ export function parseJsx(moduleURL) { JSXElement: () => {} }); + console.debug('????????', { observedAttributes }); + return tree; } From 23c29eb28ee88da9b8412d16543c0f0ac372aca3 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Fri, 2 Sep 2022 13:07:41 -0400 Subject: [PATCH 02/13] add support for descructuring this in render --- src/jsx-loader.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/jsx-loader.js b/src/jsx-loader.js index 2a114392..64cd53d5 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -211,12 +211,17 @@ function findThisReferences(context, statement) { // this.name = 'something'; // constructor references.push(expression.left.property.name); } else if(isRenderFunctionContext && type === 'VariableDeclaration') { - // const name = this.name; - // TODO const { name } = this; statement.declarations.forEach(declaration => { - const { init } = declaration; - if(init && init.object && init.object.type === 'ThisExpression') { + 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); + }) } }) } From 99d068384b9c6f626cdceb220ebb039cee929f77 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Fri, 2 Sep 2022 15:42:49 -0400 Subject: [PATCH 03/13] auto infer observedAttributes --- src/jsx-loader.js | 46 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/src/jsx-loader.js b/src/jsx-loader.js index 64cd53d5..061e6fb0 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -231,16 +231,17 @@ function findThisReferences(context, statement) { export function parseJsx(moduleURL) { const moduleContents = fs.readFileSync(moduleURL, 'utf-8'); - string = ''; - - const tree = acorn.Parser.extend(jsx()).parse(moduleContents, { - ecmaVersion: 'latest', - sourceType: 'module' - }); + const hasOwnObservedAttributes = undefined; const observedAttributes = { constructor: [], render: [] }; + let tree = acorn.Parser.extend(jsx()).parse(moduleContents, { + ecmaVersion: 'latest', + sourceType: 'module' + }); + string = ''; + walk.simple(tree, { ClassDeclaration(node) { @@ -293,7 +294,38 @@ export function parseJsx(moduleURL) { JSXElement: () => {} }); - console.debug('????????', { observedAttributes }); + // TODO - signals: use constructor, render, HTML attributes? some, none, or all? + if(observedAttributes.constructor.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); + newModuleContents = `${newModuleContents.slice(0, insertPoint)} + static get observedAttributes() { + return [${observedAttributes.constructor.map(attr => `'${attr}'`).join(',')}] + } + + ${newModuleContents.slice(insertPoint)} + `; + + tree = acorn.Parser.extend(jsx()).parse(newModuleContents, { + ecmaVersion: 'latest', + sourceType: 'module' + }); + } return tree; } From df649d70e41aa10016c9f7bd90e741c7d897e520 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Fri, 2 Sep 2022 16:02:07 -0400 Subject: [PATCH 04/13] fix linting --- src/jsx-loader.js | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/jsx-loader.js b/src/jsx-loader.js index 061e6fb0..4f3868f6 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -207,23 +207,23 @@ function findThisReferences(context, statement) { && expression.type === 'AssignmentExpression' && expression.left.object.type === 'ThisExpression'; - if(isConstructorThisAssignment) { + if (isConstructorThisAssignment) { // this.name = 'something'; // constructor references.push(expression.left.property.name); - } else if(isRenderFunctionContext && type === 'VariableDeclaration') { + } else if (isRenderFunctionContext && type === 'VariableDeclaration') { statement.declarations.forEach(declaration => { const { init, id } = declaration; - if(init.object && init.object.type === 'ThisExpression') { + if (init.object && init.object.type === 'ThisExpression') { // const { description } = this.todo; references.push(init.property.name); - } else if(init.type === 'ThisExpression' && id && id.properties) { + } else if (init.type === 'ThisExpression' && id && id.properties) { // const { description } = this.todo; id.properties.forEach((property) => { references.push(property.key.name); - }) + }); } - }) + }); } return references; @@ -242,7 +242,6 @@ export function parseJsx(moduleURL) { }); string = ''; - walk.simple(tree, { ClassDeclaration(node) { if (node.superClass.name === 'HTMLElement') { @@ -256,17 +255,17 @@ export function parseJsx(moduleURL) { observedAttributes.constructor = [ ...observedAttributes.constructor, ...findThisReferences('constructor', statement) - ] - }) + ]; + }); } else if (nodeName === 'render') { for (const n2 in n1.value.body.body) { const n = n1.value.body.body[n2]; - if(n.type === 'VariableDeclaration') { + if (n.type === 'VariableDeclaration') { observedAttributes.render = [ ...observedAttributes.render, ...findThisReferences('render', n) - ] + ]; } else if (n.type === 'ReturnStatement' && n.argument.type === 'JSXElement') { const html = parseJsxElement(n.argument, moduleContents); const elementTree = getParse(html)(html); @@ -295,16 +294,16 @@ export function parseJsx(moduleURL) { }); // TODO - signals: use constructor, render, HTML attributes? some, none, or all? - if(observedAttributes.constructor.length > 0 && !hasOwnObservedAttributes) { + if (observedAttributes.constructor.length > 0 && !hasOwnObservedAttributes) { let insertPoint; - for(const line of tree.body) { + 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' ) { + 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') { + for (const method of children) { + if (method.key.name === 'constructor') { insertPoint = method.start - 1; break; } From a448b940d0d1be6b0a3ef4c96b4c77c40a65e227 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Fri, 2 Sep 2022 18:00:38 -0400 Subject: [PATCH 05/13] inferred attributeChangedCallback --- src/jsx-loader.js | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/jsx-loader.js b/src/jsx-loader.js index 4f3868f6..d3426273 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -312,13 +312,42 @@ export function parseJsx(moduleURL) { } 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.constructor.map(attr => `'${attr}'`).join(',')}] + return [${[...observedAttributes.constructor].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.constructor.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', From e4a4f2670cd60b600ec26f5ea3344713769e592f Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Mon, 5 Sep 2022 12:10:26 -0400 Subject: [PATCH 06/13] refactor attribute tracking to use a Set --- src/jsx-loader.js | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/jsx-loader.js b/src/jsx-loader.js index d3426273..0853a61a 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -232,9 +232,11 @@ function findThisReferences(context, statement) { export function parseJsx(moduleURL) { const moduleContents = fs.readFileSync(moduleURL, 'utf-8'); const hasOwnObservedAttributes = undefined; + // TODO do we really need constructor and render checks? + // Just use render and consider attributes reflected on properties for now? const observedAttributes = { - constructor: [], - render: [] + constructor: new Set(), + render: new Set() }; let tree = acorn.Parser.extend(jsx()).parse(moduleContents, { ecmaVersion: 'latest', @@ -252,20 +254,16 @@ export function parseJsx(moduleURL) { const nodeName = n1.key.name; if (nodeName === 'constructor') { n1.value.body.body.forEach((statement) => { - observedAttributes.constructor = [ - ...observedAttributes.constructor, - ...findThisReferences('constructor', statement) - ]; + findThisReferences('render', statement) + .forEach(prop => observedAttributes.constructor.add(prop)); }); } else if (nodeName === 'render') { for (const n2 in n1.value.body.body) { const n = n1.value.body.body[n2]; if (n.type === 'VariableDeclaration') { - observedAttributes.render = [ - ...observedAttributes.render, - ...findThisReferences('render', n) - ]; + findThisReferences('render', n) + .forEach(prop => observedAttributes.render.add(prop)); } else if (n.type === 'ReturnStatement' && n.argument.type === 'JSXElement') { const html = parseJsxElement(n.argument, moduleContents); const elementTree = getParse(html)(html); @@ -294,7 +292,7 @@ export function parseJsx(moduleURL) { }); // TODO - signals: use constructor, render, HTML attributes? some, none, or all? - if (observedAttributes.constructor.length > 0 && !hasOwnObservedAttributes) { + if (observedAttributes.constructor.entries().length > 0 && !hasOwnObservedAttributes) { let insertPoint; for (const line of tree.body) { // test for class MyComponent vs export default class MyComponent @@ -317,7 +315,7 @@ export function parseJsx(moduleURL) { /* eslint-disable indent */ newModuleContents = `${newModuleContents.slice(0, insertPoint)} static get observedAttributes() { - return [${[...observedAttributes.constructor].map(attr => `'${attr}'`).join(',')}] + return [${observedAttributes.constructor.entries().map(attr => `'${attr}'`).join(',')}] } attributeChangedCallback(name, oldValue, newValue) { @@ -332,7 +330,7 @@ export function parseJsx(moduleURL) { } if (newValue !== oldValue) { switch(name) { - ${observedAttributes.constructor.map((attr) => { + ${observedAttributes.constructor.entries().map((attr) => { return ` case '${attr}': this.${attr} = getValue(newValue); From 51aa88281a048017c32235cbfd665e670b9a0a7e Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sun, 11 Sep 2022 17:17:39 -0400 Subject: [PATCH 07/13] revert usage of Set --- src/jsx-loader.js | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/jsx-loader.js b/src/jsx-loader.js index 0853a61a..d3426273 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -232,11 +232,9 @@ function findThisReferences(context, statement) { export function parseJsx(moduleURL) { const moduleContents = fs.readFileSync(moduleURL, 'utf-8'); const hasOwnObservedAttributes = undefined; - // TODO do we really need constructor and render checks? - // Just use render and consider attributes reflected on properties for now? const observedAttributes = { - constructor: new Set(), - render: new Set() + constructor: [], + render: [] }; let tree = acorn.Parser.extend(jsx()).parse(moduleContents, { ecmaVersion: 'latest', @@ -254,16 +252,20 @@ export function parseJsx(moduleURL) { const nodeName = n1.key.name; if (nodeName === 'constructor') { n1.value.body.body.forEach((statement) => { - findThisReferences('render', statement) - .forEach(prop => observedAttributes.constructor.add(prop)); + observedAttributes.constructor = [ + ...observedAttributes.constructor, + ...findThisReferences('constructor', statement) + ]; }); } else if (nodeName === 'render') { for (const n2 in n1.value.body.body) { const n = n1.value.body.body[n2]; if (n.type === 'VariableDeclaration') { - findThisReferences('render', n) - .forEach(prop => observedAttributes.render.add(prop)); + observedAttributes.render = [ + ...observedAttributes.render, + ...findThisReferences('render', n) + ]; } else if (n.type === 'ReturnStatement' && n.argument.type === 'JSXElement') { const html = parseJsxElement(n.argument, moduleContents); const elementTree = getParse(html)(html); @@ -292,7 +294,7 @@ export function parseJsx(moduleURL) { }); // TODO - signals: use constructor, render, HTML attributes? some, none, or all? - if (observedAttributes.constructor.entries().length > 0 && !hasOwnObservedAttributes) { + if (observedAttributes.constructor.length > 0 && !hasOwnObservedAttributes) { let insertPoint; for (const line of tree.body) { // test for class MyComponent vs export default class MyComponent @@ -315,7 +317,7 @@ export function parseJsx(moduleURL) { /* eslint-disable indent */ newModuleContents = `${newModuleContents.slice(0, insertPoint)} static get observedAttributes() { - return [${observedAttributes.constructor.entries().map(attr => `'${attr}'`).join(',')}] + return [${[...observedAttributes.constructor].map(attr => `'${attr}'`).join(',')}] } attributeChangedCallback(name, oldValue, newValue) { @@ -330,7 +332,7 @@ export function parseJsx(moduleURL) { } if (newValue !== oldValue) { switch(name) { - ${observedAttributes.constructor.entries().map((attr) => { + ${observedAttributes.constructor.map((attr) => { return ` case '${attr}': this.${attr} = getValue(newValue); From a1afe16ea435deb9270ee899e6a07f2937272258 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Fri, 30 Dec 2022 12:52:26 -0500 Subject: [PATCH 08/13] minor describe block refactoring for jsx spec --- test/cases/jsx/jsx.spec.js | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/test/cases/jsx/jsx.spec.js b/test/cases/jsx/jsx.spec.js index 5e5de4ce..99cfbd4e 100644 --- a/test/cases/jsx/jsx.spec.js +++ b/test/cases/jsx/jsx.spec.js @@ -10,7 +10,6 @@ * 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();'); }); }); From 0701b30dce0e9d442426ead4c0ec22d6d30bdb7a Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Fri, 30 Dec 2022 17:50:06 -0500 Subject: [PATCH 09/13] make inferred observability opt-in --- src/jsx-loader.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/jsx-loader.js b/src/jsx-loader.js index d3426273..151112a1 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -231,6 +231,10 @@ function findThisReferences(context, statement) { export function parseJsx(moduleURL) { const moduleContents = fs.readFileSync(moduleURL, 'utf-8'); + // would be nice if we could do this instead, so we could know ahead of time + // however, this requires making parseJsx async, but WCC acorn walking is done sync + // const { inferredObservability } = await import(moduleURL); + let inferredObservability = false; const hasOwnObservedAttributes = undefined; const observedAttributes = { constructor: [], @@ -286,6 +290,15 @@ export function parseJsx(moduleURL) { } } } + }, + 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 @@ -294,7 +307,7 @@ export function parseJsx(moduleURL) { }); // TODO - signals: use constructor, render, HTML attributes? some, none, or all? - if (observedAttributes.constructor.length > 0 && !hasOwnObservedAttributes) { + if (inferredObservability && observedAttributes.constructor.length > 0 && !hasOwnObservedAttributes) { let insertPoint; for (const line of tree.body) { // test for class MyComponent vs export default class MyComponent From 3fd435fad160479b28d46390eae9852832a842ab Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Fri, 30 Dec 2022 17:50:45 -0500 Subject: [PATCH 10/13] add and extend test cases for JSX and inferred observability --- package.json | 2 +- .../fixtures/attribute-changed-callback.txt | 13 +++++ .../fixtures/get-observed-attributes.txt | 3 ++ .../jsx-coarse-grained.spec.js | 52 +++++++++++++++++++ test/cases/jsx-coarse-grained/src/counter.jsx | 40 ++++++++++++++ test/cases/jsx/jsx.spec.js | 11 +++- 6 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 test/cases/jsx-coarse-grained/fixtures/attribute-changed-callback.txt create mode 100644 test/cases/jsx-coarse-grained/fixtures/get-observed-attributes.txt create mode 100644 test/cases/jsx-coarse-grained/jsx-coarse-grained.spec.js create mode 100644 test/cases/jsx-coarse-grained/src/counter.jsx 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/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 99cfbd4e..981e4297 100644 --- a/test/cases/jsx/jsx.spec.js +++ b/test/cases/jsx/jsx.spec.js @@ -3,7 +3,7 @@ * 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/ @@ -81,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 From 0e2cab5e3d69462fb420abc9e58bb41b2d08849f Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Wed, 4 Jan 2023 12:02:12 -0500 Subject: [PATCH 11/13] update documentation for inferredObservability --- docs/pages/docs.md | 58 ++++++++++++++++++++++++++++++++++++++---- docs/pages/examples.md | 7 ++++- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/docs/pages/docs.md b/docs/pages/docs.md index 13eee6b1..190c9f13 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 system 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 From f12f1bcb97b23ac682f24c82aace8e4316f157dd Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Wed, 4 Jan 2023 13:13:15 -0500 Subject: [PATCH 12/13] add links --- docs/pages/docs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pages/docs.md b/docs/pages/docs.md index 190c9f13..c7f18c6c 100644 --- a/docs/pages/docs.md +++ b/docs/pages/docs.md @@ -248,5 +248,5 @@ And so now when the attribute is set on this component, the component will re-re 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 system 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). +- 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 From ebd3b2525260d3952f186e59be9e81d2028ad649 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Wed, 4 Jan 2023 13:46:40 -0500 Subject: [PATCH 13/13] only scan render function for this references --- src/jsx-loader.js | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/src/jsx-loader.js b/src/jsx-loader.js index 151112a1..2d484c10 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -198,6 +198,7 @@ function parseJsxElement(element, moduleContents = '') { } // TODO handle if / else statements +// https://github.com/ProjectEvergreen/wcc/issues/88 function findThisReferences(context, statement) { const references = []; const isRenderFunctionContext = context === 'render'; @@ -232,14 +233,11 @@ function findThisReferences(context, statement) { export function parseJsx(moduleURL) { const moduleContents = fs.readFileSync(moduleURL, 'utf-8'); // would be nice if we could do this instead, so we could know ahead of time - // however, this requires making parseJsx async, but WCC acorn walking is done sync // const { inferredObservability } = await import(moduleURL); - let inferredObservability = false; + // however, this requires making parseJsx async, but WCC acorn walking is done sync const hasOwnObservedAttributes = undefined; - const observedAttributes = { - constructor: [], - render: [] - }; + let inferredObservability = false; + let observedAttributes = []; let tree = acorn.Parser.extend(jsx()).parse(moduleContents, { ecmaVersion: 'latest', sourceType: 'module' @@ -254,20 +252,13 @@ export function parseJsx(moduleURL) { for (const n1 of node.body.body) { if (n1.type === 'MethodDefinition') { const nodeName = n1.key.name; - if (nodeName === 'constructor') { - n1.value.body.body.forEach((statement) => { - observedAttributes.constructor = [ - ...observedAttributes.constructor, - ...findThisReferences('constructor', statement) - ]; - }); - } else if (nodeName === 'render') { + if (nodeName === 'render') { for (const n2 in n1.value.body.body) { const n = n1.value.body.body[n2]; if (n.type === 'VariableDeclaration') { - observedAttributes.render = [ - ...observedAttributes.render, + observedAttributes = [ + ...observedAttributes, ...findThisReferences('render', n) ]; } else if (n.type === 'ReturnStatement' && n.argument.type === 'JSXElement') { @@ -307,7 +298,7 @@ export function parseJsx(moduleURL) { }); // TODO - signals: use constructor, render, HTML attributes? some, none, or all? - if (inferredObservability && observedAttributes.constructor.length > 0 && !hasOwnObservedAttributes) { + if (inferredObservability && observedAttributes.length > 0 && !hasOwnObservedAttributes) { let insertPoint; for (const line of tree.body) { // test for class MyComponent vs export default class MyComponent @@ -330,7 +321,7 @@ export function parseJsx(moduleURL) { /* eslint-disable indent */ newModuleContents = `${newModuleContents.slice(0, insertPoint)} static get observedAttributes() { - return [${[...observedAttributes.constructor].map(attr => `'${attr}'`).join(',')}] + return [${[...observedAttributes].map(attr => `'${attr}'`).join(',')}] } attributeChangedCallback(name, oldValue, newValue) { @@ -345,7 +336,7 @@ export function parseJsx(moduleURL) { } if (newValue !== oldValue) { switch(name) { - ${observedAttributes.constructor.map((attr) => { + ${observedAttributes.map((attr) => { return ` case '${attr}': this.${attr} = getValue(newValue);