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,265 @@
import { describe, expect, it, vi } from 'vitest';
import { effectScope, isReadonly, nextTick, reactive, ref } from 'vue';
import { pausableWatch, watchPausable } from '.';
import { debounceFilter } from '@/utils/filters';
describe(watchPausable, () => {
it('invokes the callback on source change when active', async () => {
const scope = effectScope();
await scope.run(async () => {
const count = ref(0);
const cb = vi.fn();
watchPausable(count, cb);
count.value = 1;
await nextTick();
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.anything());
});
scope.stop();
});
it('starts active by default', () => {
const scope = effectScope();
scope.run(() => {
const { isActive } = watchPausable(ref(0), () => {});
expect(isActive.value).toBeTruthy();
});
scope.stop();
});
it('does not invoke the callback while paused', async () => {
const scope = effectScope();
await scope.run(async () => {
const count = ref(0);
const cb = vi.fn();
const { pause } = watchPausable(count, cb);
pause();
count.value = 1;
await nextTick();
expect(cb).not.toHaveBeenCalled();
});
scope.stop();
});
it('isActive reflects pause/resume', () => {
const scope = effectScope();
scope.run(() => {
const { pause, resume, isActive } = watchPausable(ref(0), () => {});
expect(isActive.value).toBeTruthy();
pause();
expect(isActive.value).toBeFalsy();
resume();
expect(isActive.value).toBeTruthy();
});
scope.stop();
});
it('resumes reacting to changes after resume', async () => {
const scope = effectScope();
await scope.run(async () => {
const count = ref(0);
const cb = vi.fn();
const { pause, resume } = watchPausable(count, cb);
pause();
count.value = 1;
await nextTick();
expect(cb).not.toHaveBeenCalled();
resume();
count.value = 2;
await nextTick();
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenLastCalledWith(2, 1, expect.anything());
});
scope.stop();
});
it('does not replay changes that happened while paused', async () => {
const scope = effectScope();
await scope.run(async () => {
const count = ref(0);
const cb = vi.fn();
const { pause, resume } = watchPausable(count, cb);
pause();
count.value = 1;
count.value = 2;
await nextTick();
resume();
await nextTick();
// Resume alone must not fire the callback for the missed changes.
expect(cb).not.toHaveBeenCalled();
});
scope.stop();
});
it('respects initialState: paused', async () => {
const scope = effectScope();
await scope.run(async () => {
const count = ref(0);
const cb = vi.fn();
const { isActive, resume } = watchPausable(count, cb, { initialState: 'paused' });
expect(isActive.value).toBeFalsy();
count.value = 1;
await nextTick();
expect(cb).not.toHaveBeenCalled();
resume();
count.value = 2;
await nextTick();
expect(cb).toHaveBeenCalledTimes(1);
});
scope.stop();
});
it('stop() halts the watcher permanently', async () => {
const scope = effectScope();
await scope.run(async () => {
const count = ref(0);
const cb = vi.fn();
const { stop, resume } = watchPausable(count, cb);
stop();
count.value = 1;
await nextTick();
expect(cb).not.toHaveBeenCalled();
// resume cannot revive a stopped watcher
resume();
count.value = 2;
await nextTick();
expect(cb).not.toHaveBeenCalled();
});
scope.stop();
});
it('returns a readonly isActive ref', () => {
const scope = effectScope();
scope.run(() => {
const { isActive } = watchPausable(ref(0), () => {});
expect(isReadonly(isActive)).toBeTruthy();
});
scope.stop();
});
it('supports multiple sources', async () => {
const scope = effectScope();
await scope.run(async () => {
const a = ref(0);
const b = ref('x');
const cb = vi.fn();
watchPausable([a, b], cb);
a.value = 1;
await nextTick();
expect(cb).toHaveBeenLastCalledWith([1, 'x'], [0, 'x'], expect.anything());
b.value = 'y';
await nextTick();
expect(cb).toHaveBeenLastCalledWith([1, 'y'], [1, 'x'], expect.anything());
});
scope.stop();
});
it('supports a getter source', async () => {
const scope = effectScope();
await scope.run(async () => {
const state = reactive({ n: 1 });
const cb = vi.fn();
watchPausable(() => state.n, cb);
state.n = 2;
await nextTick();
expect(cb).toHaveBeenLastCalledWith(2, 1, expect.anything());
});
scope.stop();
});
it('supports a reactive object source with deep', async () => {
const scope = effectScope();
await scope.run(async () => {
const state = reactive({ nested: { n: 1 } });
const cb = vi.fn();
watchPausable(state, cb, { deep: true });
state.nested.n = 2;
await nextTick();
expect(cb).toHaveBeenCalledTimes(1);
});
scope.stop();
});
it('fires synchronously with flush: sync', () => {
const scope = effectScope();
scope.run(() => {
const count = ref(0);
const cb = vi.fn();
watchPausable(count, cb, { flush: 'sync' });
count.value = 1;
expect(cb).toHaveBeenCalledTimes(1);
});
scope.stop();
});
it('honors immediate option', () => {
const scope = effectScope();
scope.run(() => {
const count = ref(0);
const cb = vi.fn();
watchPausable(count, cb, { immediate: true, flush: 'sync' });
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenLastCalledWith(0, undefined, expect.anything());
});
scope.stop();
});
it('composes with a custom eventFilter (debounce)', async () => {
vi.useFakeTimers();
const scope = effectScope();
scope.run(() => {
const count = ref(0);
const cb = vi.fn();
const { pause } = watchPausable(count, cb, {
eventFilter: debounceFilter(100),
flush: 'sync',
});
count.value = 1;
count.value = 2;
expect(cb).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(cb).toHaveBeenCalledTimes(1);
// While paused the filter must not even be reached.
pause();
count.value = 3;
vi.advanceTimersByTime(100);
expect(cb).toHaveBeenCalledTimes(1);
});
scope.stop();
vi.useRealTimers();
});
it('pausableWatch is an alias for watchPausable', () => {
expect(pausableWatch).toBe(watchPausable);
});
it('works outside an effect scope (SSR-style, manual stop)', async () => {
const count = ref(0);
const cb = vi.fn();
const { stop } = watchPausable(count, cb);
count.value = 1;
await nextTick();
expect(cb).toHaveBeenCalledTimes(1);
stop();
});
});
@@ -0,0 +1,126 @@
import { ref, shallowReadonly, watch } from 'vue';
import type {
MultiWatchSources,
Ref,
WatchCallback,
WatchOptions,
WatchSource,
WatchStopHandle,
} from 'vue';
import { bypassFilter, createFilterWrapper } from '@/utils/filters';
import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters';
type MapSources<T> = {
[K in keyof T]: T[K] extends WatchSource<infer V> ? V : never;
};
type MapOldSources<T, Immediate> = {
[K in keyof T]: T[K] extends WatchSource<infer V>
? Immediate extends true ? V | undefined : V
: never;
};
export interface UseWatchPausableOptions<Immediate>
extends WatchOptions<Immediate>, ConfigurableEventFilter {
/**
* Whether the watcher starts in an active (running) or paused state.
*
* @default 'active'
*/
initialState?: 'active' | 'paused';
}
export interface UseWatchPausableReturn {
/**
* Whether the watcher is currently active. While `false`, source changes are
* ignored and the callback is not invoked.
*/
isActive: Readonly<Ref<boolean>>;
/**
* Pause the watcher. Changes to the source are ignored until {@link resume}.
*/
pause: () => void;
/**
* Resume the watcher so it reacts to source changes again.
*/
resume: () => void;
/**
* Stop the watcher entirely. It cannot be restarted afterwards.
*/
stop: WatchStopHandle;
}
/**
* @name watchPausable
* @category Reactivity
* @description A `watch` whose execution can be paused and resumed on demand via a pausable event filter.
*
* @param {WatchSource | WatchSource[] | object} source The watch source (ref, getter, reactive object, or an array of sources)
* @param {WatchCallback} cb The callback invoked when an active source changes
* @param {UseWatchPausableOptions} [options={}] Watch options plus `eventFilter` and `initialState`
* @returns {UseWatchPausableReturn} `{ stop, pause, resume, isActive }`
*
* @example
* const count = ref(0);
* const { pause, resume, isActive } = watchPausable(count, (value) => {
* console.log('changed to', value);
* });
*
* pause();
* count.value++; // callback not called
* resume();
* count.value++; // callback called
*
* @since 0.0.15
*/
export function watchPausable<T extends Readonly<MultiWatchSources>, Immediate extends Readonly<boolean> = false>(
sources: [...T],
cb: WatchCallback<MapSources<T>, MapOldSources<T, Immediate>>,
options?: UseWatchPausableOptions<Immediate>,
): UseWatchPausableReturn;
export function watchPausable<T, Immediate extends Readonly<boolean> = false>(
source: WatchSource<T>,
cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
options?: UseWatchPausableOptions<Immediate>,
): UseWatchPausableReturn;
export function watchPausable<T extends object, Immediate extends Readonly<boolean> = false>(
source: T,
cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
options?: UseWatchPausableOptions<Immediate>,
): UseWatchPausableReturn;
export function watchPausable<Immediate extends Readonly<boolean> = false>(
source: any,
cb: any,
options: UseWatchPausableOptions<Immediate> = {},
): UseWatchPausableReturn {
const {
eventFilter: filter = bypassFilter,
initialState = 'active',
...watchOptions
} = options;
const isActive = ref(initialState !== 'paused');
const eventFilter: EventFilter = (invoke) => {
if (isActive.value)
filter(invoke);
};
const stop = watch(
source,
createFilterWrapper(eventFilter, cb),
watchOptions,
);
return {
isActive: shallowReadonly(isActive),
pause: () => { isActive.value = false; },
resume: () => { isActive.value = true; },
stop,
};
}
/**
* Alias for {@link watchPausable}.
*/
export const pausableWatch = watchPausable;