Skip to content

Commit

Permalink
bug/issue 128 fix shadow root rendering for JSX (#129)
Browse files Browse the repository at this point in the history
* fix shadow root rendering for JSX

* output JSX DSD as a template element

* JSX + DSD sandbox demo working

* JSX + DSD + inferredObservability sandbox demo working

* add random reset feature to counter sandbox demos

* rename test to match feature being tested

* refine test cases

* constructor not a prerequiste for using inferredObservability anymore

* runtime render refactoring

* document DSD interop

* misc clean and docs
  • Loading branch information
thescientist13 authored Jan 6, 2024
1 parent 05401bd commit 6abeca4
Show file tree
Hide file tree
Showing 13 changed files with 190 additions and 43 deletions.
32 changes: 29 additions & 3 deletions docs/pages/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export default class Counter extends HTMLElement {
}
connectedCallback() {
this.render();
this.render(); // this is required
}
increment() {
Expand Down Expand Up @@ -289,6 +289,32 @@ There are of couple things you will need to do to use WCC with JSX:

> _See our [example's page](/examples#jsx) for some usages of WCC + JSX._ 👀
### Declarative Shadow DOM
To opt-in to Declarative Shadow DOM with JSX, you will need to signal to the WCC compiler your intentions so it can accurately mount from a `shadowRoot` on the client side. To opt-in, simply make a call to `attachShadow` in your `connectedCallback` method.
Using, the Counter example from above, we would amend it like so:
```js
export default class Counter extends HTMLElement {
constructor() {
super();
this.count = 0;
}
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' }); // this is required for DSD support
this.render();
}
}
// ...
}
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
Expand Down Expand Up @@ -322,6 +348,6 @@ 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 to all things WCC + JSX.
- 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.
2 changes: 1 addition & 1 deletion sandbox/components/card.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export default class Card extends HTMLElement {

selectItem() {
alert(`selected item is => ${this.getAttribute('title')}!`);
alert(`selected item is => ${this.title}!`);
}

connectedCallback() {
Expand Down
6 changes: 3 additions & 3 deletions sandbox/components/card.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ const styles = `
export default class CardJsx extends HTMLElement {

selectItem() {
alert(`selected item is => ${this.getAttribute('title')}!`);
alert(`selected item is => ${this.title}!`);
}

connectedCallback() {
if (!this.shadowRoot) {
console.log('NO shadowRoot detected for card.jsx!');
console.warn('NO shadowRoot detected for card.jsx!');
this.thumbnail = this.getAttribute('thumbnail');
this.title = this.getAttribute('title');

Expand All @@ -39,7 +39,7 @@ export default class CardJsx extends HTMLElement {
const { thumbnail, title } = this;

return (
<div>
<div class="card">
<style>
{styles}
</style>
Expand Down
10 changes: 2 additions & 8 deletions sandbox/components/counter-dsd.jsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
export const inferredObservability = true;

export default class CounterDsdJsx extends HTMLElement {
// having a constructor is required for inferredObservability
constructor() {
super();
this.count = 0;
}

connectedCallback() {
if (!this.shadowRoot) {
console.log('NO shadowRoot detected for counter-dsd.jsx!');
this.count = this.getAttribute('count');
console.warn('NO shadowRoot detected for counter-dsd.jsx!');
this.count = this.getAttribute('count') || 0;

// having an attachShadow call is required for DSD
this.attachShadow({ mode: 'open' });
Expand Down
2 changes: 1 addition & 1 deletion sandbox/components/counter.jsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
export const inferredObservability = true;

export default class CounterJsx extends HTMLElement {
// having a constructor is required for inferredObservability
constructor() {
super();
this.count = 0;
}

connectedCallback() {
this.count = parseInt(this.getAttribute('count'), 10) || this.count;
this.render();
}

Expand Down
49 changes: 34 additions & 15 deletions sandbox/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,33 @@
margin: 0 auto;
text-align: center;
}

button.reset {
display: block;
min-width: 5%;
margin: 0 auto;
}
</style>

<script>
function randomReset() {
return Math.floor(Math.random() * 100);
}

globalThis.document.addEventListener('DOMContentLoaded', () => {
const counterJsxResetButton = document.getElementById('counter-jsx-reset');
const counterJsxDsdResetButton = document.getElementById('counter-jsx-dsd-reset');

counterJsxResetButton.addEventListener('click', () => {
document.querySelector('sb-counter-jsx').setAttribute('count', randomReset());
});

counterJsxDsdResetButton.addEventListener('click', () => {
document.querySelector('sb-counter-dsd-jsx').setAttribute('count', randomReset());
});
});
</script>

<script>
document.write('<script src="http://' + (location.host || 'localhost').split(':')[0] +
':35729/livereload.js?snipver=1"></' + 'script>')
Expand Down Expand Up @@ -68,21 +93,16 @@ <h2>JSX + Light DOM (no JS)</h2>

<hr/>

<!-- TODO - https://github.com/ProjectEvergreen/wcc/issues/128 -->
<h2>JSX + Declarative Shadow DOM (has JS) 🚫</h2>

<details>
SSR Shadow DOM (and thus host styles) not working, see - https://github.com/ProjectEvergreen/wcc/issues/128
</details>
<h2>JSX + Declarative Shadow DOM (has JS)</h2>

<sb-card-jsx
title="iPhone 9"
thumbnail="https://i.dummyjson.com/data/products/1/thumbnail.jpg"
title="iPhone X"
thumbnail="https://i.dummyjson.com/data/products/2/thumbnail.jpg"
></sb-card-jsx>

<pre>
&lt;sb-card-jsx
title="iPhone 9" thumbnail="https://i.dummyjson.com/data/products/1/thumbnail.jpg"
title="iPhone X" thumbnail="https://i.dummyjson.com/data/products/2/thumbnail.jpg"
&gt;&lt;/sb-card-jsx&gt;
</pre>

Expand All @@ -94,6 +114,8 @@ <h2>JSX + Light DOM + inferredObservability (has JS)</h2>
count="5"
></sb-counter-jsx>

<button class="reset" id="counter-jsx-reset">Random Reset</button>

<pre>
&lt;sb-counter-jsx
count="5"
Expand All @@ -102,17 +124,14 @@ <h2>JSX + Light DOM + inferredObservability (has JS)</h2>

<hr/>

<!-- TODO - https://github.com/ProjectEvergreen/wcc/issues/128 -->
<h2>JSX + DSD + inferredObservability (has JS) 🚫</h2>

<details>
SSR Shadow DOM and inferredObservability not working, see - https://github.com/ProjectEvergreen/wcc/issues/128
</details>
<h2>JSX + DSD + inferredObservability (has JS)</h2>

<sb-counter-dsd-jsx
count="3"
></sb-counter-dsd-jsx>

<button class="reset" id="counter-jsx-dsd-reset">Random Reset</button>

<pre>
&lt;sb-counter-dsd-jsx
count="3"
Expand Down
32 changes: 21 additions & 11 deletions src/jsx-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,26 @@ export function parseJsx(moduleURL) {

applyDomDepthSubstitutions(elementTree, undefined, hasShadowRoot);

const finalHtml = serialize(elementTree);
const transformed = acorn.parse(`${elementRoot}.innerHTML = \`${finalHtml}\`;`, {
const serializedHtml = serialize(elementTree);
// we have to Shadow DOM use cases here
// 1. No shadowRoot, so we attachShadow and append the template
// 2. If there is root from the attachShadow signal, so we just need to inject innerHTML, say in an htmx
// could / should we do something else instead of .innerHTML
// https://github.com/ProjectEvergreen/wcc/issues/138
const renderHandler = hasShadowRoot
? `
const template = document.createElement('template');
template.innerHTML = \`${serializedHtml}\`;
if(!${elementRoot}) {
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
} else {
this.shadowRoot.innerHTML = template.innerHTML;
}
`
: `${elementRoot}.innerHTML = \`${serializedHtml}\`;`;
const transformed = acorn.parse(renderHandler, {
ecmaVersion: 'latest',
sourceType: 'module'
});
Expand Down Expand Up @@ -300,15 +318,7 @@ export function parseJsx(moduleURL) {
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;
}
}
insertPoint = line.declaration.body.start + 1;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { renderToString } from '../../../src/wcc.js';
const expect = chai.expect;

describe('Run WCC For ', function() {
const LABEL = 'Single Custom Element using JSX';
const LABEL = 'Single Custom Element using JSX and Inferred Observability';
let fixtureAttributeChangedCallback;
let fixtureGetObservedAttributes;
let meta;
Expand Down
71 changes: 71 additions & 0 deletions test/cases/jsx-shadow-dom/jsx-shadow-dom.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Use Case
* Run wcc against a nested custom elements using JSX render function and Declarative Shadow DOM.
*
* User Result
* Should return the expected HTML and JavaScript output.
*
* User Workspace
* src/
* heading.jsx
*/
import chai from 'chai';
import { JSDOM } from 'jsdom';
import { renderToString } from '../../../src/wcc.js';

const expect = chai.expect;

describe('Run WCC For ', function() {
const LABEL = 'Single Custom Element using JSX and Declarative Shadow DOM';
let dom;
let meta;

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

meta = metadata;
dom = new JSDOM(html);
});

describe(LABEL, function() {

describe('<wcc-heading> component', function() {
let heading;

before(async function() {
heading = dom.window.document.querySelector('wcc-heading template[shadowrootmode="open"]');
});

describe('Metadata', () => {
it('should return a JSX definition in metadata', () => {
expect(Object.keys(meta).length).to.equal(1);
expect(meta['wcc-heading'].source).to.not.be.undefined;
});
});

describe('Declarative Shadow DOM (<template> tag)', () => {
it('should handle a this expression', () => {
expect(heading).to.not.be.undefined;
});
});

describe('Event Handling', () => {
it('should handle a this expression', () => {
const wrapper = new JSDOM(heading.innerHTML);
const button = wrapper.window.document.querySelector('button');

expect(button.getAttribute('onclick')).to.be.equal('this.parentElement.parentNode.host.sayHello()');
});
});

describe('Attribute Contents', () => {
it('should handle a this expression', () => {
const wrapper = new JSDOM(heading.innerHTML);
const header = wrapper.window.document.querySelector('h1');

expect(header.textContent).to.be.equal('Hello, World!');
});
});
});
});
});
27 changes: 27 additions & 0 deletions test/cases/jsx-shadow-dom/src/heading.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export default class HeadingComponent extends HTMLElement {
sayHello() {
alert(`Hello, ${this.greeting}!`);
}

connectedCallback() {
if (!this.shadowRoot) {
this.greeting = this.getAttribute('greeting') || 'World';

this.attachShadow({ mode: 'open' });
this.render();
}
}

render() {
const { greeting } = this;

return (
<div>
<h1>Hello, {greeting}!</h1>
<button onclick={this.sayHello}>Get a greeting!</button>
</div>
);
}
}

customElements.define('wcc-heading', HeadingComponent);

0 comments on commit 6abeca4

Please sign in to comment.