feat(mobx): make 2022.3 @observable decorator lazy#4658
Conversation
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 detectedLatest commit: 055dc66 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
|
Follow-up to #4639 per @mweststrate's review comment there:
This PR is that follow-up. It applies the same lazy treatment that #4639 shipped for
Benchmark numbers (50k × 10 fields, sparse vs full reads) are in the PR body and in |
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.
|
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 |
Summary
Follow-up to #4639 per maintainer request. Applies the same lazy treatment to the 2022.3
@observable accessordecorator that #4639 applied to@computed.Today the accessor decorator's
initcallback eagerly constructs anObservableValuefor 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
ObservableValuecreation to the first read/write/observe of the decorated accessor:decorate_20223_inobservableannotation.tsregisters a factory closure on the admin's newlazyObservableKeys_map from the accessor'sinitcallback, instead of building theObservableValueand inserting it intovalues_upfront. TheinitializedObjectsWeakSet workaround is gone —initnow always registers the lazy factory.ObservableObjectAdministration.materializeLazyObservable_(key)lazily instantiates theObservableValueand inserts it intovalues_on demand. It is called fromgetObservablePropValue_/setObservablePropValue_(so the existing accessor get/set Just Works) and fromgetAtom(soobserve(o, key, ...)materialises before reading).isObservablePropcheckslazyObservableKeys_in addition tovalues_/lazyComputedKeys_so a decorated observable continues to report as observable before its first read.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-readgetAtom/observecallers.The legacy
makeObservable-based path (defineObservableProperty_) is unchanged.Benchmarks (this PR, lazy)
50k-instance × 10-field
Wideclass, Linux Node 24,--expose-gc, 1 warmup + 3 timed runs, averaged. Reproduce withnpm run build && npm run perf-decorator:Two things to read out of this:
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~), runnpm run build && npm run perf-decorator, and compare. Earlier exploration runs onmain(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 testsuite (1043 passing, 14 skipped — same asmain) andnpm 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)stage3-decorators.ts:4616 - @observable accessor should be lazy— assertsObservableValueis not created until first read whileisObservablePropstill returnstrue, and an untouched field stays inlazyObservableKeys_.4616 - observe on @observable accessor before first read materialises it4616 - set on @observable accessor before first read materialises it4616 - autorun reacts to @observable accessor that is lazy on entrymultiple inheritance should worktest updated to unionvalues_.keys()withlazyObservableKeys_.keys(), since inherited @observable fields now live in the lazy map until first read.