Skip to content

perf: emit class fields as plain assignments - #2309

Open
lavrton wants to merge 1 commit into
mobxjs:masterfrom
lavrton:perf/plain-class-fields
Open

lavrton wants to merge 1 commit into
mobxjs:masterfrom
lavrton:perf/plain-class-fields

Conversation

@lavrton

@lavrton lavrton commented Oct 15, 2026

Copy link
Copy Markdown

What does this PR do and why?

MST is compiled with useDefineForClassFields: true, so every class field becomes an Object.defineProperty call in the constructor. MST creates a lot of nodes (one per model instance and per property value), and these calls make that slow.

This PR switches the option to false, so fields are plain assignments. I also added = undefined to a few node fields that are set later, so V8 keeps one hidden class per node type.

Results on my machine (Node, production build):

  • creating 10k todos: 244ms -> 131ms
  • applySnapshot on 4k items: 68ms -> 16ms
  • action updating 10k todos: 48ms -> 38ms
  • bundle size: 82kb -> 73kb

One thing gets a bit slower: reading node paths (getPath, getParent) is about 15% slower, because node objects are bigger now. Everything else I measured is the same or faster.

Bun ignores this compiler option when running tests, so I added a small test that compiles the node classes and checks the fields are assigned in the constructor.

With "useDefineForClassFields": true and an es2015 target, tsc turns
every class field into an Object.defineProperty(this, ...) call in the
constructor, 110 of them in the bundle. BaseNode and ObjectNode are
constructed for every model instance and every scalar property value,
so these calls dominate node creation.

The option was enabled with MobX 6 support (mobxjs#1569), when the node and
type classes called makeObservable(this). They no longer do, and no
class in src relies on define semantics: no field name collides with an
accessor, nothing enumerates or `in`-checks node or type instances, and
the type declarations are unchanged.

With plain assignments, tsc emits nothing for a field without an
initializer, so a field that is set lazily (pathAtom, _internalEvents,
middlewares, identifierCache, ...) is created on its first write. Nodes
then get one V8 hidden class for each order of those writes. Give these
fields an `= undefined` initializer in BaseNode, ObjectNode and
StoredReference, so each class keeps one hidden class.

Bun ignores this option when it runs src, so add a test that compiles
the node classes with the project's compiler options and checks that
construction assigns every declared field.
@lavrton

lavrton commented Oct 15, 2026

Copy link
Copy Markdown
Author

hey @coolsoftwaretyler, sorry for spamming with these PRs, but they will help us a lot. Please let me know if you need any more context or work from our side.
Thanks for the great library

@coolsoftwaretyler

Copy link
Copy Markdown
Collaborator

Thanks @lavrton - happy to get all the PRs. I'll get around to them as quickly as I can. Once they all merge, I'll cut a new version you can use.

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.

2 participants