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 { /** * 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 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; } export interface SyncRefReturn { /** * Stop all underlying watchers. Synchronization cannot be resumed afterwards. */ stop: WatchStopHandle; } const identity = (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} left The left ref to synchronize * @param {Ref} right The right ref to synchronize * @param {SyncRefOptions} [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( left: Ref, right: Ref, options: SyncRefOptions = {}, ): 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 }; }