Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions docs/pages/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

<!-- prettier-ignore-start -->

```jsx
export const inferredObservability = true;

Expand All @@ -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 (
<div>
<button onclick={(this.count -= 1)}> -</button>
<button onclick={this.count -= 1}> -</button>
<span>
You have clicked <span class="red">{count}</span> times
</span>
Expand All @@ -476,14 +479,15 @@ 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`!
<!-- prettier-ignore-end -->

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
<wcc-counter count="100"></wcc-counter>
```

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.
2 changes: 1 addition & 1 deletion sandbox/components/counter-dsd.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
155 changes: 122 additions & 33 deletions src/jsx-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();"`;
}
}
}
}
Expand Down Expand Up @@ -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 >
Expand All @@ -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}>`;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
});
}
Expand All @@ -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,
{
Expand All @@ -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';

Expand Down Expand Up @@ -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
Expand All @@ -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' ||
Expand All @@ -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) {
Expand All @@ -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)}
`;

Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
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)) {
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
static get observedAttributes() {
return['count'];
return['count', 'highlight'];
}
Loading