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:
2026-06-07 16:29:39 +07:00
parent e6919de29e
commit c7644ade69
203 changed files with 23016 additions and 141 deletions
@@ -0,0 +1,270 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, reactive, ref } from 'vue';
import { debouncedWatch, watchDebounced } from '.';
describe(watchDebounced, () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('does not fire before the source changes', () => {
const count = ref(0);
const cb = vi.fn();
watchDebounced(count, cb, { debounce: 100, flush: 'sync' });
vi.advanceTimersByTime(200);
expect(cb).not.toHaveBeenCalled();
});
it('defers the callback by the debounce delay', () => {
const count = ref(0);
const cb = vi.fn();
watchDebounced(count, cb, { debounce: 100, flush: 'sync' });
count.value = 1;
expect(cb).not.toHaveBeenCalled();
vi.advanceTimersByTime(50);
expect(cb).not.toHaveBeenCalled();
vi.advanceTimersByTime(50);
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function));
});
it('coalesces rapid changes into a single trailing call', () => {
const count = ref(0);
const cb = vi.fn();
watchDebounced(count, cb, { debounce: 100, flush: 'sync' });
count.value = 1;
vi.advanceTimersByTime(80);
count.value = 2;
vi.advanceTimersByTime(80);
count.value = 3;
expect(cb).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(cb).toHaveBeenCalledTimes(1);
// The filtered callback receives the args of the latest watch trigger:
// new value 3, and old value 2 (the source value just before the last change).
expect(cb).toHaveBeenLastCalledWith(3, 2, expect.any(Function));
});
it('fires synchronously with no debounce (debounce = 0)', () => {
const count = ref(0);
const cb = vi.fn();
watchDebounced(count, cb, { flush: 'sync' });
count.value = 1;
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function));
});
it('enforces maxWait under sustained changes', () => {
const count = ref(0);
const cb = vi.fn();
watchDebounced(count, cb, { debounce: 100, maxWait: 250, flush: 'sync' });
// Keep changing before the debounce timer can ever settle.
count.value = 1;
vi.advanceTimersByTime(80);
count.value = 2;
vi.advanceTimersByTime(80);
count.value = 3;
vi.advanceTimersByTime(80);
// 240ms elapsed, debounce has reset each time, but maxWait is 250ms.
expect(cb).not.toHaveBeenCalled();
count.value = 4;
vi.advanceTimersByTime(20);
// maxWait (250ms) elapsed -> forced invocation with the latest trigger args.
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenLastCalledWith(4, 3, expect.any(Function));
});
it('does not double-fire when maxWait and debounce settle together', () => {
const count = ref(0);
const cb = vi.fn();
watchDebounced(count, cb, { debounce: 100, maxWait: 200, flush: 'sync' });
count.value = 1;
// debounce settles at 100ms; maxWait would be at 200ms but is cleared.
vi.advanceTimersByTime(100);
expect(cb).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(200);
expect(cb).toHaveBeenCalledTimes(1);
});
it('supports a reactive debounce delay', () => {
const count = ref(0);
const delay = ref(100);
const cb = vi.fn();
watchDebounced(count, cb, { debounce: delay, flush: 'sync' });
count.value = 1;
vi.advanceTimersByTime(100);
expect(cb).toHaveBeenCalledTimes(1);
delay.value = 300;
count.value = 2;
vi.advanceTimersByTime(100);
expect(cb).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(200);
expect(cb).toHaveBeenCalledTimes(2);
});
it('works with a getter source', () => {
const count = ref(0);
const cb = vi.fn();
watchDebounced(() => count.value * 2, cb, { debounce: 100, flush: 'sync' });
count.value = 5;
vi.advanceTimersByTime(100);
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenLastCalledWith(10, 0, expect.any(Function));
});
it('works with an array of sources', () => {
const a = ref(0);
const b = ref('x');
const cb = vi.fn();
watchDebounced([a, b], cb, { debounce: 100, flush: 'sync' });
a.value = 1;
vi.advanceTimersByTime(100);
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenLastCalledWith([1, 'x'], [0, 'x'], expect.any(Function));
});
it('works with a reactive object source and deep option', () => {
const state = reactive({ nested: { value: 0 } });
const cb = vi.fn();
watchDebounced(state, cb, { debounce: 100, deep: true, flush: 'sync' });
state.nested.value = 1;
vi.advanceTimersByTime(100);
expect(cb).toHaveBeenCalledTimes(1);
});
it('fires immediately with the immediate option', () => {
const count = ref(0);
const cb = vi.fn();
watchDebounced(count, cb, { debounce: 100, immediate: true, flush: 'sync' });
// immediate runs through the filter synchronously only when debounce=0;
// with a positive debounce the immediate run is also debounced.
vi.advanceTimersByTime(100);
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenLastCalledWith(0, undefined, expect.any(Function));
});
it('respects a custom flush timing', async () => {
const count = ref(0);
const cb = vi.fn();
watchDebounced(count, cb, { debounce: 100, flush: 'post' });
count.value = 1;
await nextTick();
expect(cb).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(cb).toHaveBeenCalledTimes(1);
});
it('returns a handle that stops watching', () => {
const count = ref(0);
const cb = vi.fn();
const stop = watchDebounced(count, cb, { debounce: 100, flush: 'sync' });
stop();
count.value = 1;
vi.advanceTimersByTime(200);
expect(cb).not.toHaveBeenCalled();
});
it('stops watching when the owning scope is disposed', () => {
const count = ref(0);
const cb = vi.fn();
const scope = effectScope();
scope.run(() => watchDebounced(count, cb, { debounce: 100, flush: 'sync' }));
scope.stop();
count.value = 1;
vi.advanceTimersByTime(200);
expect(cb).not.toHaveBeenCalled();
});
it('honours a caller-supplied eventFilter over debounce/maxWait', () => {
const count = ref(0);
const cb = vi.fn();
const passthrough = vi.fn((invoke: () => void) => invoke());
watchDebounced(count, cb, {
debounce: 1000,
eventFilter: passthrough,
flush: 'sync',
});
count.value = 1;
// The custom filter invokes immediately, bypassing the debounce timer.
expect(passthrough).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function));
});
it('passes onCleanup to the callback', () => {
const count = ref(0);
const cleanup = vi.fn();
watchDebounced(count, (_value, _old, onCleanup) => {
onCleanup(cleanup);
}, { debounce: 100, flush: 'sync' });
count.value = 1;
vi.advanceTimersByTime(100);
count.value = 2;
vi.advanceTimersByTime(100);
// cleanup registered on the first settled run fires before the second.
expect(cleanup).toHaveBeenCalledTimes(1);
});
it('exposes debouncedWatch as an alias', () => {
expect(debouncedWatch).toBe(watchDebounced);
});
it('runs in a non-DOM scope without touching globals (SSR-safe)', () => {
const count = ref(0);
const cb = vi.fn();
const scope = effectScope();
expect(() => {
scope.run(() => watchDebounced(count, cb, { debounce: 100, flush: 'sync' }));
}).not.toThrow();
scope.stop();
});
});