From 4cddfc7b25b2d5cac9eb2f5accdfe2014eb9d843 Mon Sep 17 00:00:00 2001 From: Cristian Necula Date: Sun, 1 Feb 2026 18:57:56 +0200 Subject: [PATCH] fix: prevent orphan elements from rendering Elements that are constructed but never connected to the DOM will no longer render or run effects. This fixes issues where lit-html creates elements during template parsing that are never inserted into the DOM. The scheduler now starts with _active = false and only activates when connectedCallback is called (for custom elements) or resume() is called explicitly (for virtual components). Fixes #64 --- .changeset/fix-orphan-elements.md | 13 +++++++++++ src/scheduler.ts | 2 +- src/virtual.ts | 1 + test/use-effects.test.ts | 37 +++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-orphan-elements.md diff --git a/.changeset/fix-orphan-elements.md b/.changeset/fix-orphan-elements.md new file mode 100644 index 0000000..7c2db2f --- /dev/null +++ b/.changeset/fix-orphan-elements.md @@ -0,0 +1,13 @@ +--- +"@pionjs/pion": minor +--- + +fix: prevent orphan elements from rendering + +Elements that are constructed but never connected to the DOM (orphan elements) will no longer render or run effects. This fixes issues where lit-html creates elements during template parsing that are never inserted into the DOM. + +The scheduler now starts with `_active = false` and only activates when: +- `connectedCallback` is called (for custom elements) +- `resume()` is called explicitly (for virtual components) + +Fixes #64 diff --git a/src/scheduler.ts b/src/scheduler.ts index aa424df..b7edc0e 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -56,7 +56,7 @@ abstract class BaseScheduler< this.state = new State(this.update.bind(this), host); this[phaseSymbol] = null; this._updateQueued = false; - this._active = true; + this._active = false; } update(): void { diff --git a/src/virtual.ts b/src/virtual.ts index df89c3e..bdc4d77 100644 --- a/src/virtual.ts +++ b/src/virtual.ts @@ -82,6 +82,7 @@ function makeVirtual(): Virtual { teardownOnRemove(this.cont, part); } this.cont.args = args; + this.cont.resume(); this.cont.update(); return this.render(...args); } diff --git a/test/use-effects.test.ts b/test/use-effects.test.ts index ffbb0b3..c282338 100644 --- a/test/use-effects.test.ts +++ b/test/use-effects.test.ts @@ -205,6 +205,43 @@ describe("useEffect", () => { expect(teardowns).to.equal(1, "the effect was not torn down"); }); + it("Does not render or run effects for elements that are never connected", async () => { + let renders = 0; + let effects = 0; + + function app() { + renders++; + + useEffect(() => { + effects++; + return () => {}; + }, []); + + return html`Test`; + } + + customElements.define("orphan-element-test", component(app)); + + // Create an element but don't connect it to the DOM + const orphan = document.createElement("orphan-element-test") as HTMLElement & { prop: number }; + orphan.prop = 1; // Trigger a property setter which would normally queue an update + + await nextFrame(); + + expect(renders).to.equal(0, "the orphan element should not render"); + expect(effects).to.equal(0, "the orphan element should not run effects"); + + // Now connect it + document.body.appendChild(orphan); + await nextFrame(); + + expect(renders).to.equal(1, "the element should render after being connected"); + expect(effects).to.equal(1, "the element should run effects after being connected"); + + // Cleanup + orphan.remove(); + }); + it("useEffect(fn, []) runs the effect only once", async () => { const tag = "empty-array-effect-test"; let calls = 0;