Repository: preactjs/signals
Pull request #679: Fix accessing signals from Preact Class Component constructor.
Author: login: WeirdConstructor
Created: 2025-05-21T14:52:44Z | Merged: 2025-05-23T07:26:34Z

This pull request fixes a bug we discussed on slack today.

I was using signals with Preact in class components when I stumbled upon this error in the Browser: `Uncaught (in promise) TypeError: e.__ is null`. Where `__` is the mangled `_parent` property of a VNode. The TypeError occurs in the renderComponent() function of Preact, where it tries to access the parent to insert the newVNode.

The issue was, that reading the signal in the constructor of a top level component caused some internal Fragment VNode to be diffed. This change skips over the internal Fragment in the RENDER hook.

I needed to bump the devDependencies of preact from 10.9.0 to 10.25.0, because this error does only occur with a newer version of preact.


Diff:
diff --git a/.changeset/loud-impalas-swim.md b/.changeset/loud-impalas-swim.md
new file mode 100644
index 000000000..46993e08f
--- /dev/null
+++ b/.changeset/loud-impalas-swim.md
@@ -0,0 +1,5 @@
+---
+"@preact/signals": patch
+---
+
+Fix accessing signals from Preact Class Component constructor.
diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts
index 9d10b5455..cb93736c2 100644
--- a/packages/preact/src/index.ts
+++ b/packages/preact/src/index.ts
@@ -1,4 +1,4 @@
-import { options, Component, isValidElement } from "preact";
+import { options, Component, isValidElement, Fragment } from "preact";
 import { useRef, useMemo, useEffect } from "preact/hooks";
 import {
 	signal,
@@ -190,25 +190,29 @@ hook(OptionsTypes.DIFF, (old, vnode) => {
 
 /** Set up Updater before rendering a component */
 hook(OptionsTypes.RENDER, (old, vnode) => {
-	setCurrentUpdater();
-
-	let updater;
+	// Ignore the Fragment inserted by preact.createElement().
+	if (vnode.type !== Fragment) {
+		setCurrentUpdater();
 
-	let component = vnode.__c;
-	if (component) {
-		component._updateFlags &= ~HAS_PENDING_UPDATE;
+		let updater;
 
-		updater = component._updater;
-		if (updater === undefined) {
-			component._updater = updater = createUpdater(() => {
-				component._updateFlags |= HAS_PENDING_UPDATE;
-				component.setState({});
-			});
+		let component = vnode.__c;
+		if (component) {
+			component._updateFlags &= ~HAS_PENDING_UPDATE;
+
+			updater = component._updater;
+			if (updater === undefined) {
+				component._updater = updater = createUpdater(() => {
+					component._updateFlags |= HAS_PENDING_UPDATE;
+					component.setState({});
+				});
+			}
 		}
+
+		currentComponent = component;
+		setCurrentUpdater(updater);
 	}
 
-	currentComponent = component;
-	setCurrentUpdater(updater);
 	old(vnode);
 });
 
diff --git a/packages/preact/test/index.test.tsx b/packages/preact/test/index.test.tsx
index 904cfbafb..7d3ebe95b 100644
--- a/packages/preact/test/index.test.tsx
+++ b/packages/preact/test/index.test.tsx
@@ -7,7 +7,13 @@ import {
 	useSignal,
 } from "@preact/signals";
 import type { ReadonlySignal } from "@preact/signals";
-import { createElement, createRef, render, createContext } from "preact";
+import {
+	createElement,
+	createRef,
+	render,
+	createContext,
+	Component,
+} from "preact";
 import type { ComponentChildren, FunctionComponent, VNode } from "preact";
 import { useContext, useEffect, useRef, useState } from "preact/hooks";
 import { setupRerender, act } from "preact/test-utils";
@@ -962,4 +968,37 @@ describe("@preact/signals", () => {
 			"<div><div>4</div><div>5</div><div>6</div></div>"
 		);
 	});
+
+	describe("Preact class Component", () => {
+		it("should support reading signal in class component constructor", async () => {
+			const count = signal(1);
+			const spy = sinon.spy();
+
+			class App extends Component<{ x: number }, unknown> {
+				constructor(props: { x: number }) {
+					super(props);
+					spy("constructor:" + count.value);
+				}
+
+				componentWillMount() {
+					spy("willmount:" + count.value);
+				}
+
+				render() {
+					return <div>{count}</div>;
+				}
+			}
+			expect(() =>
+				act(() => {
+					render(<App x={1} />, scratch);
+					count.value = 2;
+					rerender();
+				})
+			).not.to.throw();
+			expect(scratch.innerHTML).to.equal("<div>2</div>");
+			expect(spy).to.have.been.calledTwice;
+			expect(spy).to.have.been.calledWith("constructor:1");
+			expect(spy).to.have.been.calledWith("willmount:1");
+		});
+	});
 });


Reviews:
user: login: JoviDeCroock
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T15:00:46Z
user: login: rschristian
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-22T19:53:24Z
user: login: JoviDeCroock
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-23T05:57:43Z

Review comments:
diff_hunk: @@ -50,7 +50,7 @@
     "preact": ">= 10.25.0"
   },
   "devDependencies": {
-    "preact": "10.9.0",
+    "preact": "10.25.0",
path: packages/preact/package.json
user: login: JoviDeCroock
body: You forgot to update the lockfile
created_at: 2025-05-21T15:00:19Z
author_association: MEMBER
diff_hunk: @@ -190,6 +190,12 @@ hook(OptionsTypes.DIFF, (old, vnode) => {
 
 /** Set up Updater before rendering a component */
 hook(OptionsTypes.RENDER, (old, vnode) => {
+	if (vnode.type === Fragment) {
path: packages/preact/src/index.ts
user: login: JoviDeCroock
body: Let's wrap the thing underneath in a `!== Fragment` to save bytes
created_at: 2025-05-21T15:00:37Z
author_association: MEMBER
diff_hunk: @@ -962,4 +968,37 @@ describe("@preact/signals", () => {
 			"<div><div>4</div><div>5</div><div>6</div></div>"
 		);
 	});
+
+	describe("Preact class Component", () => {
+		it("should support reading signal in class component constructor", async () => {
+			const count = signal(1);
+			const spy = sinon.spy();
+
+			class App extends Component {
path: packages/preact/test/index.test.tsx
user: login: JoviDeCroock
body: ```suggestion
			class App extends Component<{ x: number }, {}> {
```
created_at: 2025-05-23T05:57:06Z
author_association: MEMBER
diff_hunk: @@ -962,4 +968,37 @@ describe("@preact/signals", () => {
 			"<div><div>4</div><div>5</div><div>6</div></div>"
 		);
 	});
+
+	describe("Preact class Component", () => {
+		it("should support reading signal in class component constructor", async () => {
+			const count = signal(1);
+			const spy = sinon.spy();
+
+			class App extends Component {
+				constructor(props) {
+					super(props);
+					spy("constructor:" + count.value);
+				}
+
+				componentWillMount() {
+					spy("willmount:" + count.value);
+				}
+
+				render() {
+					return <div>{count}</div>;
+				}
+			}
+			expect(() =>
+				act(() => {
+					render(createElement(App, { x: 1 }), scratch);
path: packages/preact/test/index.test.tsx
user: login: JoviDeCroock
body: ```suggestion
					render(<App x={1} />, scratch);
```
created_at: 2025-05-23T05:57:23Z
author_association: MEMBER

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-05-21T14:52:48Z
body: ###  🦋  Changeset detected

Latest commit: c54b6a76b3056e8b6184ad91674dcbd6f640d48b

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name            | Type  |
| --------------- | ----- |
| @preact/signals | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/WeirdConstructor/signals/new/main?filename=.changeset/chilled-fans-deliver.md&value=---%0A%22%40preact%2Fsignals%22%3A%20patch%0A---%0A%0AFix%20accessing%20signals%20from%20Preact%20Class%20Component%20constructor.%0A)


author_association: NONE
user: login: netlify[bot]
created_at: 2025-05-21T14:53:44Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *preact-signals-demo* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 95b65cff4ce9bee65db77c4cc9d59e8f58cdc3af |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/projects/preact-signals-demo/deploys/68301d8ba3e0240008c5adad |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-679--preact-signals-demo.netlify.app](https://deploy-preview-679--preact-signals-demo.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTY3OS0tcHJlYWN0LXNpZ25hbHMtZGVtby5uZXRsaWZ5LmFwcCJ9.XmVz4oTPAmgiGQ7-_nme2BGmKMSOlhYmO_TLCHndjKc)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [preact-signals-demo Preview](https://deploy-preview-679--preact-signals-demo.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify project configuration](https://app.netlify.com/projects/preact-signals-demo/configuration/notifications#deploy-notifications)._
author_association: NONE
user: login: JoviDeCroock
created_at: 2025-05-21T15:14:43Z
body: This looks dangerous, please don't update all deps
author_association: MEMBER
user: login: WeirdConstructor
created_at: 2025-05-21T15:19:23Z
body: > This looks dangerous, please don't update all deps

I have not yet worked a with pnpm. How do I update only the version of preact in the lockfile? Manual editing?
author_association: CONTRIBUTOR
user: login: JoviDeCroock
created_at: 2025-05-21T15:21:06Z
body: Just update the preact version to whatever you like for instance `^10.25.0` and run `pnpm i` at the root. You most likely will have to `revert` your prior change though
author_association: MEMBER
user: login: WeirdConstructor
created_at: 2025-05-21T16:21:16Z
body: Ah, ok, will do that tomorrow.
author_association: CONTRIBUTOR
user: login: WeirdConstructor
created_at: 2025-05-22T05:19:40Z
body: The deploy-preview passes now, yay :-)
author_association: CONTRIBUTOR
user: login: JoviDeCroock
created_at: 2025-05-22T06:09:03Z
body: Awesome, can you just add a patch changeset with `pnpm changeset` or by clicking the prompt from the bot in the UI here
author_association: MEMBER
user: login: WeirdConstructor
created_at: 2025-05-22T06:35:15Z
body: I hope the changeset is correct? And I tried looking into the issues eslint found. But I have no clue what to make of the errors it produces. Also, running `pnpm run lint:eslint` does not do anything locally here, producing no output at all, neither on my Linux or my Windows machine.
author_association: CONTRIBUTOR
user: login: JoviDeCroock
created_at: 2025-05-22T06:39:49Z
body: The errors are from the typecheck and are caused by the Preact upgrade, I can look at them CC @rschristian 
author_association: MEMBER
user: login: WeirdConstructor
created_at: 2025-05-23T06:27:39Z
body: Seems like I kicked over a can of worms regarding #680 :-)
I wanted to thank everyone for your time and guiding me through this PR.
author_association: CONTRIBUTOR
user: login: JoviDeCroock
created_at: 2025-05-23T06:31:30Z
body: Thank you for starting this effort and unveiling this bug, great work!
author_association: MEMBER
user: login: JoviDeCroock
created_at: 2025-05-23T06:32:59Z
body: @WeirdConstructor I made a lint error myself, do you have the bandwidth to solve this? If you want to verify locally https://github.com/preactjs/signals/blob/main/.github/workflows/main.yml#L36 the command can be found here
author_association: MEMBER
user: login: WeirdConstructor
created_at: 2025-05-23T06:34:13Z
body: @JoviDeCroock yes, I am pulling the branch now and will try to fix that lint error.
author_association: CONTRIBUTOR