From 78dfa5bc673269550aa4e44c5c92f93866a7adab Mon Sep 17 00:00:00 2001 From: Jonathan Stockdill Date: Sat, 18 Jul 2026 13:42:48 +0000 Subject: [PATCH 1/8] fix: #282 serialize only the body fragment for document-rooted JSX render output --- src/jsx-loader.js | 17 +++++- .../cases/jsx-body-root/jsx-body-root.spec.js | 52 +++++++++++++++++++ test/cases/jsx-body-root/src/page.jsx | 15 ++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 test/cases/jsx-body-root/jsx-body-root.spec.js create mode 100644 test/cases/jsx-body-root/src/page.jsx diff --git a/src/jsx-loader.js b/src/jsx-loader.js index cbf5f8c..0c167fd 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -712,12 +712,25 @@ export function parseJsx(moduleURL) { hasShadowRoot, 1, ); - const elementTree = getParse(html)(html); + const parser = getParse(html); + const elementTree = parser(html); const elementRoot = hasShadowRoot ? 'this.shadowRoot' : 'this'; applyDomDepthSubstitutions(elementTree, undefined, hasShadowRoot); - const serializedHtml = serialize(elementTree); + // when the render output contains document-level tags (//), + // parse5's full-document `parse` wraps it in a complete + // ... document; serialize only the + // fragment so a -rooted "page" component contributes just its children, + // instead of a nested document inlined into the outer page. + // https://github.com/ProjectEvergreen/wcc/issues/282 + const bodyFragment = + parser === parse + ? elementTree.childNodes + .find((node) => node.tagName === 'html') + ?.childNodes.find((node) => node.tagName === 'body') + : null; + const serializedHtml = serialize(bodyFragment ?? 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 diff --git a/test/cases/jsx-body-root/jsx-body-root.spec.js b/test/cases/jsx-body-root/jsx-body-root.spec.js new file mode 100644 index 0000000..81811df --- /dev/null +++ b/test/cases/jsx-body-root/jsx-body-root.spec.js @@ -0,0 +1,52 @@ +/* + * Use Case + * Run wcc against a JSX component whose render function returns a document-level + * `` root (natural for a "page" component). + * + * User Result + * Should serialize only the body fragment's children, NOT a nested full + * `...` document inlined into the outer page. + * + * User Workspace + * src/ + * page.jsx + */ +import { expect } from 'chai'; +import { JSDOM } from 'jsdom'; +import { renderToString } from '../../../src/wcc.js'; + +describe('Run WCC For ', function () { + const LABEL = 'JSX Component with a document root'; + let dom; + let output; + + before(async function () { + const { html } = await renderToString(new URL('./src/page.jsx', import.meta.url)); + + output = html; + dom = new JSDOM(html); + }); + + describe(LABEL, function () { + describe(' component', function () { + it('should render the intended content', () => { + const heading = dom.window.document.querySelector('wcc-page h1'); + + expect(heading).to.not.be.null; + expect(heading.textContent.trim()).to.be.equal('Hello from JSX page'); + }); + + it('should NOT emit a nested document wrapper', () => { + expect(output).to.not.contain(''); + }); + + it('should NOT emit a nested document wrapper', () => { + expect(output).to.not.contain(''); + }); + + it('should NOT emit a nested document wrapper', () => { + expect(output).to.not.contain(''); + }); + }); + }); +}); diff --git a/test/cases/jsx-body-root/src/page.jsx b/test/cases/jsx-body-root/src/page.jsx new file mode 100644 index 0000000..2db2980 --- /dev/null +++ b/test/cases/jsx-body-root/src/page.jsx @@ -0,0 +1,15 @@ +export default class Page extends HTMLElement { + connectedCallback() { + this.render(); + } + + render() { + return ( + +

Hello from JSX page

+ + ); + } +} + +customElements.define('wcc-page', Page); From 52987192ba608b5a0189d15c61188b1d64e97760 Mon Sep 17 00:00:00 2001 From: Jonathan Stockdill Date: Sat, 18 Jul 2026 13:43:34 +0000 Subject: [PATCH 2/8] fix: #282 guard null superClass for non-extending class in jsx-loader --- src/jsx-loader.js | 8 ++- .../jsx-plain-class/jsx-plain-class.spec.js | 52 +++++++++++++++++++ test/cases/jsx-plain-class/src/badge.jsx | 25 +++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 test/cases/jsx-plain-class/jsx-plain-class.spec.js create mode 100644 test/cases/jsx-plain-class/src/badge.jsx diff --git a/src/jsx-loader.js b/src/jsx-loader.js index 0c167fd..c841d6e 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -427,8 +427,10 @@ export function parseJsx(moduleURL) { } }, ClassDeclaration(node) { + // a class without `extends` has a null superClass (e.g. a plain helper class) — guard it + // https://github.com/ProjectEvergreen/wcc/issues/282 // @ts-ignore - if (node.superClass.name === 'HTMLElement') { + if (node.superClass?.name === 'HTMLElement') { // TODO: (good first issue) find a more AST (visitor) based way to check for this // https://github.com/ProjectEvergreen/wcc/issues/258 hasShadowRoot = @@ -694,8 +696,10 @@ export function parseJsx(moduleURL) { tree, { ClassDeclaration(node) { + // a class without `extends` has a null superClass (e.g. a plain helper class) — guard it + // https://github.com/ProjectEvergreen/wcc/issues/282 // @ts-ignore - if (node.superClass.name === 'HTMLElement') { + if (node.superClass?.name === 'HTMLElement') { for (const n1 of node.body.body) { if (n1.type === 'MethodDefinition') { // @ts-ignore diff --git a/test/cases/jsx-plain-class/jsx-plain-class.spec.js b/test/cases/jsx-plain-class/jsx-plain-class.spec.js new file mode 100644 index 0000000..f95e365 --- /dev/null +++ b/test/cases/jsx-plain-class/jsx-plain-class.spec.js @@ -0,0 +1,52 @@ +/* + * Use Case + * Run wcc against a JSX component file that also declares a plain helper class with no `extends` + * (e.g. `class Formatter { ... }`) alongside the custom element. + * + * User Result + * Compilation should succeed (the non-extending helper class must not crash the compiler) and the + * custom element should still render. + * + * User Workspace + * src/ + * badge.jsx + */ +import { expect } from 'chai'; +import { JSDOM } from 'jsdom'; +import { renderToString } from '../../../src/wcc.js'; + +describe('Run WCC For ', function () { + const LABEL = 'JSX Component File Containing A Plain Class Without Extends'; + let dom; + let html; + + before(async function () { + const result = await renderToString(new URL('./src/badge.jsx', import.meta.url)); + + html = result.html; + dom = new JSDOM(html); + }); + + describe(LABEL, function () { + describe(' component', function () { + it('should compile without throwing and produce output', function () { + expect(html).to.not.be.undefined; + expect(html.length).to.be.greaterThan(0); + }); + + it('should still render the custom element', function () { + const badge = dom.window.document.querySelector('wcc-badge'); + + expect(badge).to.not.be.null; + }); + + it('should render the element contents from the render function', function () { + const badge = dom.window.document.querySelector('wcc-badge'); + const span = new JSDOM(badge.innerHTML).window.document.querySelector('span.badge'); + + expect(span).to.not.be.null; + expect(span.textContent.trim()).to.be.equal('42'); + }); + }); + }); +}); diff --git a/test/cases/jsx-plain-class/src/badge.jsx b/test/cases/jsx-plain-class/src/badge.jsx new file mode 100644 index 0000000..9275ab7 --- /dev/null +++ b/test/cases/jsx-plain-class/src/badge.jsx @@ -0,0 +1,25 @@ +// a plain helper class (no `extends`), common in real code +class Formatter { + static label(value) { + return `#${value}`; + } +} + +export default class Badge extends HTMLElement { + constructor() { + super(); + this.value = 42; + } + + connectedCallback() { + this.render(); + } + + render() { + const { value } = this; + + return {value}; + } +} + +customElements.define('wcc-badge', Badge); From 9c39efe1964d7a3632ed278a20135cfa791c0ebf Mon Sep 17 00:00:00 2001 From: Jonathan Stockdill Date: Sat, 18 Jul 2026 13:43:34 +0000 Subject: [PATCH 3/8] fix: #282 escape inline arrow handler strings and rewrite `this` via the ast --- .prettierignore | 1 + src/jsx-loader.js | 24 +++++- .../jsx-handler-string.spec.js | 75 +++++++++++++++++++ test/cases/jsx-handler-string/src/alerter.jsx | 24 ++++++ 4 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 test/cases/jsx-handler-string/jsx-handler-string.spec.js create mode 100644 test/cases/jsx-handler-string/src/alerter.jsx diff --git a/.prettierignore b/.prettierignore index 0b27943..4700191 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ test/cases/tsx/src/counter.tsx +test/cases/jsx-handler-string/src/alerter.jsx diff --git a/src/jsx-loader.js b/src/jsx-loader.js index c841d6e..44dd077 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -167,14 +167,32 @@ function parseJsxElement( // if (expression.type === 'ArrowFunctionExpression' && expression.body) { - // quick hack to get expression contents until we can properly build this all up from an AST - const contents = generate(expression.body); + // rewrite `this` -> `self` on the AST so only real ThisExpressions are scoped + // to the custom element, leaving `this` inside string literals untouched + // https://github.com/ProjectEvergreen/wcc/issues/282 + walk.simple( + expression.body, + { + ThisExpression(node) { + // @ts-ignore + node.type = 'Identifier'; + // @ts-ignore + node.name = 'self'; + }, + }, + walk.base, + ); + // escape `"` so a double-quoted string in the handler can't terminate the attribute + // https://github.com/ProjectEvergreen/wcc/issues/282 + const contents = generate(expression.body) + .replace('() => ', '') + .replace(/"/g, '"'); const eventIdentifier = expression?.params[0]?.name || 'event'; const root = hasShadowRoot ? '.getRootNode().host' : `${'.parentElement'.repeat(currentDepth)}`; - string += ` ${name}="(function (${eventIdentifier}, self) { ${contents.replace(/this./g, 'self.').replace('() => ', '')} })(event, this${root})"`; + string += ` ${name}="(function (${eventIdentifier}, self) { ${contents} })(event, this${root})"`; } if (expression.type === 'AssignmentExpression') { diff --git a/test/cases/jsx-handler-string/jsx-handler-string.spec.js b/test/cases/jsx-handler-string/jsx-handler-string.spec.js new file mode 100644 index 0000000..17ca6e7 --- /dev/null +++ b/test/cases/jsx-handler-string/jsx-handler-string.spec.js @@ -0,0 +1,75 @@ +/* + * Use Case + * Run wcc against a JSX component whose inline arrow event handlers contain + * double-quoted string literals (including strings that mention the word "this"). + * + * User Result + * Should serialize each handler into a single, well-formed `onclick` attribute: + * the double quotes inside the handler must be escaped so they can't terminate the + * attribute (no stray boolean attributes), and `this` must only be rewritten to the + * component scope for real `ThisExpression`s, never inside string literals. + * + * User Workspace + * src/ + * alerter.jsx + */ +import { expect } from 'chai'; +import { JSDOM } from 'jsdom'; +import { renderToString } from '../../../src/wcc.js'; + +describe('Run WCC For ', function () { + const LABEL = 'JSX inline arrow event handler containing a double-quoted string'; + let dom; + + before(async function () { + const { html } = await renderToString(new URL('./src/alerter.jsx', import.meta.url)); + + dom = new JSDOM(html); + }); + + describe(LABEL, function () { + // + + + ); + } +} + +customElements.define('wcc-alerter', Alerter); From d1bf2f471c4a4afd65ee957f6f295e70aac14bd4 Mon Sep 17 00:00:00 2001 From: Jonathan Stockdill Date: Sat, 18 Jul 2026 13:43:45 +0000 Subject: [PATCH 4/8] fix: #282 don't inject duplicate observedAttributes/attributeChangedCallback --- src/jsx-loader.js | 15 +++++- .../jsx-own-observed-attributes.spec.js | 47 +++++++++++++++++++ .../src/own-observed.jsx | 32 +++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 test/cases/jsx-own-observed-attributes/jsx-own-observed-attributes.spec.js create mode 100644 test/cases/jsx-own-observed-attributes/src/own-observed.jsx diff --git a/src/jsx-loader.js b/src/jsx-loader.js index 44dd077..ce8bc64 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -395,7 +395,10 @@ export function parseJsx(moduleURL) { // 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; + // track whether the component brings its own `observedAttributes` / `attributeChangedCallback` so + // the guards below don't inject a duplicate second copy (which would silently win over the user's) + // https://github.com/ProjectEvergreen/wcc/issues/282 + let hasOwnObservedAttributes = false; let inferredObservability = false; // TODO: "merge" observedAttributes tracking with constructor tracking let observedAttributes = []; @@ -479,6 +482,16 @@ export function parseJsx(moduleURL) { hasDisconnectedCallback = true; } + // the component already handles attributes itself, so honor its own definitions instead of + // injecting a duplicate `observedAttributes` / `attributeChangedCallback` + // https://github.com/ProjectEvergreen/wcc/issues/282 + if ( + (node.static && node.kind === 'get' && node?.key.name === 'observedAttributes') || + (node.kind === 'method' && node?.key.name === 'attributeChangedCallback') + ) { + hasOwnObservedAttributes = true; + } + // @ts-ignore if ( node.kind === 'constructor' && diff --git a/test/cases/jsx-own-observed-attributes/jsx-own-observed-attributes.spec.js b/test/cases/jsx-own-observed-attributes/jsx-own-observed-attributes.spec.js new file mode 100644 index 0000000..9288424 --- /dev/null +++ b/test/cases/jsx-own-observed-attributes/jsx-own-observed-attributes.spec.js @@ -0,0 +1,47 @@ +/* + * Use Case + * Run wcc against a custom element using a JSX render function with inferredObservability enabled + * that ALSO defines its own `static get observedAttributes()` and `attributeChangedCallback`. + * + * User Result + * Should return JavaScript output that keeps the component's own `observedAttributes` / + * `attributeChangedCallback` and does NOT inject a duplicate second copy of either. + * + * User Workspace + * src/ + * own-observed.jsx + */ +import { expect } from 'chai'; +import { renderToString } from '../../../src/wcc.js'; + +function countOccurrences(source, needle) { + return source.split(needle).length - 1; +} + +describe('Run WCC For ', function () { + const LABEL = 'Custom Element w/ JSX, Inferred Observability, and its own observedAttributes'; + + describe(LABEL, function () { + let source; + + before(async function () { + const { metadata } = await renderToString(new URL('./src/own-observed.jsx', import.meta.url)); + + source = metadata['wcc-own-observed'].source; + }); + + it('should not inject a duplicate observedAttributes when the component defines its own', () => { + expect(countOccurrences(source, 'observedAttributes')).to.equal(1); + }); + + it('should not inject a duplicate attributeChangedCallback when the component defines its own', () => { + expect(countOccurrences(source, 'attributeChangedCallback')).to.equal(1); + }); + + it("should preserve the component's own observedAttributes list", () => { + const normalized = source.replace(/\s/g, ''); + + expect(normalized).to.contain("return['label']"); + }); + }); +}); diff --git a/test/cases/jsx-own-observed-attributes/src/own-observed.jsx b/test/cases/jsx-own-observed-attributes/src/own-observed.jsx new file mode 100644 index 0000000..dea81f9 --- /dev/null +++ b/test/cases/jsx-own-observed-attributes/src/own-observed.jsx @@ -0,0 +1,32 @@ +export const inferredObservability = true; + +export default class OwnObserved extends HTMLElement { + static get observedAttributes() { + return ['label']; + } + + constructor() { + super(); + this.count = new Signal.State(0); + } + + attributeChangedCallback(name, oldValue, newValue) { + this.label = newValue; + } + + connectedCallback() { + this.render(); + } + + render() { + const { count } = this; + + return ( +
+ {count.get()} +
+ ); + } +} + +customElements.define('wcc-own-observed', OwnObserved); From 62d50e71e88adfef2f681e8358d123752225eaef Mon Sep 17 00:00:00 2001 From: Jonathan Stockdill Date: Sat, 18 Jul 2026 13:43:45 +0000 Subject: [PATCH 5/8] fix: #282 resolve component name for `export default Identifier` so observability effects don't call `undefined.$$tmpl0` --- src/jsx-loader.js | 5 ++ .../jsx-anonymous-class.spec.js | 75 +++++++++++++++++++ .../cases/jsx-anonymous-class/src/counter.jsx | 26 +++++++ 3 files changed, 106 insertions(+) create mode 100644 test/cases/jsx-anonymous-class/jsx-anonymous-class.spec.js create mode 100644 test/cases/jsx-anonymous-class/src/counter.jsx diff --git a/src/jsx-loader.js b/src/jsx-loader.js index ce8bc64..9aa3775 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -445,6 +445,11 @@ export function parseJsx(moduleURL) { declaration.id.name ) { componentName = declaration.id.name; + } else if (declaration && declaration.type === 'Identifier' && declaration.name) { + // also resolve the class name for the `class X {}; export default X;` form so the + // static-injection gate and the effect-append pass share one name (avoids `undefined.$$tmpl0`) + // https://github.com/ProjectEvergreen/wcc/issues/282 + componentName = declaration.name; } }, ClassDeclaration(node) { diff --git a/test/cases/jsx-anonymous-class/jsx-anonymous-class.spec.js b/test/cases/jsx-anonymous-class/jsx-anonymous-class.spec.js new file mode 100644 index 0000000..69a5e5d --- /dev/null +++ b/test/cases/jsx-anonymous-class/jsx-anonymous-class.spec.js @@ -0,0 +1,75 @@ +/* + * Use Case + * Run wcc against a custom element authored in the "declare class, customElements.define, then + * `export default Identifier`" form (as opposed to `export default class Name ...`) with + * inferredObservability enabled and a signal driving a connectedCallback effect. + * + * User Result + * The compiled source should reference the real class name (never `undefined`) in the injected + * effect wiring, and the static template / observedAttributes it depends on should be injected too, + * so the element does not throw `Cannot read properties of undefined (reading '$$tmpl0')` on connect. + * + * User Workspace + * src/ + * counter.jsx // class Counter extends HTMLElement {}; customElements.define(...); export default Counter; + */ +import { expect } from 'chai'; +import { renderToString } from '../../../src/wcc.js'; + +describe('Run WCC For ', function () { + const LABEL = 'Custom Element using JSX, Inferred Observability and `export default Identifier`'; + + describe(LABEL, function () { + let source; + + before(async function () { + const { metadata } = await renderToString(new URL('./src/counter.jsx', import.meta.url)); + + source = metadata['wcc-anon-counter'].source; + }); + + // the bug: effect wiring is appended unconditionally as `${componentName}.$$tmpl0(...)`, and for + // this export form `componentName` was `undefined` -> `undefined.$$tmpl0(...)` (throws on connect) + it('should not reference `undefined` in the injected effect expression', () => { + expect(source.indexOf('undefined.$$tmpl0')).to.equal(-1); + }); + + it('should reference the real class name in the injected effect expression', () => { + expect(source.indexOf('Counter.$$tmpl0(this.count.get())')).to.be.greaterThan(-1); + }); + + it('should inject the static template the effect depends on (real class name)', () => { + const actual = source.replace(/ /g, '').replace(/\n/g, ''); + + expect(actual.indexOf('static$$tmpl0=count=>`Count:${count}`')).to.be.greaterThan(-1); + }); + + it('should inject a get observedAttributes method for the tracked signal', () => { + const actual = source.replace(/ /g, '').replace(/\n/g, ''); + + expect(actual.indexOf("staticgetobservedAttributes(){return['count'];}")).to.be.greaterThan( + -1, + ); + }); + + it('should inject an attributeChangedCallback that parses via the real class name', () => { + const actual = source.replace(/ /g, '').replace(/\n/g, ''); + + expect(actual.indexOf('this[name].set(Counter.parseAttribute(newValue));')).to.be.greaterThan( + -1, + ); + }); + + // internal consistency: every `.$$tmpl0` call resolves to an injected `static $$tmpl0` + it('should be internally consistent (every $$tmpl0 caller has a matching static definition)', () => { + const callers = [...source.matchAll(/(\w+)\.\$\$tmpl0\(/g)].map((m) => m[1]); + + expect(callers.length).to.be.greaterThan(0); + + for (const name of callers) { + expect(name).to.not.equal('undefined'); + expect(source.indexOf('static $$tmpl0 =')).to.be.greaterThan(-1); + } + }); + }); +}); diff --git a/test/cases/jsx-anonymous-class/src/counter.jsx b/test/cases/jsx-anonymous-class/src/counter.jsx new file mode 100644 index 0000000..77b9a50 --- /dev/null +++ b/test/cases/jsx-anonymous-class/src/counter.jsx @@ -0,0 +1,26 @@ +export const inferredObservability = true; + +class Counter extends HTMLElement { + constructor() { + super(); + this.count = new Signal.State(0); + } + + connectedCallback() { + this.render(); + } + + render() { + const { count } = this; + + return ( +
+ Count: {count.get()} +
+ ); + } +} + +customElements.define('wcc-anon-counter', Counter); + +export default Counter; From 22d38a9bf9d42ab9244570c2ef8267f77edbb46e Mon Sep 17 00:00:00 2001 From: Jonathan Stockdill Date: Sat, 18 Jul 2026 13:43:45 +0000 Subject: [PATCH 6/8] fix: #282 `inferredObservability = false` now disables inferred observability --- src/jsx-loader.js | 4 +- .../jsx-observability-false.spec.js | 70 +++++++++++++++++++ .../jsx-observability-false/src/counter.jsx | 29 ++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 test/cases/jsx-observability-false/jsx-observability-false.spec.js create mode 100644 test/cases/jsx-observability-false/src/counter.jsx diff --git a/src/jsx-loader.js b/src/jsx-loader.js index 9aa3775..96ce4a2 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -430,8 +430,10 @@ export function parseJsx(moduleURL) { ) { // @ts-ignore if (declaration.declarations[0].id.name === 'inferredObservability') { + // use the parsed literal value, not its raw source text, so `= false` / `0` / `null` opt out + // https://github.com/ProjectEvergreen/wcc/issues/282 // @ts-ignore - inferredObservability = Boolean(node.declaration.declarations[0].init.raw); + inferredObservability = node.declaration.declarations[0].init.value === true; } } }, diff --git a/test/cases/jsx-observability-false/jsx-observability-false.spec.js b/test/cases/jsx-observability-false/jsx-observability-false.spec.js new file mode 100644 index 0000000..3978c24 --- /dev/null +++ b/test/cases/jsx-observability-false/jsx-observability-false.spec.js @@ -0,0 +1,70 @@ +/* + * Use Case + * Run wcc against a custom element using a JSX render function that opts out of + * inferred observability with `export const inferredObservability = false`. + * + * User Result + * Setting the flag to `false` should behave exactly like omitting the export: + * no observability wiring (observedAttributes / attributeChangedCallback / + * effect injection) should be generated in the compiled output. + * + * User Workspace + * src/ + * counter.jsx + */ +import { expect } from 'chai'; +import { JSDOM } from 'jsdom'; +import { renderToString } from '../../../src/wcc.js'; + +describe('Run WCC For ', function () { + const LABEL = 'Custom Element using JSX with Inferred Observability disabled (= false)'; + const effectImport = `import{effect}from'wc-compiler/effect';`; + + describe(LABEL, function () { + let source; + let dom; + + before(async function () { + const { html, metadata } = await renderToString( + new URL('./src/counter.jsx', import.meta.url), + ); + + source = metadata['wcc-counter-observability-false'].source + .replace(/ /g, '') + .replace(/\n/g, ''); + dom = new JSDOM(html); + }); + + it('should NOT generate a get observedAttributes method', () => { + expect(source).to.not.contain('observedAttributes'); + }); + + it('should NOT generate an attributeChangedCallback method', () => { + expect(source).to.not.contain('attributeChangedCallback'); + }); + + it('should NOT generate a static parseAttribute method', () => { + expect(source).to.not.contain('parseAttribute'); + }); + + it('should NOT inject effect wiring (no effect() calls or $eff members)', () => { + expect(source).to.not.contain('$eff'); + }); + + it("should NOT generate an import for WCC's effect function", () => { + expect(source).to.not.contain(effectImport); + }); + + // `= false` must still render as normal SSR, identical to omitting the export + it('should still render the component content in the SSR output', () => { + const shadowDom = new JSDOM( + dom.window.document.querySelector( + 'wcc-counter-observability-false template[shadowrootmode="open"]', + ).innerHTML, + ).window.document; + const span = shadowDom.querySelector('span#label'); + + expect(span.textContent.trim()).to.equal('The count is static'); + }); + }); +}); diff --git a/test/cases/jsx-observability-false/src/counter.jsx b/test/cases/jsx-observability-false/src/counter.jsx new file mode 100644 index 0000000..8af394c --- /dev/null +++ b/test/cases/jsx-observability-false/src/counter.jsx @@ -0,0 +1,29 @@ +export const inferredObservability = false; + +export default class CounterObservabilityFalse extends HTMLElement { + constructor() { + super(); + this.count = new Signal.State(0); + } + + connectedCallback() { + if (!this.shadowRoot) { + this.attachShadow({ + mode: 'open', + }); + this.render(); + } + } + + render() { + const label = 'The count is static'; + + return ( +
+ {label} +
+ ); + } +} + +customElements.define('wcc-counter-observability-false', CounterObservabilityFalse); From 435c54663f1e560c3a57935c60b041f41986c6ff Mon Sep 17 00:00:00 2001 From: Jonathan Stockdill Date: Sat, 18 Jul 2026 13:44:04 +0000 Subject: [PATCH 7/8] fix: #282 render {this.count.get()} signal reads directly in jsx instead of undefined.get() --- src/jsx-loader.js | 10 +++- .../jsx-this-signal-read.spec.js | 56 +++++++++++++++++++ .../jsx-this-signal-read/src/counter.jsx | 23 ++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 test/cases/jsx-this-signal-read/jsx-this-signal-read.spec.js create mode 100644 test/cases/jsx-this-signal-read/src/counter.jsx diff --git a/src/jsx-loader.js b/src/jsx-loader.js index 96ce4a2..d5025f9 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -296,7 +296,15 @@ function parseJsxElement( // TODO: handle this references // https://github.com/ProjectEvergreen/wcc/issues/88 const { object, property } = element.expression.callee; - string += `$\{${object.name}.${property.name}()}`; + + if (object.type === 'MemberExpression' && object.object?.type === 'ThisExpression') { + // read a signal directly off `this` in JSX, e.g. {this.count.get()}, + // mirroring the destructure-first form {count.get()} handled below + // https://github.com/ProjectEvergreen/wcc/issues/282 + string += `$\{this.${object.property.name}.${property.name}()}`; + } else { + string += `$\{${object.name}.${property.name}()}`; + } } else if (type === 'Identifier') { // You have {count} TODOs left to complete string += `$\{${element.expression.name}}`; diff --git a/test/cases/jsx-this-signal-read/jsx-this-signal-read.spec.js b/test/cases/jsx-this-signal-read/jsx-this-signal-read.spec.js new file mode 100644 index 0000000..71661c2 --- /dev/null +++ b/test/cases/jsx-this-signal-read/jsx-this-signal-read.spec.js @@ -0,0 +1,56 @@ +/* + * Use Case + * Run wcc against a custom element that reads a signal directly off `this` in its JSX + * render function (e.g. {this.count.get()}) with inferredObservability enabled, instead + * of destructuring the signal first (const { count } = this; ... {count.get()}). + * + * User Result + * Should render the signal's value (The count is 0) rather than compiling to + * ${undefined.get()} and crashing SSR with "Cannot read properties of undefined". + * + * User Workspace + * src/ + * counter.jsx + */ +import { expect } from 'chai'; +import { JSDOM } from 'jsdom'; +import { renderToString } from '../../../src/wcc.js'; + +describe('Run WCC For ', function () { + const LABEL = 'Custom Element reading a signal directly off `this` in JSX'; + + describe(LABEL, function () { + let html; + let source; + let dom; + + before(async function () { + const { html: renderedHtml, metadata } = await renderToString( + new URL('./src/counter.jsx', import.meta.url), + ); + + html = renderedHtml; + source = metadata['wcc-this-signal-read'].source; + dom = new JSDOM(html); + }); + + it('should render the signal value for {this.count.get()} read directly in JSX', function () { + const shadowRoot = dom.window.document.querySelector( + 'wcc-this-signal-read template[shadowrootmode="open"]', + ); + const innerDom = new JSDOM(shadowRoot.innerHTML).window.document; + const paragraph = innerDom.querySelector('p#count'); + + expect(paragraph.textContent.trim()).to.equal('The count is 0'); + }); + + it('should not compile a direct this-signal read down to undefined.get()', function () { + expect(html).to.not.contain('undefined.get()'); + expect(source).to.not.contain('undefined.get()'); + }); + + it('should compile {this.count.get()} to a scoped this-read in the render template', function () { + expect(source).to.contain('this.count.get()'); + }); + }); +}); diff --git a/test/cases/jsx-this-signal-read/src/counter.jsx b/test/cases/jsx-this-signal-read/src/counter.jsx new file mode 100644 index 0000000..9f5ea1a --- /dev/null +++ b/test/cases/jsx-this-signal-read/src/counter.jsx @@ -0,0 +1,23 @@ +export const inferredObservability = true; + +export default class ThisSignalCounter extends HTMLElement { + constructor() { + super(); + this.count = new Signal.State(0); + } + + connectedCallback() { + if (!this.shadowRoot) { + this.attachShadow({ mode: 'open' }); + } + + this.render(); + } + + render() { + // read the signal directly off `this` in JSX, without destructuring first + return

The count is {this.count.get()}

; + } +} + +customElements.define('wcc-this-signal-read', ThisSignalCounter); From 9a183637c3c10499eb8f1dc1f03acbc6f355762e Mon Sep 17 00:00:00 2001 From: Jonathan Stockdill Date: Sat, 18 Jul 2026 13:44:44 +0000 Subject: [PATCH 8/8] fix: #282 warn on unsupported jsx expressions instead of silently dropping them --- src/jsx-loader.js | 23 +++++ ...jsx-unsupported-expression-warning.spec.js | 87 +++++++++++++++++++ .../src/card.jsx | 28 ++++++ 3 files changed, 138 insertions(+) create mode 100644 test/cases/jsx-unsupported-expression-warning/jsx-unsupported-expression-warning.spec.js create mode 100644 test/cases/jsx-unsupported-expression-warning/src/card.jsx diff --git a/src/jsx-loader.js b/src/jsx-loader.js index d5025f9..196e52a 100644 --- a/src/jsx-loader.js +++ b/src/jsx-loader.js @@ -285,6 +285,7 @@ function parseJsxElement( if (type === 'JSXExpressionContainer') { const { type } = element.expression; + const emittedBefore = string; if ( inferredObservability && @@ -305,6 +306,9 @@ function parseJsxElement( } else { string += `$\{${object.name}.${property.name}()}`; } + } else if (type === 'Literal') { + // literal text/whitespace, e.g. the {' '} spacing idiom or {'hello'} / {42} + string += element.expression.value; } else if (type === 'Identifier') { // You have {count} TODOs left to complete string += `$\{${element.expression.name}}`; @@ -323,6 +327,25 @@ function parseJsxElement( string += `$\{${element.expression.object.name}.${element.expression.property.name}}`; } } + + // if none of the branches above emitted anything, this expression node type is not supported + // and would be silently dropped from the output — warn so the developer sees why content is + // missing. Supported child expressions: {identifier}, {object.property}, literals, and (with + // inferredObservability) {signal.get()}. `JSXEmptyExpression` ({} and {/* comments */}) + // renders nothing by design. https://github.com/ProjectEvergreen/wcc/issues/282 + if (string === emittedBefore && type !== 'JSXEmptyExpression') { + let source; + + try { + source = generate(element.expression); + } catch { + source = type; + } + + console.warn( + `wcc: unsupported JSX expression \`${source}\` (${type}) was dropped from the rendered output.`, + ); + } } } catch (e) { console.error(e); diff --git a/test/cases/jsx-unsupported-expression-warning/jsx-unsupported-expression-warning.spec.js b/test/cases/jsx-unsupported-expression-warning/jsx-unsupported-expression-warning.spec.js new file mode 100644 index 0000000..c4184c8 --- /dev/null +++ b/test/cases/jsx-unsupported-expression-warning/jsx-unsupported-expression-warning.spec.js @@ -0,0 +1,87 @@ +/* + * Use Case + * Run wcc against a JSX component that uses expression node types the renderer does not support + * (a binary expression `{count + 1}` and a `{items.map(...)}` list). These expressions are + * dropped from the SSR output; without a diagnostic the developer has no idea why content is + * missing. This case asserts that a `console.warn` naming the unsupported expression is emitted, + * while the previous (empty) render behavior is unchanged. + * + * User Result + * Should still render the surrounding markup with the unsupported expressions empty, AND emit a + * warning naming each unsupported expression so the drop is not silent. + * + * User Workspace + * src/ + * card.jsx + */ +import { expect } from 'chai'; +import { JSDOM } from 'jsdom'; +import { renderToString } from '../../../src/wcc.js'; + +describe('Run WCC For ', function () { + const LABEL = 'Unsupported JSX Expression Warning'; + let dom; + let warnings; + let originalWarn; + + before(async function () { + originalWarn = console.warn; + warnings = []; + console.warn = (...args) => { + warnings.push(args.map(String).join(' ')); + }; + + try { + const { html } = await renderToString(new URL('./src/card.jsx', import.meta.url)); + + dom = new JSDOM(html); + } finally { + console.warn = originalWarn; + } + }); + + describe(LABEL, function () { + describe('Diagnostic for unsupported expression node types', function () { + it('should warn about the unsupported binary expression naming its source', function () { + const match = warnings.find( + (warning) => warning.includes('count + 1') && /unsupported JSX expression/i.test(warning), + ); + + expect(match).to.not.be.undefined; + }); + + // the `.map` body contains JSX, which astring cannot serialize, so the warning names the + // node type (CallExpression) rather than the source text — still a diagnostic, not silence + it('should warn about the unsupported `.map` list expression', function () { + const match = warnings.find( + (warning) => + warning.includes('CallExpression') && /unsupported JSX expression/i.test(warning), + ); + + expect(match).to.not.be.undefined; + }); + + it('should emit at least one unsupported-expression warning', function () { + const unsupported = warnings.filter((warning) => + /unsupported JSX expression/i.test(warning), + ); + + expect(unsupported.length).to.be.greaterThan(0); + }); + }); + + describe('Rendered output (behavior unchanged — expressions still empty)', function () { + it('should render the paragraph with the binary expression empty', function () { + const paragraph = dom.window.document.querySelector('p#plus'); + + expect(paragraph.textContent.trim()).to.be.equal('Plus one:'); + }); + + it('should render an empty list for the `.map` expression', function () { + const list = dom.window.document.querySelector('ul#list'); + + expect(list.querySelectorAll('li').length).to.be.equal(0); + }); + }); + }); +}); diff --git a/test/cases/jsx-unsupported-expression-warning/src/card.jsx b/test/cases/jsx-unsupported-expression-warning/src/card.jsx new file mode 100644 index 0000000..907a412 --- /dev/null +++ b/test/cases/jsx-unsupported-expression-warning/src/card.jsx @@ -0,0 +1,28 @@ +export default class Card extends HTMLElement { + constructor() { + super(); + this.items = ['one', 'two', 'three']; + } + + connectedCallback() { + this.render(); + } + + render() { + const { items } = this; + const count = items.length; + + return ( +
+

Plus one: {count + 1}

+
    + {items.map((item) => ( +
  • {item}
  • + ))} +
+
+ ); + } +} + +customElements.define('wcc-card', Card);