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 += `${tagName}>`;
@@ -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 (
+
+
+
+ {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 (