feat(vue): expand @robonen/vue composable collection
Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computed, effectScope, isReactive, nextTick, ref, shallowRef, watch } from 'vue';
|
||||
import { toReactive } from '.';
|
||||
|
||||
describe(toReactive, () => {
|
||||
it('reads properties through the ref', () => {
|
||||
const state = ref({ count: 0, name: 'a' });
|
||||
const r = toReactive(state);
|
||||
expect(r.count).toBe(0);
|
||||
expect(r.name).toBe('a');
|
||||
});
|
||||
|
||||
it('returns a reactive proxy', () => {
|
||||
const r = toReactive(ref({ count: 0 }));
|
||||
expect(isReactive(r)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('writes pass through to the ref value', () => {
|
||||
const state = ref({ count: 0 });
|
||||
const r = toReactive(state);
|
||||
r.count = 5;
|
||||
expect(state.value.count).toBe(5);
|
||||
});
|
||||
|
||||
it('reflects external ref mutations', () => {
|
||||
const state = ref({ count: 0 });
|
||||
const r = toReactive(state);
|
||||
state.value.count = 9;
|
||||
expect(r.count).toBe(9);
|
||||
});
|
||||
|
||||
it('survives reassignment of the whole ref value', () => {
|
||||
const state = ref({ name: 'a' });
|
||||
const r = toReactive(state);
|
||||
expect(r.name).toBe('a');
|
||||
state.value = { name: 'b' };
|
||||
expect(r.name).toBe('b');
|
||||
});
|
||||
|
||||
it('unwraps nested refs on read', () => {
|
||||
// shallowRef preserves nested refs as values (a deep ref would unwrap them)
|
||||
const inner = ref(1);
|
||||
const state = shallowRef({ inner });
|
||||
const r = toReactive(state);
|
||||
expect(r.inner as unknown).toBe(1);
|
||||
inner.value = 2;
|
||||
expect(r.inner as unknown).toBe(2);
|
||||
});
|
||||
|
||||
it('writes a plain value into a nested ref via .value', () => {
|
||||
const inner = ref(1);
|
||||
const state = shallowRef({ inner });
|
||||
const r = toReactive(state);
|
||||
(r as unknown as { inner: number }).inner = 42;
|
||||
expect(inner.value).toBe(42);
|
||||
// the property is still a ref, not overwritten by a plain number
|
||||
expect(state.value.inner).toBe(inner);
|
||||
});
|
||||
|
||||
it('replaces a nested ref when assigning another ref', () => {
|
||||
const a = ref(1);
|
||||
const b = ref(2);
|
||||
const state = shallowRef<{ x: unknown }>({ x: a });
|
||||
const r = toReactive(state) as { x: unknown };
|
||||
r.x = b;
|
||||
expect(state.value.x).toBe(b);
|
||||
});
|
||||
|
||||
it('supports the in operator (has trap)', () => {
|
||||
const state = ref<Record<string, number>>({ a: 1 });
|
||||
const r = toReactive(state);
|
||||
expect('a' in r).toBeTruthy();
|
||||
expect('b' in r).toBeFalsy();
|
||||
});
|
||||
|
||||
it('supports key deletion', () => {
|
||||
const state = ref<Record<string, number>>({ a: 1, b: 2 });
|
||||
const r = toReactive(state);
|
||||
delete r.a;
|
||||
expect('a' in state.value).toBeFalsy();
|
||||
expect(r.b).toBe(2);
|
||||
});
|
||||
|
||||
it('enumerates own keys via Object.keys', () => {
|
||||
const state = ref<Record<string, number>>({ a: 1, b: 2 });
|
||||
const r = toReactive(state);
|
||||
expect(Object.keys(r).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('spreads enumerable own properties', () => {
|
||||
const state = ref<Record<string, number>>({ a: 1, b: 2 });
|
||||
const r = toReactive(state);
|
||||
expect({ ...r }).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it('is deeply reactive in an effect', async () => {
|
||||
const state = ref({ count: 0 });
|
||||
const r = toReactive(state);
|
||||
const seen: number[] = [];
|
||||
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
watch(() => r.count, value => seen.push(value));
|
||||
});
|
||||
|
||||
r.count = 1;
|
||||
await nextTick();
|
||||
state.value.count = 2;
|
||||
await nextTick();
|
||||
|
||||
expect(seen).toEqual([1, 2]);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('works as a computed source', () => {
|
||||
const state = ref({ first: 'John', last: 'Doe' });
|
||||
const r = toReactive(state);
|
||||
const full = computed(() => `${r.first} ${r.last}`);
|
||||
expect(full.value).toBe('John Doe');
|
||||
r.first = 'Jane';
|
||||
expect(full.value).toBe('Jane Doe');
|
||||
});
|
||||
|
||||
it('returns reactive(object) for a plain (non-ref) object', () => {
|
||||
const plain = { count: 0 };
|
||||
const r = toReactive(plain);
|
||||
expect(isReactive(r)).toBeTruthy();
|
||||
r.count = 3;
|
||||
expect(plain.count).toBe(3);
|
||||
});
|
||||
|
||||
it('handles index and length access on array-backed refs', () => {
|
||||
const state = ref<number[]>([1, 2, 3]);
|
||||
const r = toReactive(state);
|
||||
expect(r).toHaveLength(3);
|
||||
expect(r[0]).toBe(1);
|
||||
expect(r[2]).toBe(3);
|
||||
r[0] = 9;
|
||||
expect(state.value[0]).toBe(9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { isRef, reactive, unref } from 'vue';
|
||||
import type { MaybeRef, UnwrapNestedRefs } from 'vue';
|
||||
|
||||
// Shared, frozen descriptor returned by the proxy's `getOwnPropertyDescriptor`
|
||||
// trap. Every enumerable own key of the underlying ref resolves to the same
|
||||
// shape, so we allocate it once at module scope instead of per-lookup.
|
||||
const OWN_PROPERTY_DESCRIPTOR: PropertyDescriptor = {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* @name toReactive
|
||||
* @category Reactivity
|
||||
* @description Convert a ref of object to a reactive proxy. Property reads and
|
||||
* writes pass straight through to the ref's current value, so the proxy stays
|
||||
* in sync even if the ref is reassigned to a whole new object. Writing a plain
|
||||
* value onto a key that currently holds a ref unwraps into that ref's `.value`.
|
||||
* Passing a plain object simply returns `reactive(object)`.
|
||||
*
|
||||
* @param {MaybeRef<T>} objectRef A ref of object (or a plain object)
|
||||
* @returns {UnwrapNestedRefs<T>} A reactive proxy backed by the ref
|
||||
*
|
||||
* @example
|
||||
* const state = ref({ count: 0 });
|
||||
* const reactiveState = toReactive(state);
|
||||
* reactiveState.count++; // state.value.count === 1
|
||||
*
|
||||
* @example
|
||||
* // survives ref reassignment
|
||||
* const obj = ref({ name: 'a' });
|
||||
* const r = toReactive(obj);
|
||||
* obj.value = { name: 'b' };
|
||||
* r.name; // 'b'
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function toReactive<T extends object>(
|
||||
objectRef: MaybeRef<T>,
|
||||
): UnwrapNestedRefs<T> {
|
||||
if (!isRef(objectRef))
|
||||
return reactive(objectRef);
|
||||
|
||||
const proxy = new Proxy({} as T, {
|
||||
get(_, key, receiver) {
|
||||
return unref(Reflect.get(objectRef.value, key, receiver));
|
||||
},
|
||||
set(_, key, value) {
|
||||
const current = objectRef.value[key as keyof T];
|
||||
|
||||
if (isRef(current) && !isRef(value))
|
||||
current.value = value;
|
||||
else
|
||||
(objectRef.value as Record<PropertyKey, unknown>)[key] = value;
|
||||
|
||||
return true;
|
||||
},
|
||||
deleteProperty(_, key) {
|
||||
return Reflect.deleteProperty(objectRef.value, key);
|
||||
},
|
||||
has(_, key) {
|
||||
return Reflect.has(objectRef.value, key);
|
||||
},
|
||||
ownKeys() {
|
||||
return Reflect.ownKeys(objectRef.value);
|
||||
},
|
||||
getOwnPropertyDescriptor() {
|
||||
return OWN_PROPERTY_DESCRIPTOR;
|
||||
},
|
||||
});
|
||||
|
||||
return reactive(proxy) as UnwrapNestedRefs<T>;
|
||||
}
|
||||
Reference in New Issue
Block a user