Skip to content

fix(core): createModel to wrap class prototype methods as actions - #984

Open
SisyphusZheng wants to merge 7 commits into
preactjs:mainfrom
SisyphusZheng:fix/model-class-actions
Open

SisyphusZheng wants to merge 7 commits into
preactjs:mainfrom
SisyphusZheng:fix/model-class-actions

Conversation

@SisyphusZheng

@SisyphusZheng SisyphusZheng commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #983

Summary

createModel wraps model functions as actions via wrapInAction, which enumerates with for...in and therefore only sees own enumerable properties. Class instance models keep methods on the (non-enumerable) prototype, so they are missed entirely, while the public ValidateModel type accepts class instances. Consequences, all verified on main (b0df09a):

  • two signal writes in a class method flush separately (the object-literal equivalent flushes once),
  • an effect calling a class method that reads + writes a signal self-notifies until the Cycle detected guard throws,
  • a getter returning a function crashes construction with a TypeError.

Changes

In wrapInAction:

  • walk the prototype chain of "[object Object]"-tagged values and wrap inherited methods as own properties (skipping constructor and anything shadowed by an own property),
  • skip accessor properties via property descriptors instead of reading and reassigning them (fixes the getter-only TypeError),
  • the "[object Object]" check keeps built-ins (Map, Date, arrays, functions) untouched. This is defensive only — ValidateModel already rejects built-ins at the type level — but keeps JS users without typechecking safe.

Object-literal behavior is unchanged: all existing tests pass as-is.

Tests

Added to the createModel suite:

  • class prototype methods get batched action semantics,
  • methods inherited from a base class are wrapped,
  • actions called inside effects stay untracked (no self-notify / Cycle detected),
  • accessor properties no longer crash construction.

Verification

  • pnpm vitest run packages/core — 172 passed, 2 skipped
  • pnpm lint:tsc — clean
  • pnpm oxlint packages/core/src/index.ts packages/core/test/signal.test.tsx — no new warnings beyond the file's pre-existing no-unused-expressions style

Includes a changeset for @preact/signals-core (patch).

AI assistance

This contribution was developed with assistance from OpenAI Codex.

@netlify

netlify Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for preact-signals-demo ready!

Name Link
🔨 Latest commit f66b612
🔍 Latest deploy log https://app.netlify.com/projects/preact-signals-demo/deploys/6abe8bb1146889000908bb74
😎 Deploy Preview https://deploy-preview-984--preact-signals-demo.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@changeset-bot

changeset-bot Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f66b612

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

This PR includes changesets to release 2 packages
Name Type
@preact/signals-core Patch
preact-signals-devtools Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@JoviDeCroock JoviDeCroock left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to mention that our code of conduct asks for LLM disclosure...

Comment thread .changeset/wrap-in-action-class-models.md Outdated
Comment thread packages/core/src/index.ts Outdated
Comment thread packages/core/src/index.ts Outdated
Comment thread packages/core/src/index.ts Outdated
@JoviDeCroock

JoviDeCroock commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

The following compresses better

const wrapInAction = (value: Record<string, unknown>) => {
      for (
              let obj = value;
              obj && obj !== Object.prototype;
              obj =
                      Object.prototype.toString.call(value) === "[object Object]" &&
                      Object.getPrototypeOf(obj)
      ) {
              const descs = Object.getOwnPropertyDescriptors(obj);
              for (const key in descs) {
                      const val = descs[key].value;
                      if (typeof val === "function") {
                              if (key !== "constructor" && value[key] === val) {
                                      value[key] = action(val as (...args: unknown[]) => unknown);
                              }
                      } else if (
                              obj === value &&
                              typeof val === "object" &&
                              val !== null &&
                              !("brand" in val)
                      ) {
                              wrapInAction(val as Record<string, unknown>);
                      }
              }
      }
};

SisyphusZheng and others added 4 commits September 25, 2026 17:09
Co-authored-by: Jovi De Croock <decroockjovi@gmail.com>
Co-authored-by: Jovi De Croock <decroockjovi@gmail.com>
Co-authored-by: Jovi De Croock <decroockjovi@gmail.com>
@SisyphusZheng
SisyphusZheng marked this pull request as draft September 25, 2026 09:09
@SisyphusZheng
SisyphusZheng marked this pull request as ready for review September 25, 2026 10:48
@SisyphusZheng

Copy link
Copy Markdown
Contributor Author

I'd like to mention that our code of conduct asks for LLM disclosure...

Sorry, I missed the CoC requirement. I ran into this while integrating signals into a framework I'm working on. I used an LLM to help analyze the bug, draft the issue and PR description, and write the initial patch and tests. I reproduced all three failure modes myself, and I've applied your suggested change and re-ran the test suite locally.

@SisyphusZheng SisyphusZheng changed the title Fix createModel to wrap class prototype methods as actions fix(core): createModel to wrap class prototype methods as actions Sep 26, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

createModel: class prototype methods are not wrapped as actions (and getters crash construction)

2 participants