From 2b78702f24efebd3665a182caab93d1d44d80562 Mon Sep 17 00:00:00 2001 From: Michael Beckemeyer Date: Fri, 17 May 2024 18:07:34 +0200 Subject: [PATCH] Implement property decorators --- package.json | 1 + packages/reactivity-decorators/index.test.ts | 186 +++++++++++++++++++ packages/reactivity-decorators/index.ts | 115 ++++++++++++ pnpm-lock.yaml | 43 +++++ vitest.config.ts | 12 ++ 5 files changed, 357 insertions(+) create mode 100644 packages/reactivity-decorators/index.test.ts create mode 100644 vitest.config.ts diff --git a/package.json b/package.json index 16e47f2..24678ad 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "happy-dom": "^14.11.0", "prettier": "^3.2.5", "rimraf": "^5.0.7", + "rollup-plugin-esbuild": "^6.1.1", "tsx": "^4.10.4", "typedoc": "^0.25.13", "typescript": "~5.4.5", diff --git a/packages/reactivity-decorators/index.test.ts b/packages/reactivity-decorators/index.test.ts new file mode 100644 index 0000000..306615a --- /dev/null +++ b/packages/reactivity-decorators/index.test.ts @@ -0,0 +1,186 @@ +import { reactive, syncEffect } from "@conterra/reactivity-core"; +import { expect, it, vi } from "vitest"; +import { computedProperty, reactiveProperty } from "./index"; + +it("supports classes with reactive properties", () => { + class ReactiveClass { + @reactiveProperty + accessor value = 3; + } + + const obj = new ReactiveClass(); + const spy = vi.fn(); + syncEffect(() => { + spy(obj.value); + }); + + obj.value += 1; + obj.value = 20; + expect(spy.mock.calls.map((c) => c[0]!)).toMatchInlineSnapshot(` + [ + 3, + 4, + 20, + ] + `); +}); + +it("supports computed properties", () => { + const computeSpy = vi.fn(); + + class ReactiveClass { + @reactiveProperty + accessor value = 3; + + @computedProperty + get doubleValue() { + computeSpy(this.value); + return this.value * 2; + } + } + + const obj = new ReactiveClass(); + expect(obj.doubleValue).toBe(6); + expect(computeSpy).toBeCalledTimes(1); + + obj.doubleValue; + expect(computeSpy).toBeCalledTimes(1); // cached + + obj.value = 4; + expect(obj.doubleValue).toBe(8); + expect(computeSpy).toBeCalledTimes(2); +}); + +it("supports private properties", () => { + class ReactiveClass { + @reactiveProperty + accessor #value = 1; + + updateValue(newValue: number) { + this.#value = newValue; + } + + getValue() { + return this.#value; + } + } + + const obj = new ReactiveClass(); + const spy = vi.fn(); + syncEffect(() => { + spy(obj.getValue()); + }); + + obj.updateValue(4); + expect(spy.mock.calls.map((c) => c[0]!)).toMatchInlineSnapshot(` + [ + 1, + 4, + ] + `); +}); + +it("supports initialization from constructor", () => { + class ReactiveClass { + @reactiveProperty + accessor value: number; + + constructor() { + this.value = 3; + } + } + + const obj = new ReactiveClass(); + const spy = vi.fn(); + syncEffect(() => { + spy(obj.value); + }); + + obj.value = 4; + expect(spy.mock.calls.map((c) => c[0]!)).toMatchInlineSnapshot(` + [ + 3, + 4, + ] + `); +}); + +it("does not share state between instances", () => { + class ReactiveClass { + @reactiveProperty + accessor value: number; + + constructor(v: number) { + this.value = v; + } + } + + const obj1 = new ReactiveClass(1); + const obj2 = new ReactiveClass(2); + expect(obj1.value).toBe(1); + expect(obj2.value).toBe(2); +}); + +it("supports options for reactive properties", () => { + interface Point { + x: number; + y: number; + } + + class ReactiveClass { + @reactiveProperty.withOptions({ + equal(p1, p2) { + // TODO: point is initially undefined... + return p1?.x === p2.x && p1?.y === p2.y; + } + }) + accessor point: Point; + + constructor(point: Point) { + this.point = point; + } + } + + const p1 = { x: 1, y: 1}; + const p2 = { x: 1, y: 1}; + const p3 = { x: 1, y: 2}; + + const obj = new ReactiveClass(p1); + expect(obj.point).toBe(p1); + + obj.point = p2; + expect(obj.point).toBe(p1); // old value (p1 equal to p2) + + obj.point = p3; + expect(obj.point).toBe(p3); // not equal, write went through +}); + +it("supports options for computed properties", () => { + const name = reactive("foo"); + + const computedSpy = vi.fn(); + class ReactiveClass { + @computedProperty.withOptions({ + equal(o1, o2) { + return o1.name === o2.name; + } + }) + get objWithName() { + computedSpy(name.value); + return { name: name.value.toUpperCase() }; + } + } + + const obj = new ReactiveClass(); + const initial = obj.objWithName; + expect(computedSpy).toHaveBeenCalledTimes(1); + expect(initial.name).toBe("FOO"); + + name.value = "Foo"; + expect(obj.objWithName).toBe(initial); // got same object + expect(computedSpy).toHaveBeenCalledTimes(2); // but getter was called + + name.value = "Bar"; + expect(obj.objWithName.name).toBe("BAR"); + expect(computedSpy).toHaveBeenCalledTimes(3); +}); diff --git a/packages/reactivity-decorators/index.ts b/packages/reactivity-decorators/index.ts index e69de29..6baf8ba 100644 --- a/packages/reactivity-decorators/index.ts +++ b/packages/reactivity-decorators/index.ts @@ -0,0 +1,115 @@ +import { ReactiveOptions, computed, isReactive, isReadonlyReactive, reactive } from "@conterra/reactivity-core"; + +export interface ReactivePropertyDecoratorApi { + ( + target: ClassAccessorDecoratorTarget, + context: ClassAccessorDecoratorContext & { + static: false; + } + ): ClassAccessorDecoratorResult; + + withOptions(options: ReactiveOptions): ( + target: ClassAccessorDecoratorTarget, + context: ClassAccessorDecoratorContext & { + static: false; + } + ) => ClassAccessorDecoratorResult; +} + +export const reactiveProperty = Object.assign(defineReactiveProperty, { + withOptions(options: ReactiveOptions) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (target: any, context: any) => defineReactiveProperty(target, context, options); + } +}) as ReactivePropertyDecoratorApi; + +export interface ComputedPropertyDecoratorApi { + ( + target: () => Prop, + context: ClassGetterDecoratorContext & { + static: false; + } + ): () => Prop; + + withOptions(options: ReactiveOptions): ( + target: () => Prop, + context: ClassGetterDecoratorContext & { + static: false; + } + ) => () => Prop; +} + +export const computedProperty = Object.assign( + defineComputedProperty, + { + withOptions(options: ReactiveOptions) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (target: any, context: any) => defineComputedProperty(target, context, options); + } + } +) as ComputedPropertyDecoratorApi; + +function defineReactiveProperty( + target: ClassAccessorDecoratorTarget, + context: ClassAccessorDecoratorContext & { + static: false; + }, + options?: ReactiveOptions | undefined +): ClassAccessorDecoratorResult { + const getSignal = (instance: This) => { + const signal = target.get.call(instance) as unknown; + if (!isReactive(signal)) { + throw new Error("Internal error: signal was not initialized."); + } + return signal; + }; + + return { + get() { + const signal = getSignal(this); + return signal.value as Prop; + }, + set(value) { + const signal = getSignal(this); + signal.value = value; + }, + init(initialValue) { + // XXX: Replaces the value of the internal field with a wrapping signal. + // This changes the type of the field (which typescript really doesn't like). + // The getter / setter above restore type safety, so this should not be a problem for + // users of this property. + // However, the signal might be visible in other decorators on the same property. + // + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return reactive(initialValue, options) as any; + } + }; +} + +function defineComputedProperty( + target: () => Prop, + context: ClassGetterDecoratorContext & { + static: false; + }, + options?: ReactiveOptions | undefined +): () => Prop { + const COMPUTED_KEY = Symbol( + process.env.NODE_ENV === "development" ? `computed_${context.name.toString()}` : undefined + ); + + context.addInitializer(function () { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this as any)[COMPUTED_KEY] = computed(() => { + return target.call(this); + }, options); + }); + + return function getComputedProperty(this: unknown) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const signal = (this as any)[COMPUTED_KEY]; + if (!isReadonlyReactive(signal)) { + throw new Error("Internal error: computed signal was not initialized."); + } + return signal.value; + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83604f4..e8a49c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: rimraf: specifier: ^5.0.7 version: 5.0.7 + rollup-plugin-esbuild: + specifier: ^6.1.1 + version: 6.1.1(esbuild@0.21.3)(rollup@4.14.1) tsx: specifier: ^4.10.4 version: 4.10.4 @@ -476,6 +479,15 @@ packages: '@preact/signals-core@1.6.0': resolution: {integrity: sha512-O/XGxwP85h1F7+ouqTMOIZ3+V1whfaV9ToIVcuyGriD4JkSD00cQo54BKdqjvBJxbenvp7ynfqRHEwI6e+NIhw==} + '@rollup/pluginutils@5.1.0': + resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.14.1': resolution: {integrity: sha512-fH8/o8nSUek8ceQnT7K4EQbSiV7jgkHq81m9lWZFIXjJ7lJzpWXbQFpT/Zh6OZYnpFykvzC3fbEvEAFZu03dPA==} cpu: [arm] @@ -836,6 +848,9 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + es-module-lexer@1.5.2: + resolution: {integrity: sha512-l60ETUTmLqbVbVHv1J4/qj+M8nq7AwMzEcg3kmJDt9dCNrTk+yHcYFf/Kw75pMDwd9mPcIGCG5LcS20SxYRzFA==} + esbuild@0.20.2: resolution: {integrity: sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==} engines: {node: '>=12'} @@ -1317,6 +1332,13 @@ packages: engines: {node: '>=14.18'} hasBin: true + rollup-plugin-esbuild@6.1.1: + resolution: {integrity: sha512-CehMY9FAqJD5OUaE/Mi1r5z0kNeYxItmRO2zG4Qnv2qWKF09J2lTy5GUzjJR354ZPrLkCj4fiBN41lo8PzBUhw==} + engines: {node: '>=14.18.0'} + peerDependencies: + esbuild: '>=0.18.0' + rollup: ^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 + rollup@4.14.1: resolution: {integrity: sha512-4LnHSdd3QK2pa1J6dFbfm1HN0D7vSK/ZuZTsdyUAlA6Rr1yTouUTL13HaDOGJVgby461AhrNGBS7sCGXXtT+SA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1894,6 +1916,14 @@ snapshots: '@preact/signals-core@1.6.0': {} + '@rollup/pluginutils@5.1.0(rollup@4.14.1)': + dependencies: + '@types/estree': 1.0.5 + estree-walker: 2.0.2 + picomatch: 2.3.1 + optionalDependencies: + rollup: 4.14.1 + '@rollup/rollup-android-arm-eabi@4.14.1': optional: true @@ -2265,6 +2295,8 @@ snapshots: entities@4.5.0: {} + es-module-lexer@1.5.2: {} + esbuild@0.20.2: optionalDependencies: '@esbuild/aix-ppc64': 0.20.2 @@ -2834,6 +2866,17 @@ snapshots: dependencies: glob: 10.3.10 + rollup-plugin-esbuild@6.1.1(esbuild@0.21.3)(rollup@4.14.1): + dependencies: + '@rollup/pluginutils': 5.1.0(rollup@4.14.1) + debug: 4.3.4 + es-module-lexer: 1.5.2 + esbuild: 0.21.3 + get-tsconfig: 4.7.5 + rollup: 4.14.1 + transitivePeerDependencies: + - supports-color + rollup@4.14.1: dependencies: '@types/estree': 1.0.5 diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..de83fe5 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import esbuild from "rollup-plugin-esbuild"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + + }, + plugins: [ + // for decorators + esbuild() + ] +});