Skip to content

feat(mobx): make 2022.3 @observable decorator lazy#4658

Merged
kubk merged 5 commits into
mobxjs:mainfrom
ashishkr96:feat/lazy-observable-decorator-4616
Jun 3, 2026
Merged

feat(mobx): make 2022.3 @observable decorator lazy#4658
kubk merged 5 commits into
mobxjs:mainfrom
ashishkr96:feat/lazy-observable-decorator-4616

Conversation

@ashishkr96
Copy link
Copy Markdown
Contributor

@ashishkr96 ashishkr96 commented May 29, 2026

Summary

Follow-up to #4639 per maintainer request. Applies the same lazy treatment to the 2022.3 @observable accessor decorator that #4639 applied to @computed.

Today the accessor decorator's init callback eagerly constructs an ObservableValue for every decorated field on every instance — even fields that are never read. On wide classes with many fields and sparse access this is wasted allocation and construction work (see #4616).

This change defers ObservableValue creation to the first read/write/observe of the decorated accessor:

  • decorate_20223_ in observableannotation.ts registers a factory closure on the admin's new lazyObservableKeys_ map from the accessor's init callback, instead of building the ObservableValue and inserting it into values_ upfront. The initializedObjects WeakSet workaround is gone — init now always registers the lazy factory.
  • ObservableObjectAdministration.materializeLazyObservable_(key) lazily instantiates the ObservableValue and inserts it into values_ on demand. It is called from getObservablePropValue_ / setObservablePropValue_ (so the existing accessor get/set Just Works) and from getAtom (so observe(o, key, ...) materialises before reading).
  • isObservableProp checks lazyObservableKeys_ in addition to values_ / lazyComputedKeys_ so a decorated observable continues to report as observable before its first read.
  • Hot path is unchanged: values_.get(key) is the single Map lookup once the field has been read for the first time; the materialise branch only runs the first time and for the rare pre-read getAtom / observe callers.

The legacy makeObservable-based path (defineObservableProperty_) is unchanged.

Benchmarks (this PR, lazy)

50k-instance × 10-field Wide class, Linux Node 24, --expose-gc, 1 warmup + 3 timed runs, averaged. Reproduce with npm run build && npm run perf-decorator:

pattern construct heap construct ms first-read ms re-read ms
SPARSE — 1 of 10 fields read per instance 118.6 MB 320.6 40.9 45.4
FULL — all 10 fields read per instance 118.4 MB 335.4 310.7 330.7

Two things to read out of this:

  1. Construction cost is independent of access pattern. Heap and construct ms are essentially the same between SPARSE and FULL (118 MB / ~330 ms) — that's the cost of registering lazy factories for 10 fields on 50k instances, regardless of what gets read later.
  2. Lazy defers allocation rather than eliminating it. Under SPARSE, only 1 ObservableValue gets materialised per instance, and first-read stays small (~41 ms). Under FULL, all 10 get materialised on first read, and first-read jumps to ~310 ms — the same total work as eager would have done at construction, just shifted later.

So the headline win is for sparse-access workloads (the realistic #4616 shape — wide stores where most fields aren't touched per render). For workloads that eventually touch every field, lazy is roughly break-even on total work (construct + first-read), with the benefit of better GC behaviour during construction.

Eager baseline. I did not include eager numbers in this table because the benchmark file can only measure the currently built mobx — there's no in-tree way to flip back to eager. To compare against eager, check out a commit before this PR lands (git checkout main~), run npm run build && npm run perf-decorator, and compare. Earlier exploration runs on main (reported in this comment on #4639) showed ~224 MB construct heap and ~615 ms construct time for the same SPARSE shape on the eager path, so the directional savings are large — but I don't want to put unverifiable numbers in the PR table.

I also ran the full npm test suite (1043 passing, 14 skipped — same as main) and npm run test:types — both clean.

Test plan

  • npm test (1043 passing, 14 skipped — full suite green)
  • npm run test:types (clean)
  • npm run build + npm run perf-decorator (lazy-observable bench runs)
  • New regression tests in stage3-decorators.ts:
    • 4616 - @observable accessor should be lazy — asserts ObservableValue is not created until first read while isObservableProp still returns true, and an untouched field stays in lazyObservableKeys_.
    • 4616 - observe on @observable accessor before first read materialises it
    • 4616 - set on @observable accessor before first read materialises it
    • 4616 - autorun reacts to @observable accessor that is lazy on entry
  • Existing multiple inheritance should work test updated to union values_.keys() with lazyObservableKeys_.keys(), since inherited @observable fields now live in the lazy map until first read.

Mirrors the lazy `@computed` treatment from mobxjs#4639 for `@observable accessor`:
defers `ObservableValue` construction to the first read/write/observe of the
decorated accessor instead of allocating one per field at instance construction.
Closes the wide-class / sparse-access waste described in mobxjs#4616 for plain
observable fields (the original issue covered both decorators).

- `decorate_20223_` in `observableannotation.ts` registers a factory closure
  on `adm.lazyObservableKeys_` instead of building the `ObservableValue` and
  inserting it into `values_` from the accessor's `init` callback.
- `ObservableObjectAdministration.materializeLazyObservable_(key)` lazily
  instantiates the `ObservableValue` and inserts it into `values_` on demand.
  It is called from `getObservablePropValue_` / `setObservablePropValue_` (so
  the existing accessor get/set Just Works) and from `getAtom` (so
  `observe(o, key, ...)` materialises before reading).
- `isObservableProp` checks `lazyObservableKeys_` in addition to `values_` /
  `lazyComputedKeys_` so a decorated observable continues to report as
  observable before its first read.
- Hot path is unchanged: `values_.get(key)` is the single lookup once the
  field has been read for the first time; the materialise branch only runs
  the first time and for the rare pre-read `getAtom` / `observe` callers.
- The `initializedObjects` WeakSet workaround is gone — `init` now always
  registers the lazy factory, and `get` / `set` materialise via the admin.

Regression tests in `stage3-decorators.ts`:
  - `4616 - @observable accessor should be lazy` — asserts `ObservableValue`
    is not created until first read while `isObservableProp` still returns
    `true`, and an untouched field stays in `lazyObservableKeys_`.
  - `4616 - observe on @observable accessor before first read materialises it`
  - `4616 - set on @observable accessor before first read materialises it`
  - `4616 - autorun reacts to @observable accessor that is lazy on entry`

The existing `multiple inheritance should work` test asserted on
`values_.keys()` directly; updated to union with `lazyObservableKeys_` since
unread accessors now live there.
Standalone perf benchmark for the lazy `@observable accessor` change.
Mirrors `lazy-computed-decorator.ts`: 50k instances × 10 fields with two
read patterns:

  - SPARSE: 1 of 10 fields read per instance (best case for lazy)
  - FULL:   all 10 fields read per instance (worst case for lazy)

Compiled into `__tests__/perf/compiled/` alongside the @computed benchmark
via the existing `tsconfig.decorator.json`. Build with `npm run build` and
run with `npm run perf-decorator` (or directly `node --expose-gc
__tests__/perf/compiled/lazy-observable-decorator.js`).
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 29, 2026

🦋 Changeset detected

Latest commit: 055dc66

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

This PR includes changesets to release 1 package
Name Type
mobx Minor

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

@ashishkr96
Copy link
Copy Markdown
Contributor Author

Follow-up to #4639 per @mweststrate's review comment there:

The lazy observable work looks pretty intriguing as well, and better results than I expect (especially on the heap wins, although the Full lazy uses less heap than expected. Maybe it triggers GC more often which would then explain the much slower reads?). If you are willing to put up a separate PR for this, we can discuss more on it if we can find other opportunities to claw back some of the read regression, or conclude this is a worthwhile trade-off anyway.

This PR is that follow-up. It applies the same lazy treatment that #4639 shipped for @computed to @observable accessor:

  • lazyObservableKeys_ map on the admin + materializeLazyObservable_(key) mirror the @computed machinery.
  • getObservablePropValue_ / setObservablePropValue_ / getAtom / isObservableProp all consult the lazy map.
  • init always registers the lazy factory, so the old initializedObjects WeakSet workaround is gone.

Benchmark numbers (50k × 10 fields, sparse vs full reads) are in the PR body and in __tests__/perf/lazy-observable-decorator.ts. The sparse-access wins are large; the FULL-shape first-read regression is the trade-off worth discussing.

ashishkr96 and others added 3 commits May 29, 2026 20:17
Carry-over from the exploration version of this file:

- Header still described it as exploration estimating an "upper bound" under
  an eager decorator. The lazy change ships in this PR, so the file is now
  a regression benchmark for the shipped impl, not an estimate.
- A `Narrow` class (1 field) was being benchmarked but its output was never
  referenced by the summary — dead noise.
- The summary block printed "construct heap → unchanged" between independent
  SPARSE / FULL runs, overstating precision.
- "first-read 1× / 10×" labels in the summary were ambiguous (could read as
  repetitions rather than fields-per-instance).

Rewrites the header to match `lazy-computed-decorator.ts`'s framing,
collapses the two near-identical bench bodies into one parameterised
`bench(readsPerInstance)`, drops the unused `Narrow` class, drops the
misleading summary block, and adds a note that the file only measures the
currently built mobx — comparing against eager requires checking out a
pre-mobxjs#4658 commit and rebuilding.
@kubk
Copy link
Copy Markdown
Collaborator

kubk commented Jun 3, 2026

HI @ashishkr96 Thank you for the PR

i'm generally good with merging this! Had to push a small follow up before merging.

1 - removed the makeObservable() from the perf script as it was required for the legacy decorators, not stage-3 modern decorators
2 - wired the new observable benchmark with npm run perf-decorator
3 - cleared lazyObservableKeys_ / lazyComputedKeys_ when the last lazy entry materializes. This avoids retaining an empty map per instance. in the hydrated "all fields assigned" benchmark it removes the small heap regression I was seeing

@kubk kubk merged commit f0c6874 into mobxjs:main Jun 3, 2026
1 check passed
@github-actions github-actions Bot mentioned this pull request Jun 3, 2026
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