feat(vue): expand @robonen/vue composable collection
Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { syncRef } from '.';
|
||||
|
||||
describe(syncRef, () => {
|
||||
it('keeps both refs in sync two-way by default', () => {
|
||||
const left = ref('foo');
|
||||
const right = ref('bar');
|
||||
|
||||
syncRef(left, right);
|
||||
|
||||
// immediate sync: ltr propagates left -> right on setup
|
||||
expect(right.value).toBe('foo');
|
||||
|
||||
left.value = 'left-change';
|
||||
expect(right.value).toBe('left-change');
|
||||
|
||||
right.value = 'right-change';
|
||||
expect(left.value).toBe('right-change');
|
||||
});
|
||||
|
||||
it('does not enter an infinite feedback loop', () => {
|
||||
const left = ref(0);
|
||||
const right = ref(0);
|
||||
|
||||
syncRef(left, right);
|
||||
|
||||
left.value = 1;
|
||||
expect(left.value).toBe(1);
|
||||
expect(right.value).toBe(1);
|
||||
|
||||
right.value = 2;
|
||||
expect(left.value).toBe(2);
|
||||
expect(right.value).toBe(2);
|
||||
});
|
||||
|
||||
it('respects direction: ltr (one-way left -> right)', () => {
|
||||
const left = ref('a');
|
||||
const right = ref('b');
|
||||
|
||||
syncRef(left, right, { direction: 'ltr' });
|
||||
|
||||
// immediate ltr sync
|
||||
expect(right.value).toBe('a');
|
||||
|
||||
left.value = 'c';
|
||||
expect(right.value).toBe('c');
|
||||
|
||||
// right does not propagate back to left
|
||||
right.value = 'd';
|
||||
expect(left.value).toBe('c');
|
||||
});
|
||||
|
||||
it('respects direction: rtl (one-way right -> left)', () => {
|
||||
const left = ref('a');
|
||||
const right = ref('b');
|
||||
|
||||
syncRef(left, right, { direction: 'rtl' });
|
||||
|
||||
// immediate rtl sync
|
||||
expect(left.value).toBe('b');
|
||||
|
||||
right.value = 'c';
|
||||
expect(left.value).toBe('c');
|
||||
|
||||
// left does not propagate to right
|
||||
left.value = 'd';
|
||||
expect(right.value).toBe('c');
|
||||
});
|
||||
|
||||
it('applies transforms for both directions', () => {
|
||||
const left = ref(10);
|
||||
const right = ref('0');
|
||||
|
||||
syncRef(left, right, {
|
||||
transform: {
|
||||
ltr: value => String(value),
|
||||
rtl: value => Number(value),
|
||||
},
|
||||
});
|
||||
|
||||
// immediate: left (10) -> right ('10')
|
||||
expect(right.value).toBe('10');
|
||||
|
||||
left.value = 42;
|
||||
expect(right.value).toBe('42');
|
||||
|
||||
right.value = '7';
|
||||
expect(left.value).toBe(7);
|
||||
});
|
||||
|
||||
it('applies a one-way ltr transform', () => {
|
||||
const count = ref(0);
|
||||
const text = ref('');
|
||||
|
||||
syncRef(count, text, {
|
||||
direction: 'ltr',
|
||||
transform: { ltr: value => `count: ${value}` },
|
||||
});
|
||||
|
||||
expect(text.value).toBe('count: 0');
|
||||
|
||||
count.value = 5;
|
||||
expect(text.value).toBe('count: 5');
|
||||
});
|
||||
|
||||
it('skips the immediate sync when immediate is false', () => {
|
||||
const left = ref('initial-left');
|
||||
const right = ref('initial-right');
|
||||
|
||||
syncRef(left, right, { immediate: false });
|
||||
|
||||
// no initial sync
|
||||
expect(right.value).toBe('initial-right');
|
||||
expect(left.value).toBe('initial-left');
|
||||
|
||||
left.value = 'updated';
|
||||
expect(right.value).toBe('updated');
|
||||
});
|
||||
|
||||
it('stops synchronizing after stop() is called', () => {
|
||||
const left = ref(0);
|
||||
const right = ref(0);
|
||||
|
||||
const { stop } = syncRef(left, right);
|
||||
|
||||
left.value = 1;
|
||||
expect(right.value).toBe(1);
|
||||
|
||||
stop();
|
||||
|
||||
left.value = 2;
|
||||
right.value = 3;
|
||||
expect(right.value).toBe(3);
|
||||
expect(left.value).toBe(2);
|
||||
});
|
||||
|
||||
it('supports async flush (pre) with nextTick', async () => {
|
||||
const left = ref('x');
|
||||
const right = ref('y');
|
||||
|
||||
syncRef(left, right, { flush: 'pre', immediate: false });
|
||||
|
||||
left.value = 'changed';
|
||||
// pre flush is async
|
||||
expect(right.value).toBe('y');
|
||||
|
||||
await nextTick();
|
||||
expect(right.value).toBe('changed');
|
||||
|
||||
right.value = 'back';
|
||||
await nextTick();
|
||||
expect(left.value).toBe('back');
|
||||
});
|
||||
|
||||
it('syncs deep object changes when deep is enabled', () => {
|
||||
const left = ref({ nested: { count: 0 } });
|
||||
const right = ref({ nested: { count: 0 } });
|
||||
|
||||
syncRef(left, right, { deep: true });
|
||||
|
||||
left.value.nested.count = 5;
|
||||
expect(right.value.nested.count).toBe(5);
|
||||
});
|
||||
|
||||
it('works inside an effect scope and is disposed with it', () => {
|
||||
const left = ref(0);
|
||||
const right = ref(0);
|
||||
const scope = effectScope();
|
||||
|
||||
scope.run(() => {
|
||||
syncRef(left, right);
|
||||
});
|
||||
|
||||
left.value = 1;
|
||||
expect(right.value).toBe(1);
|
||||
|
||||
scope.stop();
|
||||
|
||||
left.value = 2;
|
||||
// watchers torn down with the scope
|
||||
expect(right.value).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { Ref, WatchStopHandle } from 'vue';
|
||||
import type { ConfigurableFlush } from '@/types';
|
||||
import { watchIgnorable } from '@/composables/watch/watchIgnorable';
|
||||
|
||||
export type SyncRefDirection = 'ltr' | 'rtl' | 'both';
|
||||
|
||||
/**
|
||||
* Conversion functions used when the two refs hold different value types.
|
||||
*
|
||||
* - `ltr` maps a left value to a right value (used when the left ref changes).
|
||||
* - `rtl` maps a right value to a left value (used when the right ref changes).
|
||||
*/
|
||||
export interface SyncRefTransform<L, R> {
|
||||
/**
|
||||
* Transform a left value into a right value. Required for `ltr`/`both` when `L !== R`.
|
||||
*/
|
||||
ltr?: (left: L) => R;
|
||||
/**
|
||||
* Transform a right value into a left value. Required for `rtl`/`both` when `L !== R`.
|
||||
*/
|
||||
rtl?: (right: R) => L;
|
||||
}
|
||||
|
||||
export interface SyncRefOptions<L, R> extends ConfigurableFlush {
|
||||
/**
|
||||
* Watch the refs deeply.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
deep?: boolean;
|
||||
/**
|
||||
* Sync the values immediately on setup (in the chosen direction).
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
immediate?: boolean;
|
||||
/**
|
||||
* Direction of synchronization.
|
||||
*
|
||||
* - `both` keeps both refs in sync.
|
||||
* - `ltr` only propagates `left` -> `right`.
|
||||
* - `rtl` only propagates `right` -> `left`.
|
||||
*
|
||||
* @default 'both'
|
||||
*/
|
||||
direction?: SyncRefDirection;
|
||||
/**
|
||||
* Conversion functions to apply when the refs hold different value types.
|
||||
* Provide `ltr` and/or `rtl` matching the active {@link SyncRefOptions.direction}.
|
||||
*/
|
||||
transform?: SyncRefTransform<L, R>;
|
||||
}
|
||||
|
||||
export interface SyncRefReturn {
|
||||
/**
|
||||
* Stop all underlying watchers. Synchronization cannot be resumed afterwards.
|
||||
*/
|
||||
stop: WatchStopHandle;
|
||||
}
|
||||
|
||||
const identity = <T>(value: T): T => value;
|
||||
type IgnoredUpdater = (updater: () => void) => void;
|
||||
const runDirect: IgnoredUpdater = updater => updater();
|
||||
|
||||
/**
|
||||
* @name syncRef
|
||||
* @category Reactivity
|
||||
* @description Keeps two refs in sync (two-way by default, or one-way via `direction`), with optional value transforms.
|
||||
*
|
||||
* @param {Ref<L>} left The left ref to synchronize
|
||||
* @param {Ref<R>} right The right ref to synchronize
|
||||
* @param {SyncRefOptions<L, R>} [options={}] `direction`, `transform`, `immediate`, `flush`, and `deep`
|
||||
* @returns {SyncRefReturn} `{ stop }` to tear down the synchronization
|
||||
*
|
||||
* @example
|
||||
* const left = ref('hello');
|
||||
* const right = ref('hello');
|
||||
* syncRef(left, right);
|
||||
*
|
||||
* left.value = 'world'; // right.value === 'world'
|
||||
* right.value = 'foo'; // left.value === 'foo'
|
||||
*
|
||||
* @example
|
||||
* // One-way with a transform (left number -> right string)
|
||||
* const count = ref(0);
|
||||
* const text = ref('0');
|
||||
* syncRef(count, text, {
|
||||
* direction: 'ltr',
|
||||
* transform: { ltr: value => String(value) },
|
||||
* });
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function syncRef<L, R = L>(
|
||||
left: Ref<L>,
|
||||
right: Ref<R>,
|
||||
options: SyncRefOptions<L, R> = {},
|
||||
): SyncRefReturn {
|
||||
const {
|
||||
flush = 'sync',
|
||||
deep = false,
|
||||
immediate = true,
|
||||
direction = 'both',
|
||||
transform = {},
|
||||
} = options;
|
||||
|
||||
// Identity is the safe fallback when both refs share the same value type.
|
||||
const transformLTR = (transform.ltr ?? identity) as (left: L) => R;
|
||||
const transformRTL = (transform.rtl ?? identity) as (right: R) => L;
|
||||
|
||||
const syncLTR = direction === 'both' || direction === 'ltr';
|
||||
const syncRTL = direction === 'both' || direction === 'rtl';
|
||||
|
||||
// Each callback wraps its cross-write in the OPPOSITE watcher's
|
||||
// `ignoreUpdates` so a programmatic write never re-triggers the watcher that
|
||||
// observes the written ref — preventing feedback loops without pausing every
|
||||
// watcher on every change. The handles are bound after both watchers exist,
|
||||
// so callbacks read them through these late-bound slots.
|
||||
let ignoreLeftWrites: IgnoredUpdater = runDirect;
|
||||
let ignoreRightWrites: IgnoredUpdater = runDirect;
|
||||
|
||||
const watchers: WatchStopHandle[] = [];
|
||||
|
||||
if (syncLTR) {
|
||||
const { stop, ignoreUpdates } = watchIgnorable(
|
||||
left,
|
||||
(newValue) => {
|
||||
// Writing `right`; suppress the rtl watcher.
|
||||
ignoreRightWrites(() => {
|
||||
right.value = transformLTR(newValue as L);
|
||||
});
|
||||
},
|
||||
{ flush, deep, immediate },
|
||||
);
|
||||
|
||||
// Writes to `left` (done by the rtl watcher) must be ignored here.
|
||||
ignoreLeftWrites = ignoreUpdates;
|
||||
watchers.push(stop);
|
||||
}
|
||||
|
||||
if (syncRTL) {
|
||||
// The ltr watcher already performed the initial sync, so a `both` setup
|
||||
// must not immediately back-sync (it would clobber `left` from `right`).
|
||||
const rtlImmediate = direction === 'rtl' ? immediate : false;
|
||||
|
||||
const { stop, ignoreUpdates } = watchIgnorable(
|
||||
right,
|
||||
(newValue) => {
|
||||
// Writing `left`; suppress the ltr watcher.
|
||||
ignoreLeftWrites(() => {
|
||||
left.value = transformRTL(newValue as R);
|
||||
});
|
||||
},
|
||||
{ flush, deep, immediate: rtlImmediate },
|
||||
);
|
||||
|
||||
// Writes to `right` (done by the ltr watcher) must be ignored here.
|
||||
ignoreRightWrites = ignoreUpdates;
|
||||
watchers.push(stop);
|
||||
}
|
||||
|
||||
const stop = (): void => {
|
||||
for (const stopWatcher of watchers) stopWatcher();
|
||||
};
|
||||
|
||||
return { stop };
|
||||
}
|
||||
Reference in New Issue
Block a user