diff --git a/.changeset/afraid-cooks-nail.md b/.changeset/afraid-cooks-nail.md new file mode 100644 index 0000000000..6abcf0a089 --- /dev/null +++ b/.changeset/afraid-cooks-nail.md @@ -0,0 +1,5 @@ +--- +"mobx": minor +--- + +Add 2022.3 Decorators support diff --git a/jest.base.config.js b/jest.base.config.js index acd363122b..a63edb2489 100644 --- a/jest.base.config.js +++ b/jest.base.config.js @@ -1,9 +1,11 @@ const fs = require("fs") const path = require("path") -const tsConfig = "tsconfig.test.json" - -module.exports = function buildConfig(packageDirectory, pkgConfig) { +module.exports = function buildConfig( + packageDirectory, + pkgConfig, + tsConfig = "tsconfig.test.json" +) { const packageName = require(`${packageDirectory}/package.json`).name const packageTsconfig = path.resolve(packageDirectory, tsConfig) return { diff --git a/jest.config.js b/jest.config.js index fb1ffb1f18..e40ab53e21 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,6 @@ const buildConfig = require("./jest.base.config") module.exports = buildConfig(__dirname, { - projects: ["/packages/*/jest.config.js"] + projects: ["/packages/*/jest.config.js", "/packages/*/jest.config-*.js"] // collectCoverageFrom: ["/packages/*/src/**/*.{ts,tsx}"] }) diff --git a/package.json b/package.json index 20de1105f1..9671ed58f8 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "packages/*" ], "resolutions": { - "typescript": "^4.0.2", + "typescript": "^5.0.0-beta", "recast": "^0.23.1" }, "repository": { @@ -57,7 +57,7 @@ "lodash": "^4.17.4", "minimist": "^1.2.5", "mkdirp": "1.0.4", - "prettier": "^2.0.5", + "prettier": "^2.8.4", "pretty-quick": "3.1.0", "prop-types": "15.6.2", "react": "^18.0.0", @@ -67,7 +67,7 @@ "tape": "^5.0.1", "ts-jest": "26.4.1", "tsdx": "^0.14.1", - "typescript": "^4.0.2" + "typescript": "^5.0.0-beta" }, "husky": { "hooks": { diff --git a/packages/mobx/__tests__/decorators_20223/stage3-decorators.ts b/packages/mobx/__tests__/decorators_20223/stage3-decorators.ts new file mode 100644 index 0000000000..c720dacce8 --- /dev/null +++ b/packages/mobx/__tests__/decorators_20223/stage3-decorators.ts @@ -0,0 +1,1185 @@ +"use strict" + +import { + observe, + computed, + observable, + autorun, + extendObservable, + action, + IObservableArray, + IArrayWillChange, + IArrayWillSplice, + IObservableValue, + isObservable, + isObservableProp, + isObservableObject, + transaction, + IObjectDidChange, + spy, + configure, + isAction, + IAtom, + createAtom, + runInAction, + makeObservable +} from "../../src/mobx" +import * as mobx from "../../src/mobx" + +const testFunction = function (a: any) {} + +// lazy wrapper around yest + +const t = { + equal(a: any, b: any) { + expect(a).toBe(b) + }, + deepEqual(a: any, b: any) { + expect(a).toEqual(b) + }, + notEqual(a: any, b: any) { + expect(a).not.toEqual(b) + }, + + throws(a: any, b: any) { + expect(a).toThrow(b) + } +} + +test("decorators", () => { + class Order { + @observable accessor price: number = 3 + @observable accessor amount: number = 2 + @observable accessor orders: string[] = [] + @observable accessor aFunction = testFunction + + @computed + get total() { + return this.amount * this.price * (1 + this.orders.length) + } + + constructor() { + makeObservable(this) + } + } + + const o = new Order() + t.equal(isObservableObject(o), true) + t.equal(isObservableProp(o, "amount"), true) + t.equal(isObservableProp(o, "total"), true) + + const events: any[] = [] + const d1 = observe(o, (ev: IObjectDidChange) => events.push(ev.name, (ev as any).oldValue)) + const d2 = observe(o, "price", ev => events.push(ev.newValue, ev.oldValue)) + const d3 = observe(o, "total", ev => events.push(ev.newValue, ev.oldValue)) + + o.price = 4 + + d1() + d2() + d3() + + o.price = 5 + + t.deepEqual(events, [ + 8, // new total + 6, // old total + 4, // new price + 3, // old price + "price", // event name + 3 // event oldValue + ]) +}) + +test("annotations", () => { + const fn0 = () => 0 + class Order { + @observable accessor price: number = 3 + @observable accessor amount: number = 2 + @observable accessor orders: string[] = [] + @observable accessor aFunction = fn0 + + @computed + get total() { + return this.amount * this.price * (1 + this.orders.length) + } + + constructor() { + makeObservable(this) + } + } + + const order1totals: number[] = [] + const order1 = new Order() + const order2 = new Order() + + const disposer = autorun(() => { + order1totals.push(order1.total) + }) + + order2.price = 4 + order1.amount = 1 + + t.equal(order1.price, 3) + t.equal(order1.total, 3) + t.equal(order2.total, 8) + order2.orders.push("bla") + t.equal(order2.total, 16) + + order1.orders.splice(0, 0, "boe", "hoi") + t.deepEqual(order1totals, [6, 3, 9]) + + disposer() + order1.orders.pop() + t.equal(order1.total, 6) + t.deepEqual(order1totals, [6, 3, 9]) + expect(isAction(order1.aFunction)).toBe(true) + expect(order1.aFunction()).toBe(0) + order1.aFunction = () => 1 + expect(isAction(order1.aFunction)).toBe(true) + expect(order1.aFunction()).toBe(1) +}) + +test("box", () => { + class Box { + @observable accessor uninitialized: any + @observable accessor height = 20 + @observable accessor sizes = [2] + @observable accessor someFunc = function () { + return 2 + } + @computed + get width() { + return this.height * this.sizes.length * this.someFunc() * (this.uninitialized ? 2 : 1) + } + @action("test") + addSize() { + this.sizes.push(3) + this.sizes.push(4) + } + + constructor() { + makeObservable(this) + } + } + + const box = new Box() + + const ar: number[] = [] + + autorun(() => { + ar.push(box.width) + }) + + t.deepEqual(ar.slice(), [40]) + box.height = 10 + t.deepEqual(ar.slice(), [40, 20]) + box.sizes.push(3, 4) + t.deepEqual(ar.slice(), [40, 20, 60]) + box.someFunc = () => 7 + t.deepEqual(ar.slice(), [40, 20, 60, 210]) + box.uninitialized = true + t.deepEqual(ar.slice(), [40, 20, 60, 210, 420]) + box.addSize() + expect(ar.slice()).toEqual([40, 20, 60, 210, 420, 700]) +}) + +test("computed setter should succeed", () => { + class Bla { + @observable accessor a = 3 + @computed + get propX() { + return this.a * 2 + } + set propX(v) { + this.a = v + } + + constructor() { + makeObservable(this) + } + } + + const b = new Bla() + t.equal(b.propX, 6) + b.propX = 4 + t.equal(b.propX, 8) +}) + +test("typescript: parameterized computed decorator", () => { + class TestClass { + @observable accessor x = 3 + @observable accessor y = 3 + @computed.struct + get boxedSum() { + return { sum: Math.round(this.x) + Math.round(this.y) } + } + constructor() { + makeObservable(this) + } + } + + const t1 = new TestClass() + const changes: { sum: number }[] = [] + const d = autorun(() => changes.push(t1.boxedSum)) + + t1.y = 4 // change + t.equal(changes.length, 2) + t1.y = 4.2 // no change + t.equal(changes.length, 2) + transaction(() => { + t1.y = 3 + t1.x = 4 + }) // no change + t.equal(changes.length, 2) + t1.x = 6 // change + t.equal(changes.length, 3) + d() + + t.deepEqual(changes, [{ sum: 6 }, { sum: 7 }, { sum: 9 }]) +}) + +test("issue 165", () => { + function report(msg: string, value: T) { + // console.log(msg, ":", value) + return value + } + + class Card { + constructor(public game: Game, public id: number) { + makeObservable(this) + } + + @computed + get isWrong() { + return report( + "Computing isWrong for card " + this.id, + this.isSelected && this.game.isMatchWrong + ) + } + + @computed + get isSelected() { + return report( + "Computing isSelected for card" + this.id, + this.game.firstCardSelected === this || this.game.secondCardSelected === this + ) + } + } + + class Game { + @observable accessor firstCardSelected: Card | null = null + @observable accessor secondCardSelected: Card | null = null + + @computed + get isMatchWrong() { + return report( + "Computing isMatchWrong", + this.secondCardSelected !== null && + this.firstCardSelected!.id !== this.secondCardSelected.id + ) + } + + constructor() { + makeObservable(this) + } + } + + let game = new Game() + let card1 = new Card(game, 1), + card2 = new Card(game, 2) + + autorun(() => { + card1.isWrong + card2.isWrong + // console.log("card1.isWrong =", card1.isWrong) + // console.log("card2.isWrong =", card2.isWrong) + // console.log("------------------------------") + }) + + // console.log("Selecting first card") + game.firstCardSelected = card1 + // console.log("Selecting second card") + game.secondCardSelected = card2 + + t.equal(card1.isWrong, true) + t.equal(card2.isWrong, true) +}) + +test("issue 191 - shared initializers (ts)", () => { + class Test { + @observable accessor obj = { a: 1 } + @observable accessor array = [2] + constructor() { + makeObservable(this) + } + } + + const t1 = new Test() + t1.obj.a = 2 + t1.array.push(3) + + const t2 = new Test() + t2.obj.a = 3 + t2.array.push(4) + + t.notEqual(t1.obj, t2.obj) + t.notEqual(t1.array, t2.array) + t.equal(t1.obj.a, 2) + t.equal(t2.obj.a, 3) + + t.deepEqual(t1.array.slice(), [2, 3]) + t.deepEqual(t2.array.slice(), [2, 4]) +}) + +function normalizeSpyEvents(events: any[]) { + events.forEach(ev => { + delete ev.fn + delete ev.time + }) + return events +} + +test("action decorator (typescript)", () => { + class Store { + constructor(private multiplier: number) { + makeObservable(this) + } + + @action + add(a: number, b: number): number { + return (a + b) * this.multiplier + } + } + + const store1 = new Store(2) + const store2 = new Store(3) + const events: any[] = [] + const d = spy(events.push.bind(events)) + t.equal(store1.add(3, 4), 14) + t.equal(store2.add(2, 2), 12) + t.equal(store1.add(1, 1), 4) + + t.deepEqual(normalizeSpyEvents(events), [ + { arguments: [3, 4], name: "add", spyReportStart: true, object: store1, type: "action" }, + { type: "report-end", spyReportEnd: true }, + { arguments: [2, 2], name: "add", spyReportStart: true, object: store2, type: "action" }, + { type: "report-end", spyReportEnd: true }, + { arguments: [1, 1], name: "add", spyReportStart: true, object: store1, type: "action" }, + { type: "report-end", spyReportEnd: true } + ]) + + d() +}) + +test("custom action decorator (typescript)", () => { + class Store { + constructor(private multiplier: number) { + makeObservable(this) + } + + @action("zoem zoem") + add(a: number, b: number): number { + return (a + b) * this.multiplier + } + } + + const store1 = new Store(2) + const store2 = new Store(3) + const events: any[] = [] + const d = spy(events.push.bind(events)) + t.equal(store1.add(3, 4), 14) + t.equal(store2.add(2, 2), 12) + t.equal(store1.add(1, 1), 4) + + t.deepEqual(normalizeSpyEvents(events), [ + { + arguments: [3, 4], + name: "zoem zoem", + spyReportStart: true, + object: store1, + type: "action" + }, + { type: "report-end", spyReportEnd: true }, + { + arguments: [2, 2], + name: "zoem zoem", + spyReportStart: true, + object: store2, + type: "action" + }, + { type: "report-end", spyReportEnd: true }, + { + arguments: [1, 1], + name: "zoem zoem", + spyReportStart: true, + object: store1, + type: "action" + }, + { type: "report-end", spyReportEnd: true } + ]) + + d() +}) + +test("action decorator on field (typescript)", () => { + class Store { + constructor(private multiplier: number) { + makeObservable(this) + } + + @action + accessor add = (a: number, b: number) => { + return (a + b) * this.multiplier + } + } + + const store1 = new Store(2) + const store2 = new Store(7) + expect(store1.add).not.toEqual(store2.add) + + const events: any[] = [] + const d = spy(events.push.bind(events)) + t.equal(store1.add(3, 4), 14) + t.equal(store2.add(4, 5), 63) + t.equal(store1.add(2, 2), 8) + + t.deepEqual(normalizeSpyEvents(events), [ + { arguments: [3, 4], name: "add", spyReportStart: true, object: store1, type: "action" }, + { type: "report-end", spyReportEnd: true }, + { arguments: [4, 5], name: "add", spyReportStart: true, object: store2, type: "action" }, + { type: "report-end", spyReportEnd: true }, + { arguments: [2, 2], name: "add", spyReportStart: true, object: store1, type: "action" }, + { type: "report-end", spyReportEnd: true } + ]) + + d() +}) + +test("custom action decorator on field (typescript)", () => { + class Store { + constructor(private multiplier: number) { + makeObservable(this) + } + + @action("zoem zoem") + accessor add = (a: number, b: number) => { + return (a + b) * this.multiplier + } + } + + const store1 = new Store(2) + const store2 = new Store(7) + + const events: any[] = [] + const d = spy(events.push.bind(events)) + t.equal(store1.add(3, 4), 14) + t.equal(store2.add(4, 5), 63) + t.equal(store1.add(2, 2), 8) + + t.deepEqual(normalizeSpyEvents(events), [ + { + arguments: [3, 4], + name: "zoem zoem", + spyReportStart: true, + object: store1, + type: "action" + }, + { type: "report-end", spyReportEnd: true }, + { + arguments: [4, 5], + name: "zoem zoem", + spyReportStart: true, + object: store2, + type: "action" + }, + { type: "report-end", spyReportEnd: true }, + { + arguments: [2, 2], + name: "zoem zoem", + spyReportStart: true, + object: store1, + type: "action" + }, + { type: "report-end", spyReportEnd: true } + ]) + + d() +}) + +test("267 (typescript) should be possible to declare properties observable outside strict mode", () => { + configure({ enforceActions: "observed" }) + + class Store { + @observable accessor timer: number | null = null + + constructor() { + makeObservable(this) + } + } + + configure({ enforceActions: "never" }) +}) + +test("288 atom not detected for object property", () => { + class Store { + @observable accessor foo = "" + + constructor() { + makeObservable(this) + } + } + + const store = new Store() + + mobx.observe( + store, + "foo", + () => { + // console.log("Change observed") + }, + true + ) +}) + +test.skip("observable performance - ts - decorators", () => { + const AMOUNT = 100000 + + class A { + @observable accessor a = 1 + @observable accessor b = 2 + @observable accessor c = 3 + @computed + get d() { + return this.a + this.b + this.c + } + constructor() { + makeObservable(this) + } + } + + const objs: any[] = [] + const start = Date.now() + + for (let i = 0; i < AMOUNT; i++) objs.push(new A()) + + console.log("created in ", Date.now() - start) + + for (let j = 0; j < 4; j++) { + for (let i = 0; i < AMOUNT; i++) { + const obj = objs[i] + obj.a += 3 + obj.b *= 4 + obj.c = obj.b - obj.a + obj.d + } + } + + console.log("changed in ", Date.now() - start) +}) + +test("unbound methods", () => { + class A { + // shared across all instances + @action + m1() {} + + // per instance + @action accessor m2 = () => {} + constructor() { + makeObservable(this) + } + } + + const a1 = new A() + const a2 = new A() + + t.equal(a1.m1, a2.m1) + t.notEqual(a1.m2, a2.m2) + t.equal(Object.hasOwnProperty.call(a1, "m1"), false) + t.equal(Object.hasOwnProperty.call(a1, "m2"), true) + t.equal(Object.hasOwnProperty.call(a2, "m1"), false) + t.equal(Object.hasOwnProperty.call(a2, "m2"), true) +}) + +test("inheritance", () => { + class A { + @observable accessor a = 2 + constructor() { + makeObservable(this) + } + } + + class B extends A { + @observable accessor b = 3 + @computed + get c() { + return this.a + this.b + } + constructor() { + super() + makeObservable(this) + } + } + const b1 = new B() + const b2 = new B() + const values: any[] = [] + mobx.autorun(() => values.push(b1.c + b2.c)) + + b1.a = 3 + b1.b = 4 + b2.b = 5 + b2.a = 6 + + t.deepEqual(values, [10, 11, 12, 14, 18]) +}) + +test("inheritance overrides observable", () => { + class A { + @observable accessor a = 2 + constructor() { + makeObservable(this) + } + } + + class B { + @observable accessor a = 5 + @observable accessor b = 3 + @computed + get c() { + return this.a + this.b + } + constructor() { + makeObservable(this) + } + } + + const b1 = new B() + const b2 = new B() + const values: any[] = [] + mobx.autorun(() => values.push(b1.c + b2.c)) + + b1.a = 3 + b1.b = 4 + b2.b = 5 + b2.a = 6 + + t.deepEqual(values, [16, 14, 15, 17, 18]) +}) + +test("reusing initializers", () => { + class A { + @observable accessor a = 3 + @observable accessor b = this.a + 2 + @computed + get c() { + return this.a + this.b + } + @computed + get d() { + return this.c + 1 + } + constructor() { + makeObservable(this) + } + } + + const a = new A() + const values: any[] = [] + mobx.autorun(() => values.push(a.d)) + + a.a = 4 + t.deepEqual(values, [9, 10]) +}) + +test("enumerability", () => { + class A { + @observable accessor a = 1 // enumerable, on proto + @computed + get b() { + return this.a + } // non-enumerable, (and, ideally, on proto) + @action + m() {} // non-enumerable, on proto + @action accessor m2 = () => {} // non-enumerable, on self + constructor() { + makeObservable(this) + } + } + + const a = new A() + + // not initialized yet + let ownProps = Object.keys(a) + let props: string[] = [] + for (const key in a) props.push(key) + + t.deepEqual(ownProps, [ + "a" // yeej! + ]) + + t.deepEqual(props, [ + // also 'a' would be ok + "a" + ]) + + t.equal("a" in a, true) + // eslint-disable-next-line + t.equal(a.hasOwnProperty("a"), true) + // eslint-disable-next-line + t.equal(a.hasOwnProperty("b"), false) + // eslint-disable-next-line + t.equal(a.hasOwnProperty("m"), false) + // eslint-disable-next-line + t.equal(a.hasOwnProperty("m2"), true) + + t.equal(mobx.isAction(a.m), true) + t.equal(mobx.isAction(a.m2), true) + + // after initialization + a.a + a.b + a.m + a.m2 + + ownProps = Object.keys(a) + props = [] + for (const key in a) props.push(key) + + t.deepEqual(ownProps, ["a"]) + + t.deepEqual(props, ["a"]) + + t.equal("a" in a, true) + // eslint-disable-next-line + t.equal(a.hasOwnProperty("a"), true) + // eslint-disable-next-line + t.equal(a.hasOwnProperty("b"), false) + // eslint-disable-next-line + t.equal(a.hasOwnProperty("m"), false) + // eslint-disable-next-line + t.equal(a.hasOwnProperty("m2"), true) +}) + +test("issue 285 (typescript)", () => { + const { observable, toJS } = mobx + + class Todo { + id = 1 + @observable accessor title: string + @observable accessor finished = false + @observable accessor childThings = [1, 2, 3] + constructor(title: string) { + makeObservable(this) + this.title = title + } + } + + const todo = new Todo("Something to do") + + t.deepEqual(toJS(todo), { + id: 1, + title: "Something to do", + finished: false, + childThings: [1, 2, 3] + }) +}) + +test("verify object assign (typescript)", () => { + class Todo { + @observable accessor title = "test" + @computed + get upperCase() { + return this.title.toUpperCase() + } + constructor() { + makeObservable(this) + } + } + + t.deepEqual((Object as any).assign({}, new Todo()), { + title: "test" + }) +}) + +test("373 - fix isObservable for unused computed", () => { + class Bla { + @computed + get computedVal() { + return 3 + } + constructor() { + makeObservable(this) + t.equal(isObservableProp(this, "computedVal"), true) + this.computedVal + t.equal(isObservableProp(this, "computedVal"), true) + } + } + + new Bla() +}) + +test("705 - setter undoing caching (typescript)", () => { + let recomputes = 0 + let autoruns = 0 + + class Person { + @observable accessor name: string = "" + @observable accessor title: string = "" + + // Typescript bug: if fullName is before the getter, the property is defined twice / incorrectly, see #705 + // set fullName(val) { + // // Noop + // } + @computed + get fullName() { + recomputes++ + return this.title + " " + this.name + } + // Should also be possible to define the setter _before_ the fullname + set fullName(val) { + // Noop + } + constructor() { + makeObservable(this) + } + } + + let p1 = new Person() + p1.name = "Tom Tank" + p1.title = "Mr." + + t.equal(recomputes, 0) + t.equal(autoruns, 0) + + const d1 = autorun(() => { + autoruns++ + p1.fullName + }) + + const d2 = autorun(() => { + autoruns++ + p1.fullName + }) + + t.equal(recomputes, 1) + t.equal(autoruns, 2) + + p1.title = "Master" + t.equal(recomputes, 2) + t.equal(autoruns, 4) + + d1() + d2() +}) + +test("@observable.ref (TS)", () => { + class A { + @observable.ref accessor ref = { a: 3 } + constructor() { + makeObservable(this) + } + } + + const a = new A() + t.equal(a.ref.a, 3) + t.equal(mobx.isObservable(a.ref), false) + t.equal(mobx.isObservableProp(a, "ref"), true) +}) + +test("@observable.shallow (TS)", () => { + class A { + @observable.shallow accessor arr = [{ todo: 1 }] + constructor() { + makeObservable(this) + } + } + + const a = new A() + const todo2 = { todo: 2 } + a.arr.push(todo2) + t.equal(mobx.isObservable(a.arr), true) + t.equal(mobx.isObservableProp(a, "arr"), true) + t.equal(mobx.isObservable(a.arr[0]), false) + t.equal(mobx.isObservable(a.arr[1]), false) + t.equal(a.arr[1] === todo2, true) +}) + +test("@observable.shallow - 2 (TS)", () => { + class A { + @observable.shallow accessor arr: Record = { x: { todo: 1 } } + constructor() { + makeObservable(this) + } + } + + const a = new A() + const todo2 = { todo: 2 } + a.arr.y = todo2 + t.equal(mobx.isObservable(a.arr), true) + t.equal(mobx.isObservableProp(a, "arr"), true) + t.equal(mobx.isObservable(a.arr.x), false) + t.equal(mobx.isObservable(a.arr.y), false) + t.equal(a.arr.y === todo2, true) +}) + +test("@observable.deep (TS)", () => { + class A { + @observable.deep accessor arr = [{ todo: 1 }] + constructor() { + makeObservable(this) + } + } + + const a = new A() + const todo2 = { todo: 2 } + a.arr.push(todo2) + + t.equal(mobx.isObservable(a.arr), true) + t.equal(mobx.isObservableProp(a, "arr"), true) + t.equal(mobx.isObservable(a.arr[0]), true) + t.equal(mobx.isObservable(a.arr[1]), true) + t.equal(a.arr[1] !== todo2, true) + t.equal(isObservable(todo2), false) +}) + +test("action.bound binds (TS)", () => { + class A { + @observable accessor x = 0 + @action.bound + inc(value: number) { + this.x += value + } + constructor() { + makeObservable(this) + } + } + + const a = new A() + const runner = a.inc + runner(2) + + t.equal(a.x, 2) +}) + +test("@computed.equals (TS)", () => { + const sameTime = (from: Time, to: Time) => from.hour === to.hour && from.minute === to.minute + class Time { + constructor(hour: number, minute: number) { + makeObservable(this) + this.hour = hour + this.minute = minute + } + + @observable public accessor hour: number + @observable public accessor minute: number + + @computed({ equals: sameTime }) + public get time() { + return { hour: this.hour, minute: this.minute } + } + } + const time = new Time(9, 0) + + const changes: Array<{ hour: number; minute: number }> = [] + const disposeAutorun = autorun(() => changes.push(time.time)) + + t.deepEqual(changes, [{ hour: 9, minute: 0 }]) + time.hour = 9 + t.deepEqual(changes, [{ hour: 9, minute: 0 }]) + time.minute = 0 + t.deepEqual(changes, [{ hour: 9, minute: 0 }]) + time.hour = 10 + t.deepEqual(changes, [ + { hour: 9, minute: 0 }, + { hour: 10, minute: 0 } + ]) + time.minute = 30 + t.deepEqual(changes, [ + { hour: 9, minute: 0 }, + { hour: 10, minute: 0 }, + { hour: 10, minute: 30 } + ]) + + disposeAutorun() +}) + +test("1072 - @observable accessor without initial value and observe before first access", () => { + class User { + @observable accessor loginCount: number = 0 + + constructor() { + makeObservable(this) + } + } + + const user = new User() + observe(user, "loginCount", () => {}) +}) + +test("unobserved computed reads should warn with requiresReaction enabled", () => { + const consoleWarn = console.warn + const warnings: string[] = [] + console.warn = function (...args) { + warnings.push(...args) + } + try { + class A { + @observable accessor x = 0 + + @computed({ requiresReaction: true }) + get y() { + return this.x * 2 + } + } + + const a = new A() + + a.y + const d = mobx.reaction( + () => a.y, + () => {} + ) + a.y + d() + a.y + + expect(warnings.length).toEqual(2) + expect(warnings[0]).toContain( + "is being read outside a reactive context. Doing a full recompute." + ) + expect(warnings[1]).toContain( + "is being read outside a reactive context. Doing a full recompute." + ) + } finally { + console.warn = consoleWarn + } +}) + +test("multiple inheritance should work", () => { + class A { + @observable accessor x = 1 + + constructor() { + makeObservable(this) + } + } + + class B extends A { + @observable accessor y = 1 + + constructor() { + super() + makeObservable(this) + } + } + + expect(mobx.keys(new B())).toEqual(["x", "y"]) +}) + +// 19.12.2020 @urugator: +// All annotated non-observable fields are not writable. +// All annotated fields of non-plain objects are non-configurable. +// https://github.com/mobxjs/mobx/pull/2641 +test.skip("actions are reassignable", () => { + // See #1398 and #1545, make actions reassignable to support stubbing + class A { + @action + m1() {} + @action accessor m2 = () => {} + @action.bound + m3() {} + @action.bound accessor m4 = () => {} + + constructor() { + makeObservable(this) + } + } + + const a = new A() + expect(isAction(a.m1)).toBe(true) + expect(isAction(a.m2)).toBe(true) + expect(isAction(a.m3)).toBe(true) + expect(isAction(a.m4)).toBe(true) + a.m1 = () => {} + expect(isAction(a.m1)).toBe(false) + a.m2 = () => {} + expect(isAction(a.m2)).toBe(false) + a.m3 = () => {} + expect(isAction(a.m3)).toBe(false) + a.m4 = () => {} + expect(isAction(a.m4)).toBe(false) +}) + +test("it should support asyncAction as decorator (ts)", async () => { + mobx.configure({ enforceActions: "observed" }) + + class X { + @observable accessor a = 1 + + f = mobx.flow(function* f(this: X, initial: number) { + this.a = initial // this runs in action + this.a += yield Promise.resolve(5) as any + this.a = this.a * 2 + return this.a + }) + + constructor() { + makeObservable(this) + } + } + + const x = new X() + + expect(await x.f(3)).toBe(16) +}) + +test("toJS bug #1413 (TS)", () => { + class X { + @observable + accessor test = { + test1: 1 + } + + constructor() { + makeObservable(this) + } + } + + const x = new X() + const res = mobx.toJS(x.test) as any + expect(res).toEqual({ test1: 1 }) + expect(res.__mobxDidRunLazyInitializers).toBe(undefined) +}) + +test("#2159 - computed property keys", () => { + const testSymbol = Symbol("test symbol") + const testString = "testString" + + class TestClass { + @observable accessor [testSymbol] = "original symbol value" + @observable accessor [testString] = "original string value" + + constructor() { + makeObservable(this) + } + } + + const o = new TestClass() + + const events: any[] = [] + observe(o, testSymbol, ev => events.push(ev.newValue, ev.oldValue)) + observe(o, testString, ev => events.push(ev.newValue, ev.oldValue)) + + runInAction(() => { + o[testSymbol] = "new symbol value" + o[testString] = "new string value" + }) + + t.deepEqual(events, [ + "new symbol value", // new symbol + "original symbol value", // original symbol + "new string value", // new string + "original string value" // original string + ]) +}) diff --git a/packages/mobx/__tests__/decorators_20223/tsconfig.json b/packages/mobx/__tests__/decorators_20223/tsconfig.json new file mode 100644 index 0000000000..31758eb645 --- /dev/null +++ b/packages/mobx/__tests__/decorators_20223/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": ["../../tsconfig.json", "../../../../tsconfig.test.json"], + "compilerOptions": { + "target": "ES6", + "experimentalDecorators": false, + "useDefineForClassFields": true, + + "rootDir": "../../" + }, + "exclude": ["__tests__"], + "include": ["./", "../../src"] // ["../../src", "./"] +} diff --git a/packages/mobx/jest.config-decorators.js b/packages/mobx/jest.config-decorators.js new file mode 100644 index 0000000000..751c550a4f --- /dev/null +++ b/packages/mobx/jest.config-decorators.js @@ -0,0 +1,11 @@ +const path = require("path") +const buildConfig = require("../../jest.base.config") + +module.exports = buildConfig( + __dirname, + { + testRegex: "__tests__/decorators_20223/.*\\.(t|j)sx?$", + setupFilesAfterEnv: [`/jest.setup.ts`] + }, + path.resolve(__dirname, "./__tests__/decorators_20223/tsconfig.json") +) diff --git a/packages/mobx/jest.config.js b/packages/mobx/jest.config.js index f0af2b1e22..c5223d1fce 100644 --- a/packages/mobx/jest.config.js +++ b/packages/mobx/jest.config.js @@ -1,6 +1,7 @@ const buildConfig = require("../../jest.base.config") module.exports = buildConfig(__dirname, { + projects: ["/jest.config.js", "/jest.config-decorators.js"], testRegex: "__tests__/v[4|5]/base/.*\\.(t|j)sx?$", setupFilesAfterEnv: [`/jest.setup.ts`] }) diff --git a/packages/mobx/src/api/action.ts b/packages/mobx/src/api/action.ts index ab5e386011..179621fd79 100644 --- a/packages/mobx/src/api/action.ts +++ b/packages/mobx/src/api/action.ts @@ -7,9 +7,16 @@ import { isFunction, isStringish, createDecoratorAnnotation, - createActionAnnotation + createActionAnnotation, + is20223Decorator } from "../internal" +import type { + ClassAccessorDecorator, + ClassFieldDecorator, + ClassMethodDecorator +} from "../types/decorator_fills" + export const ACTION = "action" export const ACTION_BOUND = "action.bound" export const AUTOACTION = "autoAction" @@ -29,17 +36,30 @@ const autoActionBoundAnnotation = createActionAnnotation(AUTOACTION_BOUND, { bound: true }) -export interface IActionFactory extends Annotation, PropertyDecorator { +export interface IActionFactory + extends Annotation, + PropertyDecorator, + ClassMethodDecorator, + ClassAccessorDecorator, + ClassFieldDecorator { // nameless actions (fn: T): T // named actions (name: string, fn: T): T // named decorator - (customName: string): PropertyDecorator & Annotation + (customName: string): PropertyDecorator & + Annotation & + ClassMethodDecorator & + ClassAccessorDecorator & + ClassFieldDecorator // decorator (name no longer supported) - bound: Annotation & PropertyDecorator + bound: Annotation & + PropertyDecorator & + ClassMethodDecorator & + ClassAccessorDecorator & + ClassFieldDecorator } function createActionFactory(autoAction: boolean): IActionFactory { @@ -52,6 +72,13 @@ function createActionFactory(autoAction: boolean): IActionFactory { if (isFunction(arg2)) { return createAction(arg1, arg2, autoAction) } + // @action (2022.3 Decorators) + if (is20223Decorator(arg2)) { + return (autoAction ? autoActionAnnotation : actionAnnotation).decorate_20223_( + arg1, + arg2 + ) + } // @action if (isStringish(arg2)) { return storeAnnotation(arg1, arg2, autoAction ? autoActionAnnotation : actionAnnotation) diff --git a/packages/mobx/src/api/annotation.ts b/packages/mobx/src/api/annotation.ts index 87a9ea607f..719c727402 100644 --- a/packages/mobx/src/api/annotation.ts +++ b/packages/mobx/src/api/annotation.ts @@ -20,6 +20,7 @@ export type Annotation = { descriptor: PropertyDescriptor, proxyTrap: boolean ): boolean | null + decorate_20223_(value: any, context: DecoratorContext) options_?: any } diff --git a/packages/mobx/src/api/computed.ts b/packages/mobx/src/api/computed.ts index 104932bd38..6ac31e8e04 100644 --- a/packages/mobx/src/api/computed.ts +++ b/packages/mobx/src/api/computed.ts @@ -10,19 +10,22 @@ import { die, IComputedValue, createComputedAnnotation, - comparer + comparer, + is20223Decorator } from "../internal" +import type { ClassGetterDecorator } from "../types/decorator_fills" + export const COMPUTED = "computed" export const COMPUTED_STRUCT = "computed.struct" -export interface IComputedFactory extends Annotation, PropertyDecorator { +export interface IComputedFactory extends Annotation, PropertyDecorator, ClassGetterDecorator { // @computed(opts) - (options: IComputedValueOptions): Annotation & PropertyDecorator + (options: IComputedValueOptions): Annotation & PropertyDecorator & ClassGetterDecorator // computed(fn, opts) (func: () => T, options?: IComputedValueOptions): IComputedValue - struct: Annotation & PropertyDecorator + struct: Annotation & PropertyDecorator & ClassGetterDecorator } const computedAnnotation = createComputedAnnotation(COMPUTED) @@ -35,6 +38,10 @@ const computedStructAnnotation = createComputedAnnotation(COMPUTED_STRUCT, { * For legacy purposes also invokable as ES5 observable created: `computed(() => expr)`; */ export const computed: IComputedFactory = function computed(arg1, arg2) { + if (is20223Decorator(arg2)) { + // @computed (2022.3 Decorators) + return computedAnnotation.decorate_20223_(arg1, arg2) + } if (isStringish(arg2)) { // @computed return storeAnnotation(arg1, arg2, computedAnnotation) diff --git a/packages/mobx/src/api/decorators.ts b/packages/mobx/src/api/decorators.ts index 5e682c6c82..caa64d38ee 100644 --- a/packages/mobx/src/api/decorators.ts +++ b/packages/mobx/src/api/decorators.ts @@ -1,5 +1,7 @@ import { Annotation, addHiddenProp, AnnotationsMap, hasProp, die, isOverride } from "../internal" +import type { Decorator } from "../types/decorator_fills" + export const storedAnnotationsSymbol = Symbol("mobx-stored-annotations") /** @@ -7,11 +9,17 @@ export const storedAnnotationsSymbol = Symbol("mobx-stored-annotations") * - decorator * - annotation object */ -export function createDecoratorAnnotation(annotation: Annotation): PropertyDecorator & Annotation { +export function createDecoratorAnnotation( + annotation: Annotation +): PropertyDecorator & Annotation & D { function decorator(target, property) { - storeAnnotation(target, property, annotation) + if (is20223Decorator(property)) { + return annotation.decorate_20223_(target, property) + } else { + storeAnnotation(target, property, annotation) + } } - return Object.assign(decorator, annotation) + return Object.assign(decorator, annotation) as any } /** @@ -61,13 +69,26 @@ function assertNotDecorated(prototype: object, annotation: Annotation, key: Prop */ export function collectStoredAnnotations(target): AnnotationsMap { if (!hasProp(target, storedAnnotationsSymbol)) { - if (__DEV__ && !target[storedAnnotationsSymbol]) { - die( - `No annotations were passed to makeObservable, but no decorated members have been found either` - ) - } + // if (__DEV__ && !target[storedAnnotationsSymbol]) { + // die( + // `No annotations were passed to makeObservable, but no decorated members have been found either` + // ) + // } // We need a copy as we will remove annotation from the list once it's applied. addHiddenProp(target, storedAnnotationsSymbol, { ...target[storedAnnotationsSymbol] }) } return target[storedAnnotationsSymbol] } + +export function is20223Decorator(context): context is DecoratorContext { + return typeof context == "object" && typeof context["kind"] == "string" +} + +export function assert20223DecoratorType( + context: DecoratorContext, + types: DecoratorContext["kind"][] +) { + if (__DEV__ && !types.includes(context.kind)) { + die(`Decorator may not be used like this`) + } +} diff --git a/packages/mobx/src/api/flow.ts b/packages/mobx/src/api/flow.ts index daad7a7e5a..ced847471c 100644 --- a/packages/mobx/src/api/flow.ts +++ b/packages/mobx/src/api/flow.ts @@ -7,9 +7,12 @@ import { isStringish, storeAnnotation, createFlowAnnotation, - createDecoratorAnnotation + createDecoratorAnnotation, + is20223Decorator } from "../internal" +import type { ClassMethodDecorator } from "../types/decorator_fills" + export const FLOW = "flow" let generatorId = 0 @@ -25,11 +28,11 @@ export function isFlowCancellationError(error: Error) { export type CancellablePromise = Promise & { cancel(): void } -interface Flow extends Annotation, PropertyDecorator { +interface Flow extends Annotation, PropertyDecorator, ClassMethodDecorator { ( generator: (...args: Args) => Generator | AsyncGenerator ): (...args: Args) => CancellablePromise - bound: Annotation & PropertyDecorator + bound: Annotation & PropertyDecorator & ClassMethodDecorator } const flowAnnotation = createFlowAnnotation("flow") @@ -37,6 +40,10 @@ const flowBoundAnnotation = createFlowAnnotation("flow.bound", { bound: true }) export const flow: Flow = Object.assign( function flow(arg1, arg2?) { + // @flow (2022.3 Decorators) + if (is20223Decorator(arg2)) { + return flowAnnotation.decorate_20223_(arg1, arg2) + } // @flow if (isStringish(arg2)) { return storeAnnotation(arg1, arg2, flowAnnotation) diff --git a/packages/mobx/src/api/observable.ts b/packages/mobx/src/api/observable.ts index 8d808aee1d..78ed391246 100644 --- a/packages/mobx/src/api/observable.ts +++ b/packages/mobx/src/api/observable.ts @@ -29,9 +29,12 @@ import { assign, isStringish, createObservableAnnotation, - createAutoAnnotation + createAutoAnnotation, + is20223Decorator } from "../internal" +import type { ClassAccessorDecorator, ClassFieldDecorator } from "../types/decorator_fills" + export const OBSERVABLE = "observable" export const OBSERVABLE_REF = "observable.ref" export const OBSERVABLE_SHALLOW = "observable.shallow" @@ -70,7 +73,8 @@ const observableShallowAnnotation = createObservableAnnotation(OBSERVABLE_SHALLO const observableStructAnnotation = createObservableAnnotation(OBSERVABLE_STRUCT, { enhancer: refStructEnhancer }) -const observableDecoratorAnnotation = createDecoratorAnnotation(observableAnnotation) +const observableDecoratorAnnotation = + createDecoratorAnnotation(observableAnnotation) export function getEnhancerFromOptions(options: CreateObservableOptions): IEnhancer { return options.deep === true @@ -95,6 +99,11 @@ export function getEnhancerFromAnnotation(annotation?: Annotation): IEnhancer(value?: T, options?: CreateObservableOptions): IObservableValue } -export interface IObservableFactory extends Annotation, PropertyDecorator { +export interface IObservableFactory + extends Annotation, + PropertyDecorator, + ClassAccessorDecorator, + ClassFieldDecorator { (value: T[], options?: CreateObservableOptions): IObservableArray (value: Set, options?: CreateObservableOptions): ObservableSet (value: Map, options?: CreateObservableOptions): ObservableMap @@ -170,13 +183,13 @@ export interface IObservableFactory extends Annotation, PropertyDecorator { /** * Decorator that creates an observable that only observes the references, but doesn't try to turn the assigned value into an observable.ts. */ - ref: Annotation & PropertyDecorator + ref: Annotation & PropertyDecorator & ClassAccessorDecorator & ClassFieldDecorator /** * Decorator that creates an observable converts its value (objects, maps or arrays) into a shallow observable structure */ - shallow: Annotation & PropertyDecorator - deep: Annotation & PropertyDecorator - struct: Annotation & PropertyDecorator + shallow: Annotation & PropertyDecorator & ClassAccessorDecorator & ClassFieldDecorator + deep: Annotation & PropertyDecorator & ClassAccessorDecorator & ClassFieldDecorator + struct: Annotation & PropertyDecorator & ClassAccessorDecorator & ClassFieldDecorator } const observableFactories: IObservableFactory = { diff --git a/packages/mobx/src/types/actionannotation.ts b/packages/mobx/src/types/actionannotation.ts index 7385c108b8..a8875c5412 100644 --- a/packages/mobx/src/types/actionannotation.ts +++ b/packages/mobx/src/types/actionannotation.ts @@ -7,7 +7,11 @@ import { isFunction, Annotation, globalState, - MakeResult + MakeResult, + assert20223DecoratorType, + asObservableObject, + $mobx, + getCachedFallthroughPropDescriptor } from "../internal" export function createActionAnnotation(name: string, options?: object): Annotation { @@ -15,11 +19,13 @@ export function createActionAnnotation(name: string, options?: object): Annotati annotationType_: name, options_: options, make_, - extend_ + extend_, + decorate_20223_ } } function make_( + this: Annotation, adm: ObservableObjectAdministration, key: PropertyKey, descriptor: PropertyDescriptor, @@ -49,6 +55,7 @@ function make_( } function extend_( + this: Annotation, adm: ObservableObjectAdministration, key: PropertyKey, descriptor: PropertyDescriptor, @@ -58,6 +65,81 @@ function extend_( return adm.defineProperty_(key, actionDescriptor, proxyTrap) } +function decorate_20223_(this: Annotation, mthd, context: DecoratorContext) { + if (__DEV__) { + assert20223DecoratorType(context, ["method", "accessor"]) + } + const { kind, name, addInitializer } = context + const ann = this + + const _createAction = m => + createAction(ann.options_?.name ?? name!.toString(), m, ann.options_?.autoAction ?? false) + + // Backwards/legacy behavior + if (kind == "field") { + addInitializer(function () { + const adm: ObservableObjectAdministration = asObservableObject(this)[$mobx] + const self = this as any + const actionDescriptor = createActionDescriptor( + adm, + self, + name, + { value: self[name] }, + false + ) + defineProperty(self, name, actionDescriptor) + }) + return + } + + if (kind == "method") { + if (!isAction(mthd)) { + mthd = _createAction(mthd) + } + + if (this.options_?.bound) { + addInitializer(function () { + const self = this as any + const bound = self[name].bind(self) + bound.isMobxAction = true + self[name] = bound + }) + } + + return mthd + } + + // Make the `@action accessor some_action = () => {}` syntax valid since `@action some_action = () => {}` was previously valid syntax + if (kind == "accessor") { + const { name } = context + const { set } = mthd + + // For the most backwards compatibility, this will ensure that the action is an "own" prop. + // However, since the user has to opt-in to new logic (via the `accessor` keyword), it may be nice _not_ to do so since + // that's kind of contrary to the direction that ES is going. + addInitializer(function () { + Object.defineProperty(this, name, { + ...getCachedFallthroughPropDescriptor(name), + enumerable: false + }) + }) + + return { + set(v) { + set.call(this, _createAction(v)) + }, + init(v) { + return _createAction(v) + } + } + } + + die( + `Cannot apply '${ann.annotationType_}' to '${String(name)}' (kind: ${kind}):` + + `\n'${ann.annotationType_}' can only be used on properties with a function value.` + ) +} + function assertActionDescriptor( adm: ObservableObjectAdministration, { annotationType_ }: Annotation, diff --git a/packages/mobx/src/types/autoannotation.ts b/packages/mobx/src/types/autoannotation.ts index 1be4e26363..c80f3b48d9 100644 --- a/packages/mobx/src/types/autoannotation.ts +++ b/packages/mobx/src/types/autoannotation.ts @@ -9,7 +9,8 @@ import { computed, autoAction, isGenerator, - MakeResult + MakeResult, + die } from "../internal" const AUTO = "true" @@ -21,7 +22,8 @@ export function createAutoAnnotation(options?: object): Annotation { annotationType_: AUTO, options_: options, make_, - extend_ + extend_, + decorate_20223_ } } @@ -105,3 +107,7 @@ function extend_( let observableAnnotation = this.options_?.deep === false ? observable.ref : observable return observableAnnotation.extend_(adm, key, descriptor, proxyTrap) } + +function decorate_20223_(this: Annotation, desc, context: ClassGetterDecoratorContext) { + die(`'${this.annotationType_}' cannot be used as a decorator`) +} diff --git a/packages/mobx/src/types/computedannotation.ts b/packages/mobx/src/types/computedannotation.ts index 4caa11f6eb..dd398741f4 100644 --- a/packages/mobx/src/types/computedannotation.ts +++ b/packages/mobx/src/types/computedannotation.ts @@ -1,15 +1,26 @@ -import { ObservableObjectAdministration, die, Annotation, MakeResult } from "../internal" +import { + ObservableObjectAdministration, + die, + Annotation, + MakeResult, + assert20223DecoratorType, + $mobx, + asObservableObject, + ComputedValue +} from "../internal" export function createComputedAnnotation(name: string, options?: object): Annotation { return { annotationType_: name, options_: options, make_, - extend_ + extend_, + decorate_20223_ } } function make_( + this: Annotation, adm: ObservableObjectAdministration, key: PropertyKey, descriptor: PropertyDescriptor @@ -18,6 +29,7 @@ function make_( } function extend_( + this: Annotation, adm: ObservableObjectAdministration, key: PropertyKey, descriptor: PropertyDescriptor, @@ -35,6 +47,31 @@ function extend_( ) } +function decorate_20223_(this: Annotation, get, context: ClassGetterDecoratorContext) { + if (__DEV__) { + assert20223DecoratorType(context, ["getter"]) + } + const ann = this + const { name: key, addInitializer } = context + + 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)) + }) + + return function () { + return this[$mobx].getObservablePropValue_(key) + } +} + function assertComputedDescriptor( adm: ObservableObjectAdministration, { annotationType_ }: Annotation, diff --git a/packages/mobx/src/types/decorator_fills.ts b/packages/mobx/src/types/decorator_fills.ts new file mode 100644 index 0000000000..084f023376 --- /dev/null +++ b/packages/mobx/src/types/decorator_fills.ts @@ -0,0 +1,33 @@ +// Hopefully these will be main-lined into Typescipt, but at the moment TS only declares the Contexts + +export type ClassAccessorDecorator = ( + value: ClassAccessorDecoratorTarget, + context: ClassAccessorDecoratorContext +) => ClassAccessorDecoratorResult | void + +export type ClassGetterDecorator = ( + value: (this: This) => Value, + context: ClassGetterDecoratorContext +) => ((this: This) => Value) | void + +export type ClassSetterDecorator = ( + value: (this: This, value: Value) => void, + context: ClassSetterDecoratorContext +) => ((this: This, value: Value) => void) | void + +export type ClassMethodDecorator any = any> = ( + value: Value, + context: ClassMethodDecoratorContext +) => Value | void + +export type ClassFieldDecorator any = any> = ( + value: Value, + context: ClassFieldDecoratorContext +) => Value | void + +export type Decorator = + | ClassAccessorDecorator + | ClassGetterDecorator + | ClassSetterDecorator + | ClassMethodDecorator + | ClassFieldDecorator diff --git a/packages/mobx/src/types/flowannotation.ts b/packages/mobx/src/types/flowannotation.ts index 42d6f3e109..530b95942c 100644 --- a/packages/mobx/src/types/flowannotation.ts +++ b/packages/mobx/src/types/flowannotation.ts @@ -8,7 +8,8 @@ import { isFunction, globalState, MakeResult, - hasProp + hasProp, + assert20223DecoratorType } from "../internal" export function createFlowAnnotation(name: string, options?: object): Annotation { @@ -16,11 +17,13 @@ export function createFlowAnnotation(name: string, options?: object): Annotation annotationType_: name, options_: options, make_, - extend_ + extend_, + decorate_20223_ } } function make_( + this: Annotation, adm: ObservableObjectAdministration, key: PropertyKey, descriptor: PropertyDescriptor, @@ -50,6 +53,7 @@ function make_( } function extend_( + this: Annotation, adm: ObservableObjectAdministration, key: PropertyKey, descriptor: PropertyDescriptor, @@ -59,6 +63,28 @@ function extend_( return adm.defineProperty_(key, flowDescriptor, proxyTrap) } +function decorate_20223_(this: Annotation, mthd, context: ClassMethodDecoratorContext) { + if (__DEV__) { + assert20223DecoratorType(context, ["method"]) + } + const { name, addInitializer } = context + + if (!isFlow(mthd)) { + mthd = flow(mthd) + } + + if (this.options_?.bound) { + addInitializer(function () { + const self = this as any + const bound = self[name].bind(self) + bound.isMobXFlow = true + self[name] = bound + }) + } + + return mthd +} + function assertFlowDescriptor( adm: ObservableObjectAdministration, { annotationType_ }: Annotation, diff --git a/packages/mobx/src/types/observableannotation.ts b/packages/mobx/src/types/observableannotation.ts index 4e5175e431..30052ed0d4 100644 --- a/packages/mobx/src/types/observableannotation.ts +++ b/packages/mobx/src/types/observableannotation.ts @@ -3,7 +3,12 @@ import { deepEnhancer, die, Annotation, - MakeResult + MakeResult, + assert20223DecoratorType, + ObservableValue, + asObservableObject, + $mobx, + getCachedFallthroughPropDescriptor } from "../internal" export function createObservableAnnotation(name: string, options?: object): Annotation { @@ -11,11 +16,13 @@ export function createObservableAnnotation(name: string, options?: object): Anno annotationType_: name, options_: options, make_, - extend_ + extend_, + decorate_20223_ } } function make_( + this: Annotation, adm: ObservableObjectAdministration, key: PropertyKey, descriptor: PropertyDescriptor @@ -24,6 +31,7 @@ function make_( } function extend_( + this: Annotation, adm: ObservableObjectAdministration, key: PropertyKey, descriptor: PropertyDescriptor, @@ -38,6 +46,88 @@ function extend_( ) } +function decorate_20223_( + this: Annotation, + desc, + context: ClassAccessorDecoratorContext | ClassFieldDecoratorContext +) { + if (__DEV__) { + assert20223DecoratorType(context, ["accessor", "field"]) + } + + const ann = this + const { kind, name: key, addInitializer } = context + + // Backwards/Legacy behavior, expects makeObservable(this) + if (kind == "field") { + addInitializer(function () { + const adm: ObservableObjectAdministration = asObservableObject(this)[$mobx] + adm.defineObservableProperty_( + key, + (this as any)[key], + ann.options_?.enhancer ?? deepEnhancer, + false + ) + }) + return + } + + // The laziness here is not ideal... It's a workaround to how 2022.3 Decorators are implemented: + // `addInitializer` callbacks are executed _before_ any accessors are defined (instead of the ideal-for-us right after each). + // This means that, if we were to do our stuff in an `addInitializer`, we'd attempt to read a private slot + // before it has been initialized. The runtime doesn't like that and throws a `Cannot read private member + // from an object whose class did not declare it` error. + const initializedObjects = new WeakSet() + + function initializeObservable(target, value) { + // adm.set_(key, value); + const adm: ObservableObjectAdministration = asObservableObject(target)[$mobx] + const observable = new ObservableValue( + value, + ann.options_?.enhancer ?? deepEnhancer, + __DEV__ ? `${adm.name_}.${key.toString()}` : "ObservableObject.key", + false + ) + adm.values_.set(key, observable) + initializedObjects.add(target) + } + + if (kind == "accessor") { + // For the most backwards compatibility, this will ensure that the observable is an enumerable "own" prop. + // However, since the user has to opt-in to new logic (via the `accessor` keyword), it may be nice _not_ to do so since + // that's kind of contrary to the direction that ES is going. + addInitializer(function () { + Object.defineProperty(this, key, { + ...getCachedFallthroughPropDescriptor(key), + enumerable: true + }) + }) + + return { + get() { + if (!initializedObjects.has(this)) { + initializeObservable(this, desc.get.call(this)) + } + return this[$mobx].getObservablePropValue_(key) + }, + set(value) { + if (!initializedObjects.has(this)) { + initializeObservable(this, value) + } + return this[$mobx].setObservablePropValue_(key, value) + }, + init(value) { + if (!initializedObjects.has(this)) { + initializeObservable(this, value) + } + return value + } + } + } + + return +} + function assertObservableDescriptor( adm: ObservableObjectAdministration, { annotationType_ }: Annotation, diff --git a/packages/mobx/src/types/observableobject.ts b/packages/mobx/src/types/observableobject.ts index 0828577e54..75a63a4d69 100644 --- a/packages/mobx/src/types/observableobject.ts +++ b/packages/mobx/src/types/observableobject.ts @@ -707,6 +707,22 @@ function getCachedObservablePropDescriptor(key) { ) } +const fallthroughDescriptorCache = Object.create(null) + +export function getCachedFallthroughPropDescriptor(key) { + return ( + fallthroughDescriptorCache[key] || + (fallthroughDescriptorCache[key] = { + get() { + return Reflect.get(Object.getPrototypeOf(this), key, this) + }, + set(v) { + return Reflect.set(Object.getPrototypeOf(this), key, v, this) + } + }) + ) +} + export function isObservableObject(thing: any): boolean { if (isObject(thing)) { return isObservableObjectAdministration((thing as any)[$mobx]) diff --git a/packages/mobx/src/types/overrideannotation.ts b/packages/mobx/src/types/overrideannotation.ts index 36fe60d555..f688c99630 100644 --- a/packages/mobx/src/types/overrideannotation.ts +++ b/packages/mobx/src/types/overrideannotation.ts @@ -7,19 +7,23 @@ import { MakeResult } from "../internal" +import type { ClassMethodDecorator } from "./decorator_fills" + const OVERRIDE = "override" -export const override: Annotation & PropertyDecorator = createDecoratorAnnotation({ - annotationType_: OVERRIDE, - make_, - extend_ -}) +export const override: Annotation & PropertyDecorator & ClassMethodDecorator = + createDecoratorAnnotation({ + annotationType_: OVERRIDE, + make_, + extend_, + decorate_20223_ + }) export function isOverride(annotation: Annotation): boolean { return annotation.annotationType_ === OVERRIDE } -function make_(adm: ObservableObjectAdministration, key): MakeResult { +function make_(this: Annotation, adm: ObservableObjectAdministration, key): MakeResult { // Must not be plain object if (__DEV__ && adm.isPlainObject_) { die( @@ -37,6 +41,10 @@ function make_(adm: ObservableObjectAdministration, key): MakeResult { return MakeResult.Cancel } -function extend_(adm, key, descriptor, proxyTrap): boolean { +function extend_(this: Annotation, adm, key, descriptor, proxyTrap): boolean { die(`'${this.annotationType_}' can only be used with 'makeObservable'`) } + +function decorate_20223_(this: Annotation, desc, context: DecoratorContext) { + console.warn(`'${this.annotationType_}' cannot be used with decorators - this is a no-op`) +} diff --git a/yarn.lock b/yarn.lock index 34cd4b4b55..838f12c39e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11574,10 +11574,10 @@ prettier@^1.19.1: resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== -prettier@^2.0.5: - version "2.5.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.5.1.tgz#fff75fa9d519c54cf0fce328c1017d94546bc56a" - integrity sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg== +prettier@^2.8.4: + version "2.8.4" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.4.tgz#34dd2595629bfbb79d344ac4a91ff948694463c3" + integrity sha512-vIS4Rlc2FNh0BySk3Wkd6xmwxB0FpOndW5fisM5H8hsZSxU2VWVB5CWIkIjWvrHjIhxk2g3bfMKM87zNTrZddw== pretty-format@^25.2.1, pretty-format@^25.5.0: version "25.5.0" @@ -13908,10 +13908,10 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@^3.7.3, typescript@^4.0.2: - version "4.5.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.4.tgz#a17d3a0263bf5c8723b9c52f43c5084edf13c2e8" - integrity sha512-VgYs2A2QIRuGphtzFV7aQJduJ2gyfTljngLzjpfW9FoYZF6xuw1W0vW9ghCKLfcWrCFxK81CSGRAvS1pn4fIUg== +typescript@^3.7.3, typescript@^5.0.0-beta: + version "5.0.0-dev.20230222" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.0-dev.20230222.tgz#58809d36b989d244ef037ae5f869f0fc233a952c" + integrity sha512-OCNanAIcGf3Uy1aBvLbPNe524MnDEZChefbzgo9gvEZPYNG7Zma1C6dXuOBSCapLgbLHksOTyoPwixKTbDkZPQ== uglify-js@^3.1.4: version "3.14.5"