Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/issue 87 jsx coarse grained observability #94

Merged
merged 13 commits into from
Jan 4, 2023
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
58 changes: 53 additions & 5 deletions docs/pages/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,8 @@ Even more experimental than WCC is the option to author a rendering function for

### Example

Below is an example of what is possible right now [demonstrated](https://github.com/thescientist13/greenwood-counter-jsx) through a Counter component.
Below is an example of what is possible right now demonstrated through a [Counter component](https://github.com/thescientist13/greenwood-counter-jsx).

```jsx
export default class Counter extends HTMLElement {
constructor() {
Expand All @@ -173,14 +174,19 @@ export default class Counter extends HTMLElement {
this.render();
}

increment() {
this.count += 1;
this.render();
}

render() {
const { count } = this;

return (
<div>
<button onclick={this.count -= 1}> -</button>
<span>You have clicked <span class="red">{count}</span> times</span>
<button onclick={this.count += 1}> +</button>
<button onclick={this.increment}> +</button>
</div>
);
}
Expand All @@ -189,16 +195,58 @@ export default class Counter extends HTMLElement {
customElements.define('wcc-counter', Counter);
```

There is an [active discussion tracking features](https://github.com/ProjectEvergreen/wcc/discussions/84) and [issues in progress](https://github.com/ProjectEvergreen/wcc/issues?q=is%3Aopen+is%3Aissue+label%3AJSX) to continue iterating on this, so please feel free to try it out and give us your feedback!
A couple things to observe in the above example:
- The `this` reference is correctly bound to the `<wcc-counter>` element's state. This works for both `this.count` and the event handler, `this.increment`.
- Event handlers need to manage their own render function updates.
- `this.count` will know it is a member of the `<wcc-counter>`'s state, and so will re-run `this.render` automatically in the compiled output.

> There is an [active discussion tracking features](https://github.com/ProjectEvergreen/wcc/discussions/84) and [issues in progress](https://github.com/ProjectEvergreen/wcc/issues?q=is%3Aopen+is%3Aissue+label%3AJSX) to continue iterating on this, so please feel free to try it out and give us your feedback!

### Prerequisites

There are of couple things you will need to do to use WCC with JSX:
1. NodeJS version needs to be `16.x`
1. NodeJS version needs to be >= `16.x`
1. You will need to use the _.jsx_ extension
1. Requires the `--experimental-loaders` flag when invoking NodeJS
```js
$ node --experimental-loader ./node_modules/wc-compiler/src/jsx-loader.js server.js
```

> _See our [example's page](/examples#jsx) for some usages of WCC + JSX._ 👀
> _See our [example's page](/examples#jsx) for some usages of WCC + JSX._ 👀

### (Inferred) Attribute Observability

An optional feature supported by JSX based compilation is a feature called `inferredObservability`. With this enabled, WCC will read any `this` member references in your component's `render` function and map each member instance to
- an entry in the `observedAttributes` array
- automatically handle `attributeChangedCallback` update (by calling `this.render()`)

So taking the above counter example, and opting in to this feature, we just need to enable the `inferredObservability` option in the component
```jsx
export const inferredObservability = true;

export default class Counter extends HTMLElement {
...

render() {
const { count } = this;

return (
<div>
<button onclick={this.count -= 1}> -</button>
<span>You have clicked <span class="red">{count}</span> times</span>
<button onclick={this.increment}> +</button>
</div>
);
}
}
```

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

Some notes / limitations:
- Please be aware of the above linked discussion which is tracking known bugs / feature requests to all things WCC + JSX.
- We consider the capability of this observability to be "coarse grained" at this time since WCC just re-runs the entire `render` function, replacing of the `innerHTML` for the host component. Thought it is still WIP, we are exploring a more ["fine grained" 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.
7 changes: 6 additions & 1 deletion docs/pages/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,4 +259,9 @@ export async function handler() {

## JSX

A current example of a Todo App can be seen in [this repo](https://github.com/thescientist13/todo-app), which is a fork of [this Greenwood based Todo App which uses LitElement](https://github.com/ProjectEvergreen/todo-app). It can compile JSX for _**the client or the server**_ using [Greenwood](https://www.greenwoodjs.io/), and can even be used with great testing tools like [**@web/test-runner**](https://modern-web.dev/docs/test-runner/overview/)! 💪
A couple examples of using WCC + JSX are available for reference and reproduction:

* [Counter](https://github.com/thescientist13/greenwood-counter-jsx)
* [Todo App](https://github.com/thescientist13/todo-app)

Both of these examples can compile JSX for _**the client or the server**_ using [Greenwood](https://www.greenwoodjs.io/), and can even be used with great testing tools like [**@web/test-runner**](https://modern-web.dev/docs/test-runner/overview/)! 💪
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"build": "node ./build.js",
"serve": "node ./build.js && http-server ./dist --open",
"start": "npm run develop",
"test": "mocha --exclude \"./test/cases/jsx/**\" --exclude \"./test/cases/custom-extension/**\" \"./test/**/**/*.spec.js\"",
"test": "mocha --exclude \"./test/cases/jsx*/**\" --exclude \"./test/cases/custom-extension/**\" \"./test/**/**/*.spec.js\"",
"test:exp": "c8 node --experimental-loader ./test-exp-loader.js ./node_modules/mocha/bin/mocha \"./test/**/**/*.spec.js\"",
"test:tdd": "npm run test -- --watch",
"test:tdd:exp": "npm run test:exp -- --watch",
Expand Down
159 changes: 138 additions & 21 deletions src/jsx-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,51 +197,168 @@ function parseJsxElement(element, moduleContents = '') {
return string;
}

// TODO handle if / else statements
// https://github.com/ProjectEvergreen/wcc/issues/88
function findThisReferences(context, statement) {
const references = [];
const isRenderFunctionContext = context === 'render';
const { expression, type } = statement;
const isConstructorThisAssignment = context === 'constructor'
&& type === 'ExpressionStatement'
&& expression.type === 'AssignmentExpression'
&& expression.left.object.type === 'ThisExpression';

if (isConstructorThisAssignment) {
// this.name = 'something'; // constructor
references.push(expression.left.property.name);
} else if (isRenderFunctionContext && type === 'VariableDeclaration') {
statement.declarations.forEach(declaration => {
const { init, id } = declaration;

if (init.object && init.object.type === 'ThisExpression') {
// const { description } = this.todo;
references.push(init.property.name);
} else if (init.type === 'ThisExpression' && id && id.properties) {
// const { description } = this.todo;
id.properties.forEach((property) => {
references.push(property.key.name);
});
}
});
}

return references;
}

export function parseJsx(moduleURL) {
const moduleContents = fs.readFileSync(moduleURL, 'utf-8');
string = '';

const tree = acorn.Parser.extend(jsx()).parse(moduleContents, {
// would be nice if we could do this instead, so we could know ahead of time
// const { inferredObservability } = await import(moduleURL);
// however, this requires making parseJsx async, but WCC acorn walking is done sync
const hasOwnObservedAttributes = undefined;
let inferredObservability = false;
let observedAttributes = [];
let tree = acorn.Parser.extend(jsx()).parse(moduleContents, {
ecmaVersion: 'latest',
sourceType: 'module'
});
string = '';

walk.simple(tree, {
ClassDeclaration(node) {
if (node.superClass.name === 'HTMLElement') {
const hasShadowRoot = moduleContents.slice(node.body.start, node.body.end).indexOf('this.attachShadow(') > 0;

for (const n1 of node.body.body) {
if (n1.type === 'MethodDefinition' && n1.key.name === 'render') {
for (const n2 in n1.value.body.body) {
const n = n1.value.body.body[n2];

if (n.type === 'ReturnStatement' && n.argument.type === 'JSXElement') {
const html = parseJsxElement(n.argument, moduleContents);
const elementTree = getParse(html)(html);
const elementRoot = hasShadowRoot ? 'this.shadowRoot' : 'this';

applyDomDepthSubstitutions(elementTree, undefined, hasShadowRoot);

const finalHtml = serialize(elementTree);
const transformed = acorn.parse(`${elementRoot}.innerHTML = \`${finalHtml}\`;`, {
ecmaVersion: 'latest',
sourceType: 'module'
});

n1.value.body.body[n2] = transformed;
if (n1.type === 'MethodDefinition') {
const nodeName = n1.key.name;
if (nodeName === 'render') {
for (const n2 in n1.value.body.body) {
const n = n1.value.body.body[n2];

if (n.type === 'VariableDeclaration') {
observedAttributes = [
...observedAttributes,
...findThisReferences('render', n)
];
} else if (n.type === 'ReturnStatement' && n.argument.type === 'JSXElement') {
const html = parseJsxElement(n.argument, moduleContents);
const elementTree = getParse(html)(html);
const elementRoot = hasShadowRoot ? 'this.shadowRoot' : 'this';

applyDomDepthSubstitutions(elementTree, undefined, hasShadowRoot);

const finalHtml = serialize(elementTree);
const transformed = acorn.parse(`${elementRoot}.innerHTML = \`${finalHtml}\`;`, {
ecmaVersion: 'latest',
sourceType: 'module'
});

n1.value.body.body[n2] = transformed;
}
}
}
}
}
}
},
ExportNamedDeclaration(node) {
const { declaration } = node;

if (declaration && declaration.type === 'VariableDeclaration' && declaration.kind === 'const' && declaration.declarations.length === 1) {
if (declaration.declarations[0].id.name === 'inferredObservability') {
inferredObservability = Boolean(node.declaration.declarations[0].init.raw);
}
}
}
}, {
// https://github.com/acornjs/acorn/issues/829#issuecomment-1172586171
...walk.base,
JSXElement: () => {}
});

// 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
if (line.type === 'ClassDeclaration' || (line.declaration && line.declaration.type) === 'ClassDeclaration') {
const children = !line.declaration
? line.body.body
: line.declaration.body.body;
for (const method of children) {
if (method.key.name === 'constructor') {
insertPoint = method.start - 1;
break;
}
}
}
}

let newModuleContents = escodegen.generate(tree);

// TODO better way to determine value type?
/* eslint-disable indent */
newModuleContents = `${newModuleContents.slice(0, insertPoint)}
static get observedAttributes() {
return [${[...observedAttributes].map(attr => `'${attr}'`).join(',')}]
}

attributeChangedCallback(name, oldValue, newValue) {
function getValue(value) {
return value.charAt(0) === '{' || value.charAt(0) === '['
? JSON.parse(value)
: !isNaN(value)
? parseInt(value, 10)
: value === 'true' || value === 'false'
? value === 'true' ? true : false
: value;
}
if (newValue !== oldValue) {
switch(name) {
${observedAttributes.map((attr) => {
return `
case '${attr}':
this.${attr} = getValue(newValue);
break;
`;
}).join('\n')}
}

this.render();
}
}

${newModuleContents.slice(insertPoint)}
`;
/* eslint-enable indent */

tree = acorn.Parser.extend(jsx()).parse(newModuleContents, {
ecmaVersion: 'latest',
sourceType: 'module'
});
}

return tree;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
attributeChangedCallback(name, oldValue, newValue) {
function getValue(value) {
return value.charAt(0) === '{' || value.charAt(0) === '[' ? JSON.parse(value) : !isNaN(value) ? parseInt(value, 10) : value === 'true' || value === 'false' ? value === 'true' ? true : false : value;
}
if (newValue !== oldValue) {
switch (name) {
case 'count':
this.count = getValue(newValue);
break;
}
this.render();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
static get observedAttributes() {
return['count'];
}
52 changes: 52 additions & 0 deletions test/cases/jsx-coarse-grained/jsx-coarse-grained.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Use Case
* Run wcc against a custom element using JSX render function with inferredObservability enabled
*
* User Result
* Should return the expected JavaScript output.
*
* User Workspace
* src/
* counter.jsx
*/
import chai from 'chai';
import fs from 'fs/promises';
import { renderToString } from '../../../src/wcc.js';

const expect = chai.expect;

describe('Run WCC For ', function() {
const LABEL = 'Single Custom Element using JSX';
let fixtureAttributeChangedCallback;
let fixtureGetObservedAttributes;
let meta;

before(async function() {
const { metadata } = await renderToString(new URL('./src/counter.jsx', import.meta.url));

meta = metadata;

fixtureAttributeChangedCallback = await fs.readFile(new URL('./fixtures/attribute-changed-callback.txt', import.meta.url), 'utf-8');
fixtureGetObservedAttributes = await fs.readFile(new URL('./fixtures/get-observed-attributes.txt', import.meta.url), 'utf-8');
});

describe(LABEL, function() {

describe('<Counter> component w/ <Badge> and Inferred Observability', function() {

it('should infer observability by generating a get observedAttributes method', () => {
const actual = meta['wcc-counter-jsx'].source.replace(/ /g, '').replace(/\n/g, '');
const expected = fixtureGetObservedAttributes.replace(/ /g, '').replace(/\n/g, '');

expect(actual).to.contain(expected);
});

it('should infer observability by generating an attributeChangedCallback method', () => {
const actual = meta['wcc-counter-jsx'].source.replace(/ /g, '').replace(/\n/g, '');
const expected = fixtureAttributeChangedCallback.replace(/ /g, '').replace(/\n/g, '');

expect(actual).to.contain(expected);
});
});
});
});
Loading