fix(vue): eslint/tsconfig migration + resolve type errors
@robonen/vue (toolkit): migrate to eslint flat config + composite tsconfig; fix composable + test type errors (writable computed returns, null guards, overload-compatible signatures, typed test helpers) — all type-level.
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { nextTick, reactive, ref } from 'vue';
|
||||
import { cloneFnDefault, useCloned } from '.';
|
||||
|
||||
describe(useCloned, () => {
|
||||
it('clones the initial source value (deep, not referentially equal)', () => {
|
||||
const source = ref({ nested: { count: 0 } });
|
||||
const { cloned } = useCloned(source);
|
||||
|
||||
expect(cloned.value).toEqual({ nested: { count: 0 } });
|
||||
expect(cloned.value).not.toBe(source.value);
|
||||
expect(cloned.value.nested).not.toBe(source.value.nested);
|
||||
});
|
||||
|
||||
it('re-clones automatically when the source ref changes', async () => {
|
||||
const source = ref({ count: 0 });
|
||||
const { cloned } = useCloned(source);
|
||||
|
||||
source.value = { count: 5 };
|
||||
await nextTick();
|
||||
|
||||
expect(cloned.value).toEqual({ count: 5 });
|
||||
expect(cloned.value).not.toBe(source.value);
|
||||
});
|
||||
|
||||
it('re-clones automatically when a deep source property changes', async () => {
|
||||
const source = ref({ nested: { count: 0 } });
|
||||
const { cloned } = useCloned(source);
|
||||
|
||||
source.value.nested.count = 9;
|
||||
await nextTick();
|
||||
|
||||
expect(cloned.value.nested.count).toBe(9);
|
||||
});
|
||||
|
||||
it('tracks modification of the cloned value via isModified', () => {
|
||||
const source = ref({ count: 0 });
|
||||
const { cloned, isModified } = useCloned(source);
|
||||
|
||||
expect(isModified.value).toBeFalsy();
|
||||
|
||||
cloned.value.count = 1;
|
||||
|
||||
expect(isModified.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not set isModified when the change came from a source sync', async () => {
|
||||
const source = ref({ count: 0 });
|
||||
const { isModified } = useCloned(source);
|
||||
|
||||
source.value = { count: 1 };
|
||||
await nextTick();
|
||||
|
||||
expect(isModified.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('sync() re-clones from source and resets isModified', () => {
|
||||
const source = ref({ count: 0 });
|
||||
const { cloned, isModified, sync } = useCloned(source);
|
||||
|
||||
cloned.value.count = 42;
|
||||
expect(isModified.value).toBeTruthy();
|
||||
|
||||
sync();
|
||||
|
||||
expect(cloned.value).toEqual({ count: 0 });
|
||||
expect(isModified.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('manual mode does not auto-sync on source change', async () => {
|
||||
const source = ref({ count: 0 });
|
||||
const { cloned, sync } = useCloned(source, { manual: true });
|
||||
|
||||
expect(cloned.value).toEqual({ count: 0 });
|
||||
|
||||
source.value = { count: 100 };
|
||||
await nextTick();
|
||||
|
||||
// still the original clone, manual mode ignores source changes
|
||||
expect(cloned.value).toEqual({ count: 0 });
|
||||
|
||||
sync();
|
||||
expect(cloned.value).toEqual({ count: 100 });
|
||||
});
|
||||
|
||||
it('supports a getter source', async () => {
|
||||
const state = reactive({ count: 1 });
|
||||
const { cloned } = useCloned(() => ({ count: state.count }));
|
||||
|
||||
expect(cloned.value).toEqual({ count: 1 });
|
||||
|
||||
state.count = 2;
|
||||
await nextTick();
|
||||
|
||||
expect(cloned.value).toEqual({ count: 2 });
|
||||
});
|
||||
|
||||
it('supports a plain (non-reactive) source value', () => {
|
||||
const { cloned, isModified } = useCloned({ count: 7 });
|
||||
|
||||
expect(cloned.value).toEqual({ count: 7 });
|
||||
expect(isModified.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it('uses a custom clone function when provided', () => {
|
||||
const clone = vi.fn((s: { count: number }) => ({ count: s.count + 1 }));
|
||||
const source = ref({ count: 10 });
|
||||
const { cloned } = useCloned(source, { clone });
|
||||
|
||||
expect(clone).toHaveBeenCalled();
|
||||
expect(cloned.value).toEqual({ count: 11 });
|
||||
});
|
||||
|
||||
it('respects immediate: false (no clone until source changes)', async () => {
|
||||
const source = ref({ count: 0 });
|
||||
const { cloned } = useCloned(source, { immediate: false });
|
||||
|
||||
// not yet synced
|
||||
expect(cloned.value).toBeUndefined();
|
||||
|
||||
source.value = { count: 3 };
|
||||
await nextTick();
|
||||
|
||||
expect(cloned.value).toEqual({ count: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe(cloneFnDefault, () => {
|
||||
it('deep clones via structuredClone when available', () => {
|
||||
const input = { a: 1, b: { c: [1, 2, 3] }, d: new Date(0) };
|
||||
const out = cloneFnDefault(input);
|
||||
|
||||
expect(out).toEqual(input);
|
||||
expect(out).not.toBe(input);
|
||||
expect(out.b).not.toBe(input.b);
|
||||
expect(out.d).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('falls back to JSON when structuredClone is unavailable (SSR / old runtime)', () => {
|
||||
const original = globalThis.structuredClone;
|
||||
// simulate environment without structuredClone
|
||||
(globalThis as { structuredClone?: unknown }).structuredClone = undefined;
|
||||
|
||||
try {
|
||||
const input = { a: 1, b: { c: 2 } };
|
||||
const out = cloneFnDefault(input);
|
||||
|
||||
expect(out).toEqual(input);
|
||||
expect(out).not.toBe(input);
|
||||
expect(out.b).not.toBe(input.b);
|
||||
}
|
||||
finally {
|
||||
globalThis.structuredClone = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to JSON when the value is not structured-cloneable', () => {
|
||||
// functions are not structured-cloneable; JSON drops them
|
||||
const input = { keep: 1, fn: () => {} };
|
||||
const out = cloneFnDefault(input) as { keep: number; fn?: unknown };
|
||||
|
||||
expect(out.keep).toBe(1);
|
||||
expect(out.fn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { isRef, ref, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref, WatchOptions } from 'vue';
|
||||
import { isFunction } from '@robonen/stdlib';
|
||||
|
||||
export type CloneFn<Source, Target = Source> = (source: Source) => Target;
|
||||
|
||||
export interface UseClonedOptions<T = unknown> extends WatchOptions {
|
||||
/**
|
||||
* Custom clone function.
|
||||
*
|
||||
* By default uses `structuredClone` when available, falling back to
|
||||
* `JSON.parse(JSON.stringify(value))`.
|
||||
*/
|
||||
clone?: CloneFn<T>;
|
||||
|
||||
/**
|
||||
* Manually sync the cloned ref instead of watching the source.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
manual?: boolean;
|
||||
}
|
||||
|
||||
export interface UseClonedReturn<T> {
|
||||
/**
|
||||
* The cloned, mutable ref.
|
||||
*/
|
||||
cloned: Ref<T>;
|
||||
|
||||
/**
|
||||
* Whether the cloned data has been modified since the last sync.
|
||||
*/
|
||||
isModified: Ref<boolean>;
|
||||
|
||||
/**
|
||||
* Sync the cloned data with the source manually.
|
||||
*/
|
||||
sync: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default clone implementation. Prefers the structured clone algorithm and
|
||||
* falls back to a JSON round-trip when `structuredClone` is unavailable
|
||||
* (older runtimes / SSR) or the value is not structured-cloneable.
|
||||
*/
|
||||
export function cloneFnDefault<T>(source: T): T {
|
||||
if (typeof structuredClone === 'function') {
|
||||
try {
|
||||
return structuredClone(source);
|
||||
}
|
||||
catch {
|
||||
// value contains functions, symbols, etc. — fall back to JSON.
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(source)) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useCloned
|
||||
* @category Reactivity
|
||||
* @description Reactive deep clone of a source with a mutable cloned ref, modification tracking, and manual mode.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<T>} source The reactive source to clone (ref, getter, or plain value)
|
||||
* @param {UseClonedOptions<T>} [options={}] Options: `clone`, `manual`, and watch options (`deep`, `immediate`, `flush`)
|
||||
* @returns {UseClonedReturn<T>} The cloned ref, an `isModified` flag, and a `sync` function
|
||||
*
|
||||
* @example
|
||||
* const original = ref({ count: 0 });
|
||||
* const { cloned, isModified, sync } = useCloned(original);
|
||||
* cloned.value.count = 1; // isModified.value === true
|
||||
* sync(); // re-clone from source, isModified.value === false
|
||||
*
|
||||
* @example
|
||||
* const { cloned, sync } = useCloned(source, { manual: true });
|
||||
* // cloned only updates when sync() is called
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useCloned<T>(
|
||||
source: MaybeRefOrGetter<T>,
|
||||
options: UseClonedOptions<T> = {},
|
||||
): UseClonedReturn<T> {
|
||||
const cloned = ref<T>() as Ref<T>;
|
||||
const isModified = ref(false);
|
||||
let lastSync = false;
|
||||
|
||||
const {
|
||||
manual,
|
||||
clone = cloneFnDefault,
|
||||
deep = true,
|
||||
immediate = true,
|
||||
} = options;
|
||||
|
||||
function sync(): void {
|
||||
lastSync = true;
|
||||
isModified.value = false;
|
||||
cloned.value = clone(toValue(source));
|
||||
}
|
||||
|
||||
watch(cloned, () => {
|
||||
if (lastSync) {
|
||||
lastSync = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isModified.value = true;
|
||||
}, {
|
||||
deep: true,
|
||||
flush: 'sync',
|
||||
});
|
||||
|
||||
if (!manual && (isRef(source) || isFunction(source))) {
|
||||
watch(source, sync, {
|
||||
...options,
|
||||
deep,
|
||||
immediate,
|
||||
});
|
||||
}
|
||||
else {
|
||||
sync();
|
||||
}
|
||||
|
||||
return { cloned, isModified, sync };
|
||||
}
|
||||
Reference in New Issue
Block a user