Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lazy-computed-decorator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"mobx": minor
---

feat(mobx): make the 2022.3 `@computed` decorator lazy. `ComputedValue` is now created on first read of the decorated getter rather than eagerly during instance construction, avoiding wasted allocations for getters that are never used. On a 50k-instance × 10-getter class with one read per instance this cuts construction heap by ~50% and construction time by ~25%; the steady-state read path is unchanged. Closes #4616.
3 changes: 2 additions & 1 deletion packages/mobx/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ README.md
LICENSE

temp
perf_report
perf_report
__tests__/perf/compiled
60 changes: 59 additions & 1 deletion packages/mobx/__tests__/decorators_20223/stage3-decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
runInAction,
makeObservable
} from "../../src/mobx"
import { type ObservableArrayAdministration } from "../../src/internal"
import { $mobx, type ObservableArrayAdministration } from "../../src/internal"
import * as mobx from "../../src/mobx"

const testFunction = function (a: any) {}
Expand Down Expand Up @@ -1150,6 +1150,64 @@ test("#2159 - computed property keys", () => {
])
})

test("4616 - @computed decorator should be lazy", () => {
let computeCount = 0

class Order {
@observable accessor price: number = 3

@computed
get unused() {
computeCount++
return this.price * 2
}

@computed
get used() {
return this.price * 3
}
}

const o = new Order()
// Sanity check via public API: both should report as observable props
t.equal(isObservableProp(o, "unused"), true)
t.equal(isObservableProp(o, "used"), true)

// Internal check: ComputedValue is not yet allocated for either getter
const adm: any = (o as any)[$mobx]
expect(adm.values_.has("unused")).toBe(false)
expect(adm.values_.has("used")).toBe(false)
expect(adm.lazyComputedKeys_.has("unused")).toBe(true)
expect(adm.lazyComputedKeys_.has("used")).toBe(true)
expect(computeCount).toBe(0)

// First access materialises the ComputedValue
t.equal(o.used, 9)
expect(adm.values_.has("used")).toBe(true)
expect(adm.lazyComputedKeys_.has("used")).toBe(false)

// The unused computed remains lazy and never ran
expect(adm.values_.has("unused")).toBe(false)
expect(computeCount).toBe(0)
})

test("4616 - observe on @computed before first read materialises it", () => {
class Order {
@observable accessor price: number = 3

@computed
get total() {
return this.price * 2
}
}

const o = new Order()
const events: number[] = []
observe(o, "total", ev => events.push((ev as any).newValue))
o.price = 4
t.deepEqual(events, [8])
})

test(`decorated field can be inherited, but doesn't inherite the effect of decorator`, () => {
class Store {
@action
Expand Down
152 changes: 152 additions & 0 deletions packages/mobx/__tests__/perf/lazy-computed-decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// Benchmark for the lazy `@computed` decorator (#4616 / #4639).
//
// Shape: many instances of a class with several `@computed` getters where only
// one getter is read per instance — the realistic "wide class, sparse access"
// pattern. Compares construction heap, construction time, first-read time, and
// re-read time. Run with `yarn perf-decorator` (requires a prior `yarn build`).

/* eslint-disable @typescript-eslint/no-require-imports */
import * as path from "path"
const distPath = path.resolve(__dirname, "..", "..", "..", "dist", "mobx.cjs.development.js")
const mobx = require(distPath) as {
computed: any
makeObservable: any
observable: any
}
const { computed, makeObservable, observable } = mobx

const INSTANCES = 50_000
const GETTERS_PER_INSTANCE = 10
const RUNS = 3
const RE_READS = 5

class Wide {
@observable accessor v = 1

@computed get c0() {
return this.v + 0
}
@computed get c1() {
return this.v + 1
}
@computed get c2() {
return this.v + 2
}
@computed get c3() {
return this.v + 3
}
@computed get c4() {
return this.v + 4
}
@computed get c5() {
return this.v + 5
}
@computed get c6() {
return this.v + 6
}
@computed get c7() {
return this.v + 7
}
@computed get c8() {
return this.v + 8
}
@computed get c9() {
return this.v + 9
}

constructor() {
makeObservable(this)
}
}

const GETTERS = ["c0", "c1", "c2", "c3", "c4", "c5", "c6", "c7", "c8", "c9"] as const
if (GETTERS.length !== GETTERS_PER_INSTANCE) throw new Error("getter list mismatch")

function forceGc() {
if (typeof global.gc === "function") global.gc()
}

function heapMB(): number {
return process.memoryUsage().heapUsed / (1024 * 1024)
}

type Sample = {
constructHeapMB: number
constructMs: number
firstReadMs: number
reReadMs: number
}

function bench(): Sample {
forceGc()
const heapBefore = heapMB()

const t0 = performance.now()
const instances: Wide[] = new Array(INSTANCES)
for (let i = 0; i < INSTANCES; i++) instances[i] = new Wide()
const t1 = performance.now()

forceGc()
const heapAfter = heapMB()

const t2 = performance.now()
let sink = 0
for (let i = 0; i < INSTANCES; i++) {
sink += (instances[i] as any)[GETTERS[i % GETTERS_PER_INSTANCE]]
}
const t3 = performance.now()

const t4 = performance.now()
for (let r = 0; r < RE_READS; r++) {
for (let i = 0; i < INSTANCES; i++) {
sink += (instances[i] as any)[GETTERS[i % GETTERS_PER_INSTANCE]]
}
}
const t5 = performance.now()

if (sink === Number.NEGATIVE_INFINITY) console.log("unreachable")

return {
constructHeapMB: heapAfter - heapBefore,
constructMs: t1 - t0,
firstReadMs: t3 - t2,
reReadMs: t5 - t4
}
}

function fmt(n: number, digits = 1): string {
return n.toFixed(digits).padStart(8)
}

function main() {
console.log(
`\nLazy @computed decorator benchmark — ${INSTANCES} instances × ` +
`${GETTERS_PER_INSTANCE} @computed getters, 1 read/instance, ${RE_READS} re-reads.`
)
console.log(`Node ${process.version}, ${RUNS} timed runs after 1 warmup.\n`)

bench() // warmup

const samples: Sample[] = []
for (let r = 0; r < RUNS; r++) samples.push(bench())

console.log("run | construct heap MB | construct ms | first-read ms | re-read ms")
console.log("----+-------------------+--------------+---------------+-----------")
samples.forEach((s, i) => {
console.log(
` ${i + 1} | ${fmt(s.constructHeapMB)} | ${fmt(s.constructMs)} | ${fmt(
s.firstReadMs
)} | ${fmt(s.reReadMs)}`
)
})

const avg = (pick: (s: Sample) => number) =>
samples.reduce((a, s) => a + pick(s), 0) / samples.length
console.log(
`avg | ${fmt(avg(s => s.constructHeapMB))} | ${fmt(
avg(s => s.constructMs)
)} | ${fmt(avg(s => s.firstReadMs))} | ${fmt(avg(s => s.reReadMs))}`
)
}

main()
15 changes: 15 additions & 0 deletions packages/mobx/__tests__/perf/tsconfig.decorator.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"useDefineForClassFields": true,
"experimentalDecorators": false,
"esModuleInterop": true,
"strict": false,
"skipLibCheck": true,
"outDir": "./compiled",
"rootDir": "./"
},
"include": ["./lazy-computed-decorator.ts"]
}
1 change: 1 addition & 0 deletions packages/mobx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"perf": "scripts/perf.sh",
"perf-legacy": "node --expose-gc ./__tests__/perf/index.js legacy",
"perf-proxy": "node --expose-gc ./__tests__/perf/index.js proxy",
"perf-decorator": "tsc -p ./__tests__/perf/tsconfig.decorator.json && node --expose-gc ./__tests__/perf/compiled/lazy-computed-decorator.js",
"test:performance": "yarn perf proxy && yarn perf legacy",
"test:mixed-versions": "yarn test --testRegex mixed-versions",
"test:types": "tsc --noEmit",
Expand Down
3 changes: 2 additions & 1 deletion packages/mobx/src/api/isobservable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ function _isObservable(value, property?: PropertyKey): boolean {
)
}
if (isObservableObject(value)) {
return value[$mobx].values_.has(property)
const adm = value[$mobx]
return adm.values_.has(property) || !!adm.lazyComputedKeys_?.has(property)
}
return false
}
Expand Down
24 changes: 15 additions & 9 deletions packages/mobx/src/types/computedannotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,23 @@ function decorate_20223_(this: Annotation, get, context: ClassGetterDecoratorCon
const ann = this
const { name: key, addInitializer } = context

// Defer ComputedValue creation until first access — avoids allocating
// ComputedValues for getters that are never read on a given instance.
// The factory is materialised by ObservableObjectAdministration on demand.
addInitializer(function () {
const adm: ObservableObjectAdministration = asObservableObject(this)[$mobx]
const options = {
...ann.options_,
get,
context: this
}
options.name ||= __DEV__
? `${adm.name_}.${key.toString()}`
: `ObservableObject.${key.toString()}`
adm.values_.set(key, new ComputedValue(options))
const target = this
;(adm.lazyComputedKeys_ ??= new Map()).set(key, () => {
const options = {
...ann.options_,
get,
context: target
}
options.name ||= __DEV__
? `${adm.name_}.${key.toString()}`
: `ObservableObject.${key.toString()}`
return new ComputedValue(options)
})
})

return function () {
Expand Down
18 changes: 16 additions & 2 deletions packages/mobx/src/types/observableobject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export class ObservableObjectAdministration
isPlainObject_: boolean
appliedAnnotations_?: object
private pendingKeys_: undefined | Map<PropertyKey, ObservableValue<boolean>>
lazyComputedKeys_: undefined | Map<PropertyKey, () => ComputedValue<any>>

constructor(
public target_: any,
Expand All @@ -119,11 +120,24 @@ export class ObservableObjectAdministration
}

getObservablePropValue_(key: PropertyKey): any {
return this.values_.get(key)!.get()
// Hot path: single map lookup. Lazy computeds (rare) take the materialise branch.
const observable = this.values_.get(key) ?? this.materializeLazyComputed_(key)
return observable!.get()
}

materializeLazyComputed_(key: PropertyKey): ComputedValue<any> | undefined {
const factory = this.lazyComputedKeys_?.get(key)
if (!factory) {
return undefined
}
this.lazyComputedKeys_!.delete(key)
const computed = factory()
this.values_.set(key, computed)
return computed
}

setObservablePropValue_(key: PropertyKey, newValue): boolean | null {
const observable = this.values_.get(key)
const observable = this.values_.get(key) ?? this.materializeLazyComputed_(key)
if (observable instanceof ComputedValue) {
observable.set(newValue)
return true
Expand Down
3 changes: 2 additions & 1 deletion packages/mobx/src/types/type-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ export function getAtom(thing: any, property?: PropertyKey): IDepTreeNode {
if (!property) {
return die(26)
}
const observable = (thing as any)[$mobx].values_.get(property)
const adm = (thing as any)[$mobx]
const observable = adm.values_.get(property) ?? adm.materializeLazyComputed_(property)
if (!observable) {
die(27, property, getDebugName(thing))
}
Expand Down