-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
357 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; | ||
} |
Oops, something went wrong.