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,348 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope } from 'vue';
|
||||
import { useCloseWatcher } from '.';
|
||||
|
||||
/**
|
||||
* Minimal fake of the native `CloseWatcher`: tracks instances so tests can drive
|
||||
* close/destroy and assert recreation behaviour.
|
||||
*/
|
||||
function createCloseWatcherStub() {
|
||||
const instances: FakeCloseWatcher[] = [];
|
||||
|
||||
class FakeCloseWatcher {
|
||||
listeners = new Map<string, Set<(event: Event) => void>>();
|
||||
destroyed = false;
|
||||
requestCloseCalls = 0;
|
||||
closeCalls = 0;
|
||||
|
||||
constructor() {
|
||||
instances.push(this);
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: (event: Event) => void) {
|
||||
if (!this.listeners.has(type))
|
||||
this.listeners.set(type, new Set());
|
||||
this.listeners.get(type)!.add(listener);
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: (event: Event) => void) {
|
||||
this.listeners.get(type)?.delete(listener);
|
||||
}
|
||||
|
||||
requestClose() {
|
||||
this.requestCloseCalls++;
|
||||
this.fireClose();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closeCalls++;
|
||||
this.fireClose();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
|
||||
private fireClose() {
|
||||
const event = new Event('close');
|
||||
this.listeners.get('close')?.forEach(fn => fn(event));
|
||||
}
|
||||
|
||||
oncancel: ((event: Event) => void) | null = null;
|
||||
onclose: ((event: Event) => void) | null = null;
|
||||
}
|
||||
|
||||
// A window-like object that exposes CloseWatcher and basic event listening
|
||||
const eventTarget = new EventTarget();
|
||||
const win = {
|
||||
CloseWatcher: FakeCloseWatcher,
|
||||
addEventListener: eventTarget.addEventListener.bind(eventTarget),
|
||||
removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
|
||||
dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget),
|
||||
} as unknown as Window;
|
||||
|
||||
return { win, instances };
|
||||
}
|
||||
|
||||
/** A window-like object WITHOUT CloseWatcher (fallback path). */
|
||||
function createFallbackWindow() {
|
||||
const eventTarget = new EventTarget();
|
||||
const win = {
|
||||
addEventListener: eventTarget.addEventListener.bind(eventTarget),
|
||||
removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
|
||||
dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget),
|
||||
} as unknown as Window;
|
||||
|
||||
const dispatchKey = (key: string) =>
|
||||
win.dispatchEvent(new KeyboardEvent('keydown', { key }));
|
||||
|
||||
return { win, dispatchKey };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe(useCloseWatcher, () => {
|
||||
it('reports support when CloseWatcher exists on window', () => {
|
||||
const { win } = createCloseWatcherStub();
|
||||
const scope = effectScope();
|
||||
let cw: ReturnType<typeof useCloseWatcher>;
|
||||
scope.run(() => {
|
||||
cw = useCloseWatcher({ window: win });
|
||||
});
|
||||
expect(cw!.isSupported.value).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('reports unsupported when CloseWatcher is absent', () => {
|
||||
const { win } = createFallbackWindow();
|
||||
const scope = effectScope();
|
||||
let cw: ReturnType<typeof useCloseWatcher>;
|
||||
scope.run(() => {
|
||||
cw = useCloseWatcher({ window: win });
|
||||
});
|
||||
expect(cw!.isSupported.value).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('is a safe no-op when there is no window (SSR)', () => {
|
||||
// Force the SSR branch with an explicit falsy (non-undefined) window so the
|
||||
// default-parameter fallback to `defaultWindow` does not kick in: only
|
||||
// `undefined` triggers a parameter default, `null` survives the destructure.
|
||||
const scope = effectScope();
|
||||
let cw: ReturnType<typeof useCloseWatcher>;
|
||||
scope.run(() => {
|
||||
cw = useCloseWatcher({ window: null as unknown as Window });
|
||||
});
|
||||
|
||||
expect(cw!.isSupported.value).toBeFalsy();
|
||||
|
||||
const handler = vi.fn();
|
||||
const stop = cw!.onClose(handler);
|
||||
expect(() => cw!.close()).not.toThrow();
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(() => stop()).not.toThrow();
|
||||
expect(() => cw!.destroy()).not.toThrow();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
describe('native CloseWatcher path', () => {
|
||||
it('fires registered handler when close() is requested', () => {
|
||||
const { win, instances } = createCloseWatcherStub();
|
||||
const scope = effectScope();
|
||||
let cw: ReturnType<typeof useCloseWatcher>;
|
||||
scope.run(() => {
|
||||
cw = useCloseWatcher({ window: win });
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
cw!.onClose(handler);
|
||||
expect(instances).toHaveLength(1);
|
||||
|
||||
cw!.close();
|
||||
expect(instances[0]!.requestCloseCalls).toBe(1);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler.mock.calls[0]![0]).toBeInstanceOf(Event);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('fires handler when the native close event occurs (Esc / back)', () => {
|
||||
const { win, instances } = createCloseWatcherStub();
|
||||
const scope = effectScope();
|
||||
let cw: ReturnType<typeof useCloseWatcher>;
|
||||
scope.run(() => {
|
||||
cw = useCloseWatcher({ window: win });
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
cw!.onClose(handler);
|
||||
|
||||
// Simulate the platform firing the close event (e.g. Esc / Android back)
|
||||
instances[0]!.close();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('recreates the watcher after a close so it keeps working', () => {
|
||||
const { win, instances } = createCloseWatcherStub();
|
||||
const scope = effectScope();
|
||||
let cw: ReturnType<typeof useCloseWatcher>;
|
||||
scope.run(() => {
|
||||
cw = useCloseWatcher({ window: win });
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
cw!.onClose(handler);
|
||||
expect(instances).toHaveLength(1);
|
||||
|
||||
cw!.close();
|
||||
// a fresh watcher is created after the close fired
|
||||
expect(instances).toHaveLength(2);
|
||||
|
||||
cw!.close();
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('fires all registered handlers with a single watcher', () => {
|
||||
const { win, instances } = createCloseWatcherStub();
|
||||
const scope = effectScope();
|
||||
let cw: ReturnType<typeof useCloseWatcher>;
|
||||
scope.run(() => {
|
||||
cw = useCloseWatcher({ window: win });
|
||||
});
|
||||
|
||||
const a = vi.fn();
|
||||
const b = vi.fn();
|
||||
cw!.onClose(a);
|
||||
cw!.onClose(b);
|
||||
// both handlers share one native watcher
|
||||
expect(instances).toHaveLength(1);
|
||||
|
||||
cw!.close();
|
||||
expect(a).toHaveBeenCalledTimes(1);
|
||||
expect(b).toHaveBeenCalledTimes(1);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('stop handle removes only its own handler', () => {
|
||||
const { win } = createCloseWatcherStub();
|
||||
const scope = effectScope();
|
||||
let cw: ReturnType<typeof useCloseWatcher>;
|
||||
scope.run(() => {
|
||||
cw = useCloseWatcher({ window: win });
|
||||
});
|
||||
|
||||
const a = vi.fn();
|
||||
const b = vi.fn();
|
||||
const stopA = cw!.onClose(a);
|
||||
cw!.onClose(b);
|
||||
|
||||
stopA();
|
||||
cw!.close();
|
||||
expect(a).not.toHaveBeenCalled();
|
||||
expect(b).toHaveBeenCalledTimes(1);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('destroy() tears down the watcher and clears handlers', () => {
|
||||
const { win, instances } = createCloseWatcherStub();
|
||||
const scope = effectScope();
|
||||
let cw: ReturnType<typeof useCloseWatcher>;
|
||||
scope.run(() => {
|
||||
cw = useCloseWatcher({ window: win });
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
cw!.onClose(handler);
|
||||
cw!.destroy();
|
||||
|
||||
expect(instances[0]!.destroyed).toBeTruthy();
|
||||
cw!.close();
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('survives a handler calling destroy() during dispatch', () => {
|
||||
const { win } = createCloseWatcherStub();
|
||||
const scope = effectScope();
|
||||
let cw: ReturnType<typeof useCloseWatcher>;
|
||||
scope.run(() => {
|
||||
cw = useCloseWatcher({ window: win });
|
||||
});
|
||||
|
||||
const other = vi.fn();
|
||||
cw!.onClose(() => cw!.destroy());
|
||||
cw!.onClose(other);
|
||||
|
||||
// dispatch must not throw even though destroy() clears the set mid-loop
|
||||
expect(() => cw!.close()).not.toThrow();
|
||||
// the snapshot means the second handler still runs for this dispatch
|
||||
expect(other).toHaveBeenCalledTimes(1);
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback (keydown) path', () => {
|
||||
it('fires handler on Escape keydown', () => {
|
||||
const { win, dispatchKey } = createFallbackWindow();
|
||||
const scope = effectScope();
|
||||
let cw: ReturnType<typeof useCloseWatcher>;
|
||||
scope.run(() => {
|
||||
cw = useCloseWatcher({ window: win });
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
cw!.onClose(handler);
|
||||
|
||||
dispatchKey('Escape');
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('ignores non-Escape keys', () => {
|
||||
const { win, dispatchKey } = createFallbackWindow();
|
||||
const scope = effectScope();
|
||||
let cw: ReturnType<typeof useCloseWatcher>;
|
||||
scope.run(() => {
|
||||
cw = useCloseWatcher({ window: win });
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
cw!.onClose(handler);
|
||||
|
||||
dispatchKey('Enter');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('close() synthesizes a close event in the fallback path', () => {
|
||||
const { win } = createFallbackWindow();
|
||||
const scope = effectScope();
|
||||
let cw: ReturnType<typeof useCloseWatcher>;
|
||||
scope.run(() => {
|
||||
cw = useCloseWatcher({ window: win });
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
cw!.onClose(handler);
|
||||
|
||||
cw!.close();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('destroy() removes the keydown listener', () => {
|
||||
const { win, dispatchKey } = createFallbackWindow();
|
||||
const scope = effectScope();
|
||||
let cw: ReturnType<typeof useCloseWatcher>;
|
||||
scope.run(() => {
|
||||
cw = useCloseWatcher({ window: win });
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
cw!.onClose(handler);
|
||||
cw!.destroy();
|
||||
|
||||
dispatchKey('Escape');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
scope.stop();
|
||||
});
|
||||
});
|
||||
|
||||
it('disposes when the effect scope stops', () => {
|
||||
const { win, instances } = createCloseWatcherStub();
|
||||
const scope = effectScope();
|
||||
let cw: ReturnType<typeof useCloseWatcher>;
|
||||
scope.run(() => {
|
||||
cw = useCloseWatcher({ window: win });
|
||||
});
|
||||
|
||||
cw!.onClose(vi.fn());
|
||||
expect(instances[0]!.destroyed).toBeFalsy();
|
||||
|
||||
scope.stop();
|
||||
expect(instances[0]!.destroyed).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { VoidFunction } from '@robonen/stdlib';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import type { Ref } from 'vue';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { useSupported } from '@/composables/browser/useSupported';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
|
||||
/**
|
||||
* Subset of the native `CloseWatcher` instance surface we rely on.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/CloseWatcher
|
||||
*/
|
||||
interface CloseWatcherInstance {
|
||||
requestClose: () => void;
|
||||
close: () => void;
|
||||
destroy: () => void;
|
||||
addEventListener: (type: string, listener: (event: Event) => void) => void;
|
||||
removeEventListener: (type: string, listener: (event: Event) => void) => void;
|
||||
oncancel: ((event: Event) => void) | null;
|
||||
onclose: ((event: Event) => void) | null;
|
||||
}
|
||||
|
||||
type CloseWatcherConstructor = new (options?: { signal?: AbortSignal }) => CloseWatcherInstance;
|
||||
|
||||
/**
|
||||
* Handler invoked when a close request is received.
|
||||
*
|
||||
* The argument is the native `close` event when the platform `CloseWatcher`
|
||||
* is used, or the `Escape` `KeyboardEvent` when falling back to keydown.
|
||||
*/
|
||||
export type CloseWatcherHandler = (event: Event) => void;
|
||||
|
||||
export interface UseCloseWatcherOptions extends ConfigurableWindow {}
|
||||
|
||||
export interface UseCloseWatcherReturn {
|
||||
/**
|
||||
* Whether the native `CloseWatcher` API is available.
|
||||
*/
|
||||
isSupported: Readonly<Ref<boolean>>;
|
||||
|
||||
/**
|
||||
* Register a handler for close requests (Esc key / Android back / `close()`).
|
||||
*
|
||||
* @returns A stop handle that removes this handler.
|
||||
*/
|
||||
onClose: (handler: CloseWatcherHandler) => VoidFunction;
|
||||
|
||||
/**
|
||||
* Request a close, firing every registered handler.
|
||||
*/
|
||||
close: VoidFunction;
|
||||
|
||||
/**
|
||||
* Tear down the watcher and remove all registered handlers.
|
||||
*/
|
||||
destroy: VoidFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useCloseWatcher
|
||||
* @category Browser
|
||||
* @description Wrap the native `CloseWatcher` API to handle close requests
|
||||
* (the `Esc` key or the Android back gesture). Falls back to listening for
|
||||
* `Escape` keydown when `CloseWatcher` is unavailable. SSR-safe.
|
||||
*
|
||||
* @param {UseCloseWatcherOptions} [options={}] Configuration options
|
||||
* @returns {UseCloseWatcherReturn} `isSupported`, `onClose`, `close`, and `destroy`
|
||||
*
|
||||
* @example
|
||||
* const { onClose, close, isSupported } = useCloseWatcher();
|
||||
* onClose(() => { dialogOpen.value = false; });
|
||||
*
|
||||
* @example
|
||||
* // Programmatically request a close
|
||||
* close();
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useCloseWatcher(options: UseCloseWatcherOptions = {}): UseCloseWatcherReturn {
|
||||
const { window = defaultWindow } = options;
|
||||
|
||||
const isSupported = useSupported(() => !!window && 'CloseWatcher' in window);
|
||||
|
||||
const handlers = new Set<CloseWatcherHandler>();
|
||||
let watcher: CloseWatcherInstance | undefined;
|
||||
let stopFallback: VoidFunction = noop;
|
||||
|
||||
const dispatch = (event: Event): void => {
|
||||
// Snapshot so a handler that calls destroy()/onClose() can't mutate mid-loop
|
||||
// eslint-disable-next-line unicorn/no-useless-spread
|
||||
for (const handler of [...handlers])
|
||||
handler(event);
|
||||
};
|
||||
|
||||
const teardownWatcher = (): void => {
|
||||
watcher?.destroy();
|
||||
watcher = undefined;
|
||||
stopFallback();
|
||||
stopFallback = noop;
|
||||
};
|
||||
|
||||
const ensureWatcher = (): void => {
|
||||
if (!window)
|
||||
return;
|
||||
|
||||
if (isSupported.value) {
|
||||
if (watcher)
|
||||
return;
|
||||
|
||||
const CloseWatcherCtor = (window as unknown as { CloseWatcher: CloseWatcherConstructor }).CloseWatcher;
|
||||
watcher = new CloseWatcherCtor();
|
||||
// The native watcher deactivates after a single close; recreate it so the
|
||||
// returned `close()`/Esc keep working across multiple close requests.
|
||||
watcher.addEventListener('close', (event: Event) => {
|
||||
watcher = undefined;
|
||||
dispatch(event);
|
||||
ensureWatcher();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: only one keydown listener regardless of handler count
|
||||
if (stopFallback !== noop)
|
||||
return;
|
||||
|
||||
stopFallback = useEventListener(
|
||||
window,
|
||||
'keydown',
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape')
|
||||
dispatch(event);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
};
|
||||
|
||||
const onClose = (handler: CloseWatcherHandler): VoidFunction => {
|
||||
handlers.add(handler);
|
||||
ensureWatcher();
|
||||
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
};
|
||||
};
|
||||
|
||||
const close = (): void => {
|
||||
if (!window)
|
||||
return;
|
||||
|
||||
if (watcher) {
|
||||
watcher.requestClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// No active native watcher (unsupported, torn down, or none registered yet):
|
||||
// synthesize a close event so handlers still fire.
|
||||
dispatch(new Event('close'));
|
||||
};
|
||||
|
||||
const destroy = (): void => {
|
||||
handlers.clear();
|
||||
teardownWatcher();
|
||||
};
|
||||
|
||||
tryOnScopeDispose(destroy);
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
onClose,
|
||||
close,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user