From 84e31245e1249833ddf92ef4f75faac7f1c72952 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Wed, 4 Jan 2023 16:53:51 -0500 Subject: [PATCH 01/12] implementing fine grained observability --- src/jsx-loader.js | 49 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/src/jsx-loader.js b/src/jsx-loader.js index 32e397b3..21f73bf7 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -125,7 +125,8 @@ 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();"`; + // 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});"`; } } } @@ -160,6 +161,9 @@ function parseJsxElement(element, moduleContents = '') { default: break; } + + // TODO make sure this only applies to `this` references! + string += ` data-wcc-${expression.name}="${name}" data-wcc-ins="attr"`; } } else { // xxx > @@ -186,6 +190,11 @@ function parseJsxElement(element, moduleContents = '') { if (type === 'Identifier') { // You have {count} TODOs left to complete + 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; @@ -374,6 +383,9 @@ export function parseJsx(moduleURL) { } attributeChangedCallback(name, oldValue, newValue) { + console.debug('???attributeChangedCallback', { name }); + console.debug('???attributeChangedCallback', { oldValue }); + console.debug('???attributeChangedCallback', { newValue }); function getValue(value) { return value.charAt(0) === '{' || value.charAt(0) === '[' ? JSON.parse(value) @@ -395,11 +407,42 @@ export function parseJsx(moduleURL) { }) .join('\n')} } - - this.render(); + this.update(name, oldValue, newValue); } } + update(name, oldValue, newValue) { + console.debug('Update tracking against....', this.constructor.observedAttributes); + console.debug('Updating', name); + console.debug('Swap old', oldValue); + console.debug('For new', newValue); + console.debug('this[name]', this[name]); + const attr = \`data-wcc-\${name}\`; + const selector = \`[\${attr}]\`; + console.debug({ attr }); + console.debug({ selector }); + + this.querySelectorAll(selector).forEach((el) => { + const needle = oldValue || el.getAttribute(attr); + console.debug({ el }) + console.debug({ needle }); + console.debug({ newValue }); + switch(el.getAttribute('data-wcc-ins')) { + case 'text': + el.textContent = el.textContent.replace(needle, newValue); + break; + case 'attr': + console.debug(el.hasAttribute(attr)) + if (el.hasAttribute(el.getAttribute(attr))) { + el.setAttribute(el.getAttribute(attr), newValue); + } + break; + } + }) + + console.debug('****************************'); + } + ${newModuleContents.slice(insertPoint)} `; From b320b386eda4580f73f3beb182604171bb18f9fc Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sun, 8 Jan 2023 15:18:25 -0500 Subject: [PATCH 02/12] naive implementation of derived attribute tracking --- src/jsx-loader.js | 48 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/src/jsx-loader.js b/src/jsx-loader.js index 21f73bf7..53db8bcd 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -242,10 +242,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); } }); } @@ -375,11 +381,33 @@ export function parseJsx(moduleURL) { } let newModuleContents = generate(tree); + const trackingAttrs = observedAttributes.filter(attr => typeof attr === 'string'); + // TODO ideally derivedAttrs would explicitely 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(',')}) { + console.log('@@@@@@@@@@@@@@@@@@@@ updating derivative value for => ${attr.id.name}'); + console.log('@@@@@@@@@@@@@@@@@@@@ new derivative value is =>', ${moduleContents.slice(attr.init.start, attr.init.end)}); + 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? newModuleContents = `${newModuleContents.slice(0, insertPoint)} static get observedAttributes() { - return [${[...observedAttributes].map((attr) => `'${attr}'`).join(',')}] + return [${[...trackingAttrs].map((attr) => `'${attr}'`).join()}] } attributeChangedCallback(name, oldValue, newValue) { @@ -397,13 +425,13 @@ 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')} } @@ -432,7 +460,6 @@ export function parseJsx(moduleURL) { el.textContent = el.textContent.replace(needle, newValue); break; case 'attr': - console.debug(el.hasAttribute(attr)) if (el.hasAttribute(el.getAttribute(attr))) { el.setAttribute(el.getAttribute(attr), newValue); } @@ -440,9 +467,14 @@ export function parseJsx(moduleURL) { } }) + if ([${[...trackingAttrs].map(attr => `'${attr}'`).join()}].includes(name)) { + ${derivedSetters} + } console.debug('****************************'); } + ${derivedGetters} + ${newModuleContents.slice(insertPoint)} `; From afbaba7d201d722a838c7d5ce870b9d03cc4d710 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Mon, 8 Jan 2024 21:16:53 -0500 Subject: [PATCH 03/12] track and document caveats with inferredObservability targeting --- docs/pages/docs.md | 15 +++--- sandbox/components/counter-dsd.jsx | 2 +- .../fixtures/attribute-changed-callback.txt | 52 +++++++++++++++---- test/cases/jsx-shadow-dom/src/heading.jsx | 2 +- test/cases/jsx/jsx.spec.js | 3 +- 5 files changed, 52 insertions(+), 22 deletions(-) diff --git a/docs/pages/docs.md b/docs/pages/docs.md index a735922f..5807acc4 100644 --- a/docs/pages/docs.md +++ b/docs/pages/docs.md @@ -447,10 +447,9 @@ 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 entry in the `observedAttributes` array -- automatically handle `attributeChangedCallback` update (by calling `this.render()`) +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: +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`: @@ -463,9 +462,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 @@ -484,6 +484,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. +- 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. +- This automatically reflects properties used in the `render` function to attributes, so [YMMV](https://dictionary.cambridge.org/us/dictionary/english/ymmv). 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/test/cases/jsx-inferred-observability/fixtures/attribute-changed-callback.txt b/test/cases/jsx-inferred-observability/fixtures/attribute-changed-callback.txt index 3801a391..87fb7217 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,43 @@ 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(); - } + 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.update(name, oldValue, newValue); + } +} +update(name, oldValue, newValue) { + console.debug('Update tracking against....', this.constructor.observedAttributes); + console.debug('Updating', name); + console.debug('Swap old', oldValue); + console.debug('For new', newValue); + console.debug('this[name]', this[name]); + const attr = `data-wcc-${ name }`; + const selector = `[${ attr }]`; + console.debug({ attr }); + console.debug({ selector }); + this.querySelectorAll(selector).forEach(el => { + const needle = oldValue || el.getAttribute(attr); + console.debug({ el }); + console.debug({ needle }); + console.debug({ newValue }); + 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'].includes(name)) { + } + console.debug('****************************'); } \ No newline at end of file diff --git a/test/cases/jsx-shadow-dom/src/heading.jsx b/test/cases/jsx-shadow-dom/src/heading.jsx index a3c78cb0..31164474 100644 --- a/test/cases/jsx-shadow-dom/src/heading.jsx +++ b/test/cases/jsx-shadow-dom/src/heading.jsx @@ -17,7 +17,7 @@ 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..f3006d5c 100644 --- a/test/cases/jsx/jsx.spec.js +++ b/test/cases/jsx/jsx.spec.js @@ -24,6 +24,7 @@ describe('Run WCC For ', function () { before(async function () { const { html, metadata } = await renderToString(new URL('./src/counter.jsx', import.meta.url)); + console.log({ html }); meta = metadata; dom = new JSDOM(html); }); @@ -75,7 +76,7 @@ describe('Run WCC For ', function () { ); expect(element.getAttribute('onclick')).to.be.equal( - 'this.parentElement.parentElement.count-=1; this.parentElement.parentElement.render();', + 'this.parentElement.parentElement.count-=1; this.parentElement.parentElement.setAttribute(\'count\', this.parentElement.parentElement.count);' ); }); }); From e73ceec98011132e7867b16172a456cf6860521e Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Mon, 8 Jan 2024 21:36:21 -0500 Subject: [PATCH 04/12] update fixture --- .../fixtures/attribute-changed-callback.txt | 112 ++++++++++++------ 1 file changed, 74 insertions(+), 38 deletions(-) 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 87fb7217..6dd712b6 100644 --- a/test/cases/jsx-inferred-observability/fixtures/attribute-changed-callback.txt +++ b/test/cases/jsx-inferred-observability/fixtures/attribute-changed-callback.txt @@ -1,43 +1,79 @@ -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; +export const inferredObservability = true; +export default class Counter extends HTMLElement { + static get observedAttributes() { + return ['count']; + } + attributeChangedCallback(name, oldValue, newValue) { + console.debug('???attributeChangedCallback', { name }); + console.debug('???attributeChangedCallback', { oldValue }); + console.debug('???attributeChangedCallback', { 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; } - this.update(name, oldValue, newValue); - } -} -update(name, oldValue, newValue) { - console.debug('Update tracking against....', this.constructor.observedAttributes); - console.debug('Updating', name); - console.debug('Swap old', oldValue); - console.debug('For new', newValue); - console.debug('this[name]', this[name]); - const attr = `data-wcc-${ name }`; - const selector = `[${ attr }]`; - console.debug({ attr }); - console.debug({ selector }); - this.querySelectorAll(selector).forEach(el => { - const needle = oldValue || el.getAttribute(attr); - console.debug({ el }); - console.debug({ needle }); - console.debug({ newValue }); - 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); + if (newValue !== oldValue) { + switch (name) { + case 'count': + this.count = getValue(newValue); + break; } - break; + this.update(name, oldValue, newValue); } - }); - if (['count'].includes(name)) { } - console.debug('****************************'); + update(name, oldValue, newValue) { + console.debug('Update tracking against....', this.constructor.observedAttributes); + console.debug('Updating', name); + console.debug('Swap old', oldValue); + console.debug('For new', newValue); + console.debug('this[name]', this[name]); + const attr = `data-wcc-${ name }`; + const selector = `[${ attr }]`; + console.debug({ attr }); + console.debug({ selector }); + this.querySelectorAll(selector).forEach(el => { + const needle = oldValue || el.getAttribute(attr); + console.debug({ el }); + console.debug({ needle }); + console.debug({ newValue }); + 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'].includes(name)) { + } + console.debug('****************************'); + } + constructor() { + super(); + this.count = 0; + } + increment() { + this.count += 1; + this.render(); + } + decrement() { + this.count -= 1; + this.render(); + } + connectedCallback() { + this.render(); + } + render() { + const {count} = this; + this.innerHTML = `
+ +

Counter JSX

+ + + You have clicked ${ count } times + + +
`; + } } \ No newline at end of file From 778183aa3fef954b721ebfa4e6079414609037aa Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Tue, 9 Jan 2024 18:56:11 -0500 Subject: [PATCH 05/12] add attribute to inferredObservablity test case --- .../fixtures/attribute-changed-callback.txt | 45 ++++--------------- .../fixtures/get-observed-attributes.txt | 2 +- .../src/counter.jsx | 23 +++------- 3 files changed, 16 insertions(+), 54 deletions(-) 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 6dd712b6..6fa9d6a5 100644 --- a/test/cases/jsx-inferred-observability/fixtures/attribute-changed-callback.txt +++ b/test/cases/jsx-inferred-observability/fixtures/attribute-changed-callback.txt @@ -1,9 +1,4 @@ -export const inferredObservability = true; -export default class Counter extends HTMLElement { - static get observedAttributes() { - return ['count']; - } - attributeChangedCallback(name, oldValue, newValue) { +attributeChangedCallback(name, oldValue, newValue) { console.debug('???attributeChangedCallback', { name }); console.debug('???attributeChangedCallback', { oldValue }); console.debug('???attributeChangedCallback', { newValue }); @@ -15,6 +10,9 @@ export default class Counter extends HTMLElement { case 'count': this.count = getValue(newValue); break; + case 'highlight': + this.highlight = getValue(newValue); + break; } this.update(name, oldValue, newValue); } @@ -45,35 +43,10 @@ export default class Counter extends HTMLElement { break; } }); - if (['count'].includes(name)) { + if ([ + 'count', + 'highlight' + ].includes(name)) { } console.debug('****************************'); - } - constructor() { - super(); - this.count = 0; - } - increment() { - this.count += 1; - this.render(); - } - decrement() { - this.count -= 1; - this.render(); - } - connectedCallback() { - this.render(); - } - render() { - const {count} = this; - this.innerHTML = `
- -

Counter JSX

- - - You have clicked ${ count } times - - -
`; - } -} \ No newline at end of file + } \ 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/src/counter.jsx b/test/cases/jsx-inferred-observability/src/counter.jsx index b1e964e7..56f71c5c 100644 --- a/test/cases/jsx-inferred-observability/src/counter.jsx +++ b/test/cases/jsx-inferred-observability/src/counter.jsx @@ -4,6 +4,7 @@ export default class Counter extends HTMLElement { constructor() { super(); this.count = 0; + this.highlight = 'red'; } increment() { @@ -21,28 +22,16 @@ export default class Counter extends HTMLElement { } render() { - const { count } = this; + const { count, highlight } = this; return (

Counter JSX

- - - - You have clicked{' '} - - {count} - {' '} - times - - + + + You have clicked {count} times +
); From 98923f70e80939a50e2e88b95e051f7dffc5b976 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Mon, 29 Dec 2025 17:02:53 -0500 Subject: [PATCH 06/12] rebasing and reconciling --- docs/pages/docs.md | 7 +- src/jsx-loader.js | 110 +++++++++++------- .../fixtures/attribute-changed-callback.txt | 5 +- .../src/counter.jsx | 20 +++- .../jsx-shadow-dom/jsx-shadow-dom.spec.js | 2 +- test/cases/jsx-shadow-dom/src/heading.jsx | 4 +- test/cases/jsx/jsx.spec.js | 2 +- .../fixtures/attribute-changed-callback.txt | 2 +- 8 files changed, 97 insertions(+), 55 deletions(-) diff --git a/docs/pages/docs.md b/docs/pages/docs.md index 5807acc4..fa6c3dd7 100644 --- a/docs/pages/docs.md +++ b/docs/pages/docs.md @@ -447,12 +447,15 @@ customElements.define('wcc-counter', Counter); ### (Inferred) Attribute Observability -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 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: + 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; @@ -476,6 +479,8 @@ 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`! ```html diff --git a/src/jsx-loader.js b/src/jsx-loader.js index 53db8bcd..3ee8f652 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,9 +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__.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});"`; + 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();"`; + } } } } @@ -163,7 +168,9 @@ function parseJsxElement(element, moduleContents = '') { } // TODO make sure this only applies to `this` references! - string += ` data-wcc-${expression.name}="${name}" data-wcc-ins="attr"`; + if (inferredObservability) { + string += ` data-wcc-${expression.name}="${name}" data-wcc-ins="attr"`; + } } } else { // xxx > @@ -175,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 += ``; @@ -190,10 +199,12 @@ function parseJsxElement(element, moduleContents = '') { if (type === 'Identifier') { // You have {count} TODOs left to complete - const { name } = element.expression; + 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}="\${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') { @@ -277,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, { @@ -301,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'; @@ -340,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 @@ -381,39 +405,41 @@ export function parseJsx(moduleURL) { } let newModuleContents = generate(tree); - const trackingAttrs = observedAttributes.filter(attr => typeof attr === 'string'); - // TODO ideally derivedAttrs would explicitely reference trackingAttrs + 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 ` + const derivedAttrs = observedAttributes.filter((attr) => typeof attr !== 'string'); + const derivedGetters = derivedAttrs + .map((attr) => { + return ` get_${attr.id.name}(${trackingAttrs.join(',')}) { console.log('@@@@@@@@@@@@@@@@@@@@ updating derivative value for => ${attr.id.name}'); console.log('@@@@@@@@@@@@@@@@@@@@ new derivative value is =>', ${moduleContents.slice(attr.init.start, attr.init.end)}); return ${moduleContents.slice(attr.init.start, attr.init.end)} } `; - }).join('\n'); - const derivedSetters = derivedAttrs.map(attr => { - const name = attr.id.name; + }) + .join('\n'); + const derivedSetters = derivedAttrs + .map((attr) => { + const name = attr.id.name; - return ` + return ` const old_${name} = this.get_${name}(oldValue); const new_${name} = this.get_${name}(newValue); this.update('${name}', old_${name}, new_${name}); `; - }).join('\n'); + }) + .join('\n'); - // TODO better way to determine value type? + // 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 [${[...trackingAttrs].map((attr) => `'${attr}'`).join()}] } attributeChangedCallback(name, oldValue, newValue) { - console.debug('???attributeChangedCallback', { name }); - console.debug('???attributeChangedCallback', { oldValue }); - console.debug('???attributeChangedCallback', { newValue }); function getValue(value) { return value.charAt(0) === '{' || value.charAt(0) === '[' ? JSON.parse(value) @@ -450,7 +476,7 @@ export function parseJsx(moduleURL) { console.debug({ attr }); console.debug({ selector }); - this.querySelectorAll(selector).forEach((el) => { + (this?.shadowRoot || this).querySelectorAll(selector).forEach((el) => { const needle = oldValue || el.getAttribute(attr); console.debug({ el }) console.debug({ needle }); @@ -467,7 +493,7 @@ export function parseJsx(moduleURL) { } }) - if ([${[...trackingAttrs].map(attr => `'${attr}'`).join()}].includes(name)) { + if ([${[...trackingAttrs].map((attr) => `'${attr}'`).join()}].includes(name)) { ${derivedSetters} } console.debug('****************************'); 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 6fa9d6a5..d2da2c0b 100644 --- a/test/cases/jsx-inferred-observability/fixtures/attribute-changed-callback.txt +++ b/test/cases/jsx-inferred-observability/fixtures/attribute-changed-callback.txt @@ -1,7 +1,4 @@ attributeChangedCallback(name, oldValue, newValue) { - console.debug('???attributeChangedCallback', { name }); - console.debug('???attributeChangedCallback', { oldValue }); - console.debug('???attributeChangedCallback', { 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; } @@ -27,7 +24,7 @@ attributeChangedCallback(name, oldValue, newValue) { const selector = `[${ attr }]`; console.debug({ attr }); console.debug({ selector }); - this.querySelectorAll(selector).forEach(el => { + (this?.shadowRoot || this).querySelectorAll(selector).forEach(el => { const needle = oldValue || el.getAttribute(attr); console.debug({ el }); console.debug({ needle }); diff --git a/test/cases/jsx-inferred-observability/src/counter.jsx b/test/cases/jsx-inferred-observability/src/counter.jsx index 56f71c5c..bc2457cb 100644 --- a/test/cases/jsx-inferred-observability/src/counter.jsx +++ b/test/cases/jsx-inferred-observability/src/counter.jsx @@ -28,10 +28,22 @@ export default class Counter extends HTMLElement {

Counter JSX

- - - You have clicked {count} times - + + + + 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 31164474..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 f3006d5c..43bcfcdf 100644 --- a/test/cases/jsx/jsx.spec.js +++ b/test/cases/jsx/jsx.spec.js @@ -76,7 +76,7 @@ describe('Run WCC For ', function () { ); expect(element.getAttribute('onclick')).to.be.equal( - 'this.parentElement.parentElement.count-=1; this.parentElement.parentElement.setAttribute(\'count\', this.parentElement.parentElement.count);' + 'this.parentElement.parentElement.count-=1; this.parentElement.parentElement.render();', ); }); }); 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 From e0f3b02bbe2da8fe5ac702a52c14fbdcd8210d5d Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Mon, 29 Dec 2025 17:04:12 -0500 Subject: [PATCH 07/12] remove comment --- test/cases/jsx/jsx.spec.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/cases/jsx/jsx.spec.js b/test/cases/jsx/jsx.spec.js index 43bcfcdf..df814cd6 100644 --- a/test/cases/jsx/jsx.spec.js +++ b/test/cases/jsx/jsx.spec.js @@ -24,7 +24,6 @@ describe('Run WCC For ', function () { before(async function () { const { html, metadata } = await renderToString(new URL('./src/counter.jsx', import.meta.url)); - console.log({ html }); meta = metadata; dom = new JSDOM(html); }); From 41be983a6cc3cec9fc81a2685a8a5b5646c56748 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Mon, 29 Dec 2025 17:44:27 -0500 Subject: [PATCH 08/12] fix: inferred attributes apply to non-inferred components --- src/jsx-loader.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/jsx-loader.js b/src/jsx-loader.js index 3ee8f652..f416cca5 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -167,7 +167,7 @@ function parseJsxElement(element, moduleContents = '', inferredObservability = f break; } - // TODO make sure this only applies to `this` references! + // only apply this when dealing with `this` references if (inferredObservability) { string += ` data-wcc-${expression.name}="${name}" data-wcc-ins="attr"`; } @@ -389,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' || From a61dbdf2e7ed29d6715275253c1747e9b448fce6 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Wed, 31 Dec 2025 11:01:35 -0500 Subject: [PATCH 09/12] add support for empty string attributes to properly trigger change detection --- src/jsx-loader.js | 3 ++- .../fixtures/attribute-changed-callback.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/jsx-loader.js b/src/jsx-loader.js index f416cca5..582ffa55 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -476,7 +476,8 @@ export function parseJsx(moduleURL) { console.debug({ selector }); (this?.shadowRoot || this).querySelectorAll(selector).forEach((el) => { - const needle = oldValue || el.getAttribute(attr); + // handle empty strings as a value for the purposes of attribute change detection + const needle = oldValue === '' ? '' : oldValue ?? el.getAttribute(attr); console.debug({ el }) console.debug({ needle }); console.debug({ newValue }); 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 d2da2c0b..0e343c61 100644 --- a/test/cases/jsx-inferred-observability/fixtures/attribute-changed-callback.txt +++ b/test/cases/jsx-inferred-observability/fixtures/attribute-changed-callback.txt @@ -25,7 +25,7 @@ attributeChangedCallback(name, oldValue, newValue) { console.debug({ attr }); console.debug({ selector }); (this?.shadowRoot || this).querySelectorAll(selector).forEach(el => { - const needle = oldValue || el.getAttribute(attr); + const needle = oldValue === '' ? '' : oldValue ?? el.getAttribute(attr); console.debug({ el }); console.debug({ needle }); console.debug({ newValue }); From f288a21cc00283cd7ad7ebf7d8b91fdf0b9d6846 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Mon, 12 Jan 2026 21:35:55 -0500 Subject: [PATCH 10/12] feat: #108 add more test cases --- .../jsx-inferred-obsevability.spec.js | 25 ++++++++++++- .../jsx-inferred-observability/src/badge.jsx | 37 +++++++++++++++++++ .../src/counter.jsx | 2 + test/cases/jsx/jsx.spec.js | 3 ++ .../src/counter.tsx | 3 +- .../tsx-inferred-obsevability.spec.js | 18 +++++++-- 6 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 test/cases/jsx-inferred-observability/src/badge.jsx 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 bc2457cb..478ef0e9 100644 --- a/test/cases/jsx-inferred-observability/src/counter.jsx +++ b/test/cases/jsx-inferred-observability/src/counter.jsx @@ -1,3 +1,5 @@ +import './badge.jsx'; + export const inferredObservability = true; export default class Counter extends HTMLElement { 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/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