Skip to content

Commit

Permalink
Implement property decorators
Browse files Browse the repository at this point in the history
  • Loading branch information
mbeckem committed May 17, 2024
1 parent 6881ef8 commit 2b78702
Show file tree
Hide file tree
Showing 5 changed files with 357 additions and 0 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
186 changes: 186 additions & 0 deletions packages/reactivity-decorators/index.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
115 changes: 115 additions & 0 deletions packages/reactivity-decorators/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { ReactiveOptions, computed, isReactive, isReadonlyReactive, reactive } from "@conterra/reactivity-core";

export interface ReactivePropertyDecoratorApi {
<This, Prop>(
target: ClassAccessorDecoratorTarget<This, Prop>,
context: ClassAccessorDecoratorContext<This, Prop> & {
static: false;
}
): ClassAccessorDecoratorResult<This, Prop>;

withOptions<Prop>(options: ReactiveOptions<Prop>): <This>(
target: ClassAccessorDecoratorTarget<This, Prop>,
context: ClassAccessorDecoratorContext<This, Prop> & {
static: false;
}
) => ClassAccessorDecoratorResult<This, Prop>;
}

export const reactiveProperty = Object.assign(defineReactiveProperty, {
withOptions(options: ReactiveOptions<unknown>) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (target: any, context: any) => defineReactiveProperty(target, context, options);
}
}) as ReactivePropertyDecoratorApi;

export interface ComputedPropertyDecoratorApi {
<This, Prop>(
target: () => Prop,
context: ClassGetterDecoratorContext<This, Prop> & {
static: false;
}
): () => Prop;

withOptions<Prop>(options: ReactiveOptions<Prop>): <This>(
target: () => Prop,
context: ClassGetterDecoratorContext<This, Prop> & {
static: false;
}
) => () => Prop;
}

export const computedProperty = Object.assign(
defineComputedProperty,
{
withOptions(options: ReactiveOptions<unknown>) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (target: any, context: any) => defineComputedProperty(target, context, options);
}
}
) as ComputedPropertyDecoratorApi;

function defineReactiveProperty<This, Prop>(
target: ClassAccessorDecoratorTarget<This, Prop>,
context: ClassAccessorDecoratorContext<This, Prop> & {
static: false;
},
options?: ReactiveOptions<Prop> | undefined
): ClassAccessorDecoratorResult<This, Prop> {
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<This, Prop>(
target: () => Prop,
context: ClassGetterDecoratorContext<This, Prop> & {
static: false;
},
options?: ReactiveOptions<Prop> | 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;
};
}
Loading

0 comments on commit 2b78702

Please sign in to comment.