diff --git a/docs/pages/docs.md b/docs/pages/docs.md index a735922f..c28e66aa 100644 --- a/docs/pages/docs.md +++ b/docs/pages/docs.md @@ -447,13 +447,15 @@ customElements.define('wcc-counter', Counter); ### (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 optional feature supported by JSX based compilation is `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()`) +1. an entry in the `observedAttributes` array +1. automatically handle `attributeChangedCallback` updates So taking the above counter example, and opting in to this feature, we just need to enable the `inferredObservability` option in the component by exporting it as a `const`: + + ```jsx export const inferredObservability = true; @@ -463,9 +465,10 @@ export default class Counter extends HTMLElement { render() { const { count } = this; + // note that {count} has to be wrapped in its own HTML tag return (
- + You have clicked {count} times @@ -476,7 +479,9 @@ export default class Counter extends HTMLElement { } ``` -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`! + + +And so now when the attribute is set on this component, the component will re-render automatically using fine-grained updates; no need to write out `observedAttributes` or `attributeChangedCallback`! ```html @@ -484,6 +489,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 / open items related 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. +- This automatically reflects properties used in the `render` function to attributes, so [YMMV](https://dictionary.cambridge.org/us/dictionary/english/ymmv). +- Please be aware of the above linked discussion and issue filter which is tracking any known bugs / feature requests / open items related to all things WCC + JSX. diff --git a/sandbox/components/counter-dsd.jsx b/sandbox/components/counter-dsd.jsx index 6639575d..5b12dca5 100644 --- a/sandbox/components/counter-dsd.jsx +++ b/sandbox/components/counter-dsd.jsx @@ -4,7 +4,7 @@ export default class CounterDsdJsx extends HTMLElement { connectedCallback() { if (!this.shadowRoot) { console.warn('NO shadowRoot detected for counter-dsd.jsx!'); - this.count = this.getAttribute('count') || 0; + this.count = parseInt(this.getAttribute('count'), 10) || 0; // having an attachShadow call is required for DSD this.attachShadow({ mode: 'open' }); diff --git a/src/jsx-loader.js b/src/jsx-loader.js index 32e397b3..55b17e20 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -77,7 +77,7 @@ function applyDomDepthSubstitutions(tree, currentDepth = 1, hasShadowRoot = fals return tree; } -function parseJsxElement(element, moduleContents = '') { +function parseJsxElement(element, moduleContents = '', inferredObservability = false) { try { const { type } = element; @@ -124,8 +124,14 @@ function parseJsxElement(element, moduleContents = '') { if (left.object.type === 'ThisExpression') { if (left.property.type === 'Identifier') { - // very naive (fine grained?) reactivity - string += ` ${name}="__this__.${left.property.name}${expression.operator}${right.raw}; __this__.render();"`; + if (inferredObservability) { + // very naive (fine grained?) reactivity + // string += ` ${name}="__this__.${left.property.name}${expression.operator}${right.raw}; __this__.update(\\'${left.property.name}\\', null, __this__.${left.property.name});"`; + string += ` ${name}="__this__.${left.property.name}${expression.operator}${right.raw}; __this__.setAttribute(\\'${left.property.name}\\', __this__.${left.property.name});"`; + } else { + // implicit reactivity using this.render + string += ` ${name}="__this__.${left.property.name}${expression.operator}${right.raw}; __this__.render();"`; + } } } } @@ -160,6 +166,11 @@ function parseJsxElement(element, moduleContents = '') { default: break; } + + // only apply this when dealing with `this` references + if (inferredObservability) { + string += ` data-wcc-${expression.name}="${name}" data-wcc-ins="attr"`; + } } } else { // xxx > @@ -171,7 +182,9 @@ function parseJsxElement(element, moduleContents = '') { string += openingElement.selfClosing ? ' />' : '>'; if (element.children.length > 0) { - element.children.forEach((child) => parseJsxElement(child, moduleContents)); + element.children.forEach((child) => + parseJsxElement(child, moduleContents, inferredObservability), + ); } string += ``; @@ -186,6 +199,13 @@ function parseJsxElement(element, moduleContents = '') { if (type === 'Identifier') { // You have {count} TODOs left to complete + if (inferredObservability) { + const { name } = element.expression; + + string = `${string.slice(0, string.lastIndexOf('>'))} data-wcc-${name}="\${this.${name}}" data-wcc-ins="text">`; + } + // TODO be able to remove this extra data attribute + // string = `${string.slice(0, string.lastIndexOf('>'))} data-wcc-${name} data-wcc-ins="text">`; string += `$\{${element.expression.name}}`; } else if (type === 'MemberExpression') { const { object } = element.expression.object; @@ -233,10 +253,16 @@ function findThisReferences(context, statement) { // const { description } = this.todo; references.push(init.property.name); } else if (init.type === 'ThisExpression' && id && id.properties) { - // const { description } = this.todo; + // const { id, description } = this; id.properties.forEach((property) => { references.push(property.key.name); }); + } else { + // TODO we are just blindly tracking anything here. + // everything should ideally be mapped to actual this references, to create a strong chain of direct reactivity + // instead of tracking any declaration as a derived tracking attr + // for convenience here, we push the entire declaration here, instead of the name like for direct this references (see above) + references.push(declaration); } }); } @@ -262,6 +288,35 @@ export function parseJsx(moduleURL) { }); string = ''; + // TODO: would be nice to do this one pass, but first we need to know if `inferredObservability` is set first + walk.simple( + tree, + { + ExportNamedDeclaration(node) { + const { declaration } = node; + + if ( + declaration && + declaration.type === 'VariableDeclaration' && + declaration.kind === 'const' && + declaration.declarations.length === 1 + ) { + // @ts-ignore + if (declaration.declarations[0].id.name === 'inferredObservability') { + // @ts-ignore + inferredObservability = Boolean(node.declaration.declarations[0].init.raw); + } + } + }, + }, + { + // https://github.com/acornjs/acorn/issues/829#issuecomment-1172586171 + ...walk.base, + // @ts-ignore + JSXElement: () => {}, + }, + ); + walk.simple( tree, { @@ -286,7 +341,7 @@ export function parseJsx(moduleURL) { ]; // @ts-ignore } else if (n.type === 'ReturnStatement' && n.argument.type === 'JSXElement') { - const html = parseJsxElement(n.argument, moduleContents); + const html = parseJsxElement(n.argument, moduleContents, inferredObservability); const elementTree = getParse(html)(html); const elementRoot = hasShadowRoot ? 'this.shadowRoot' : 'this'; @@ -325,22 +380,6 @@ export function parseJsx(moduleURL) { } } }, - ExportNamedDeclaration(node) { - const { declaration } = node; - - if ( - declaration && - declaration.type === 'VariableDeclaration' && - declaration.kind === 'const' && - declaration.declarations.length === 1 - ) { - // @ts-ignore - if (declaration.declarations[0].id.name === 'inferredObservability') { - // @ts-ignore - inferredObservability = Boolean(node.declaration.declarations[0].init.raw); - } - } - }, }, { // https://github.com/acornjs/acorn/issues/829#issuecomment-1172586171 @@ -350,11 +389,10 @@ export function parseJsx(moduleURL) { }, ); - // 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 + // TODO: test for class MyComponent vs export default class MyComponent // @ts-ignore if ( line.type === 'ClassDeclaration' || @@ -366,11 +404,36 @@ export function parseJsx(moduleURL) { } let newModuleContents = generate(tree); - - // TODO better way to determine value type? + const trackingAttrs = observedAttributes.filter((attr) => typeof attr === 'string'); + // TODO ideally derivedAttrs would explicitly reference trackingAttrs + // and if there are no derivedAttrs, do not include the derivedGetters / derivedSetters code in the compiled output + const derivedAttrs = observedAttributes.filter((attr) => typeof attr !== 'string'); + const derivedGetters = derivedAttrs + .map((attr) => { + return ` + get_${attr.id.name}(${trackingAttrs.join(',')}) { + return ${moduleContents.slice(attr.init.start, attr.init.end)} + } + `; + }) + .join('\n'); + const derivedSetters = derivedAttrs + .map((attr) => { + const name = attr.id.name; + + return ` + const old_${name} = this.get_${name}(oldValue); + const new_${name} = this.get_${name}(newValue); + this.update('${name}', old_${name}, new_${name}); + `; + }) + .join('\n'); + + // TODO: better way to determine value type, e,g. array, int, object, etc? + // TODO: better way to test for shadowRoot presence when running querySelectorAll newModuleContents = `${newModuleContents.slice(0, insertPoint)} static get observedAttributes() { - return [${[...observedAttributes].map((attr) => `'${attr}'`).join(',')}] + return [${[...trackingAttrs].map((attr) => `'${attr}'`).join()}] } attributeChangedCallback(name, oldValue, newValue) { @@ -385,21 +448,47 @@ export function parseJsx(moduleURL) { } if (newValue !== oldValue) { switch(name) { - ${observedAttributes + ${trackingAttrs .map((attr) => { return ` - case '${attr}': - this.${attr} = getValue(newValue); - break; - `; + case '${attr}': + this.${attr} = getValue(newValue); + break; + `; }) .join('\n')} } + this.update(name, oldValue, newValue); + } + } - this.render(); + update(name, oldValue, newValue) { + const attr = \`data-wcc-\${name}\`; + const selector = \`[\${attr}]\`; + + (this?.shadowRoot || this).querySelectorAll(selector).forEach((el) => { + // handle empty strings as a value for the purposes of attribute change detection + const needle = oldValue === '' ? '' : oldValue ?? el.getAttribute(attr); + + switch(el.getAttribute('data-wcc-ins')) { + case 'text': + el.textContent = el.textContent.replace(needle, newValue); + break; + case 'attr': + if (el.hasAttribute(el.getAttribute(attr))) { + el.setAttribute(el.getAttribute(attr), newValue); + } + break; + } + }) + + if ([${[...trackingAttrs].map((attr) => `'${attr}'`).join()}].includes(name)) { + ${derivedSetters} } } + ${derivedGetters} + ${newModuleContents.slice(insertPoint)} `; diff --git a/test/cases/jsx-inferred-observability/fixtures/attribute-changed-callback.txt b/test/cases/jsx-inferred-observability/fixtures/attribute-changed-callback.txt index 3801a391..ccc626bd 100644 --- a/test/cases/jsx-inferred-observability/fixtures/attribute-changed-callback.txt +++ b/test/cases/jsx-inferred-observability/fixtures/attribute-changed-callback.txt @@ -1,13 +1,40 @@ 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 + 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; + case 'highlight': + this.highlight = getValue(newValue); + break; + } + this.update(name, oldValue, newValue); + } + } + update(name, oldValue, newValue) { + const attr = `data-wcc-${ name }`; + const selector = `[${ attr }]`; + + (this?.shadowRoot || this).querySelectorAll(selector).forEach(el => { + const needle = oldValue === '' ? '' : oldValue ?? el.getAttribute(attr); + + switch (el.getAttribute('data-wcc-ins')) { + case 'text': + el.textContent = el.textContent.replace(needle, newValue); + break; + case 'attr': + if (el.hasAttribute(el.getAttribute(attr))) { + el.setAttribute(el.getAttribute(attr), newValue); + } + break; + } + }); + if ([ + 'count', + 'highlight' + ].includes(name)) { + } + } \ No newline at end of file diff --git a/test/cases/jsx-inferred-observability/fixtures/get-observed-attributes.txt b/test/cases/jsx-inferred-observability/fixtures/get-observed-attributes.txt index 4421db23..97fc2020 100644 --- a/test/cases/jsx-inferred-observability/fixtures/get-observed-attributes.txt +++ b/test/cases/jsx-inferred-observability/fixtures/get-observed-attributes.txt @@ -1,3 +1,3 @@ static get observedAttributes() { - return['count']; + return['count', 'highlight']; } \ No newline at end of file diff --git a/test/cases/jsx-inferred-observability/jsx-inferred-obsevability.spec.js b/test/cases/jsx-inferred-observability/jsx-inferred-obsevability.spec.js index f6795288..fc230375 100644 --- a/test/cases/jsx-inferred-observability/jsx-inferred-obsevability.spec.js +++ b/test/cases/jsx-inferred-observability/jsx-inferred-obsevability.spec.js @@ -11,6 +11,7 @@ */ import chai from 'chai'; import fs from 'fs/promises'; +import { JSDOM } from 'jsdom'; import { renderToString } from '../../../src/wcc.js'; const expect = chai.expect; @@ -20,11 +21,13 @@ describe('Run WCC For ', function () { let fixtureAttributeChangedCallback; let fixtureGetObservedAttributes; let meta; + let dom; before(async function () { - const { metadata } = await renderToString(new URL('./src/counter.jsx', import.meta.url)); + const { html, metadata } = await renderToString(new URL('./src/counter.jsx', import.meta.url)); meta = metadata; + dom = new JSDOM(html); fixtureAttributeChangedCallback = await fs.readFile( new URL('./fixtures/attribute-changed-callback.txt', import.meta.url), @@ -51,6 +54,26 @@ describe('Run WCC For ', function () { expect(actual).to.contain(expected); }); + + // + it('should have the expected observability attributes on the component', () => { + const badge = dom.window.document.querySelector('wcc-badge'); + const conditionalClassSpan = badge.querySelector('span[class="unmet"]'); // conditional class rendering + + expect(badge.getAttribute('data-wcc-count')).to.equal('count'); + expect(badge.getAttribute('data-wcc-ins')).to.equal('attr'); + + expect(conditionalClassSpan.textContent.trim()).to.equal('0'); + }); + + // 0 + it('should have the expected observability attributes on the component', () => { + const span = dom.window.document.querySelector('wcc-counter-jsx span[class="red"]'); + + expect(span.getAttribute('data-wcc-highlight')).to.equal('class'); + expect(span.getAttribute('data-wcc-ins')).to.equal('attr'); + expect(span.textContent.trim()).to.equal('0'); + }); }); }); }); diff --git a/test/cases/jsx-inferred-observability/src/badge.jsx b/test/cases/jsx-inferred-observability/src/badge.jsx new file mode 100644 index 00000000..140c4036 --- /dev/null +++ b/test/cases/jsx-inferred-observability/src/badge.jsx @@ -0,0 +1,37 @@ +export default class BadgeComponent extends HTMLElement { + count; + predicate; + + constructor() { + super(); + + this.count = 0; + this.predicate = false; + } + + connectedCallback() { + this.render(); + } + + static get observedAttributes() { + return ['count', 'predicate']; + } + + render() { + const { count, predicate } = this; + const conditionalClass = predicate ? 'met' : 'unmet'; + const conditionalText = predicate ? ' 🥳' : ''; + + return ( + + Badge Icon + + {count} + {conditionalText} + + + ); + } +} + +customElements.define('wcc-badge', BadgeComponent); diff --git a/test/cases/jsx-inferred-observability/src/counter.jsx b/test/cases/jsx-inferred-observability/src/counter.jsx index b1e964e7..478ef0e9 100644 --- a/test/cases/jsx-inferred-observability/src/counter.jsx +++ b/test/cases/jsx-inferred-observability/src/counter.jsx @@ -1,9 +1,12 @@ +import './badge.jsx'; + export const inferredObservability = true; export default class Counter extends HTMLElement { constructor() { super(); this.count = 0; + this.highlight = 'red'; } increment() { @@ -21,7 +24,7 @@ export default class Counter extends HTMLElement { } render() { - const { count } = this; + const { count, highlight } = this; return (
@@ -37,7 +40,7 @@ export default class Counter extends HTMLElement { You have clicked{' '} - + {count} {' '} times diff --git a/test/cases/jsx-shadow-dom/jsx-shadow-dom.spec.js b/test/cases/jsx-shadow-dom/jsx-shadow-dom.spec.js index e0c1e4e3..1c2752c8 100644 --- a/test/cases/jsx-shadow-dom/jsx-shadow-dom.spec.js +++ b/test/cases/jsx-shadow-dom/jsx-shadow-dom.spec.js @@ -62,7 +62,7 @@ describe('Run WCC For ', function () { const wrapper = new JSDOM(heading.innerHTML); const header = wrapper.window.document.querySelector('h1'); - expect(header.textContent).to.be.equal('Hello, World!'); + expect(header.textContent.trim()).to.be.equal('Hello, World!'); }); }); }); diff --git a/test/cases/jsx-shadow-dom/src/heading.jsx b/test/cases/jsx-shadow-dom/src/heading.jsx index a3c78cb0..6dcd10e5 100644 --- a/test/cases/jsx-shadow-dom/src/heading.jsx +++ b/test/cases/jsx-shadow-dom/src/heading.jsx @@ -17,7 +17,9 @@ export default class HeadingComponent extends HTMLElement { return (
-

Hello, {greeting}!

+

+ Hello, {greeting}! +

); diff --git a/test/cases/jsx/jsx.spec.js b/test/cases/jsx/jsx.spec.js index df814cd6..b5a4d000 100644 --- a/test/cases/jsx/jsx.spec.js +++ b/test/cases/jsx/jsx.spec.js @@ -20,10 +20,12 @@ describe('Run WCC For ', function () { const LABEL = 'Single Custom Element using JSX'; let dom; let meta; + let output; before(async function () { const { html, metadata } = await renderToString(new URL('./src/counter.jsx', import.meta.url)); + output = html; meta = metadata; dom = new JSDOM(html); }); @@ -95,6 +97,7 @@ describe('Run WCC For ', function () { expect(actual).to.not.contain('staticgetobservedAttributes()'); expect(actual).to.not.contain('attributeChangedCallback'); + expect(output).to.not.contain('data-wcc'); }); }); }); diff --git a/test/cases/tsx-inferred-observability/fixtures/attribute-changed-callback.txt b/test/cases/tsx-inferred-observability/fixtures/attribute-changed-callback.txt index 3801a391..bc925d60 100644 --- a/test/cases/tsx-inferred-observability/fixtures/attribute-changed-callback.txt +++ b/test/cases/tsx-inferred-observability/fixtures/attribute-changed-callback.txt @@ -8,6 +8,6 @@ attributeChangedCallback(name, oldValue, newValue) { this.count = getValue(newValue); break; } - this.render(); + this.update(name,oldValue,newValue); } } \ No newline at end of file diff --git a/test/cases/tsx-inferred-observability/src/counter.tsx b/test/cases/tsx-inferred-observability/src/counter.tsx index 340c0fef..1aa52739 100644 --- a/test/cases/tsx-inferred-observability/src/counter.tsx +++ b/test/cases/tsx-inferred-observability/src/counter.tsx @@ -27,7 +27,6 @@ export default class Counter extends HTMLElement { return (
-

Counter JSX