forked from akai/readest
942095bcd6
Three paragraph-mode problems, all fixed:
- Shift+P inside paragraph mode flashed and re-entered (and pressing it
repeatedly did nothing). A single keypress toggled twice: eventDispatcher
.dispatch() iterated the live listener Set while awaiting each listener, and
the exit's awaited dispatch('paragraph-mode-disabled') let React re-run
useParagraphMode's subscription effect mid-loop, adding a handler the same
dispatch then called. Snapshot the listeners before iterating (dispatchSync
already did), so a listener added during a dispatch can't fire for the
current event.
- Shift+P / Escape only worked when focus sat on the overlay. Handle the
overlay's keys the way a dialog/alert does: focus the dialog element on open
and handle Escape / the toggle shortcut / paragraph navigation in its own
onKeyDown (stopping propagation so the global handler can't double-fire),
instead of a global window listener. Suppress the focus ring on the
programmatically-focused, non-tab-stop container.
- Resume jumped to the chapter start, and repeated enter/exit walked further
back. Two causes: (a) entering/exiting scrolled the underlying view to the
focused paragraph's start, which rewinds a page when that paragraph began on
the previous page — don't scroll on resume/exit (the paragraph is already on
screen); navigation still scrolls. (b) resume preferred the rAF-debounced
store progress and a stored last-paragraph CFI that can come out malformed
and resolve to an empty range, shadowing the correct candidate and sending
findByRange to the first block. Resume from the view's live, foliate-
generated lastLocation CFI first (set synchronously on every relocate,
resolved against the current document so it survives iframe recreation).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
import { afterEach, describe, it, expect, vi } from 'vitest';
|
|
|
|
// We need to import the singleton, but also be able to test a fresh instance.
|
|
// The module exports a singleton `eventDispatcher`. Since the class is not
|
|
// exported, we test through the singleton and reset between tests.
|
|
|
|
import {
|
|
eventDispatcher,
|
|
markSettled,
|
|
onSettled,
|
|
__resetSettledEventsForTests,
|
|
} from '@/utils/event';
|
|
|
|
describe('EventDispatcher', () => {
|
|
// -----------------------------------------------------------------------
|
|
// Async listeners (on / off / dispatch)
|
|
// -----------------------------------------------------------------------
|
|
describe('async listeners', () => {
|
|
it('registers and dispatches an async listener', async () => {
|
|
const fn = vi.fn();
|
|
eventDispatcher.on('test-event', fn);
|
|
await eventDispatcher.dispatch('test-event', { foo: 'bar' });
|
|
expect(fn).toHaveBeenCalledOnce();
|
|
expect(fn.mock.calls[0]![0]).toBeInstanceOf(CustomEvent);
|
|
expect(fn.mock.calls[0]![0].detail).toEqual({ foo: 'bar' });
|
|
eventDispatcher.off('test-event', fn);
|
|
});
|
|
|
|
it('dispatches to multiple listeners in order', async () => {
|
|
const order: number[] = [];
|
|
const fn1 = vi.fn(() => {
|
|
order.push(1);
|
|
});
|
|
const fn2 = vi.fn(() => {
|
|
order.push(2);
|
|
});
|
|
eventDispatcher.on('multi', fn1);
|
|
eventDispatcher.on('multi', fn2);
|
|
await eventDispatcher.dispatch('multi');
|
|
expect(order).toEqual([1, 2]);
|
|
eventDispatcher.off('multi', fn1);
|
|
eventDispatcher.off('multi', fn2);
|
|
});
|
|
|
|
it('removes a listener with off()', async () => {
|
|
const fn = vi.fn();
|
|
eventDispatcher.on('rm-test', fn);
|
|
eventDispatcher.off('rm-test', fn);
|
|
await eventDispatcher.dispatch('rm-test');
|
|
expect(fn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('off() on non-existent event does not throw', () => {
|
|
const fn = vi.fn();
|
|
expect(() => eventDispatcher.off('nonexistent', fn)).not.toThrow();
|
|
});
|
|
|
|
it('dispatch on event with no listeners does not throw', async () => {
|
|
await expect(eventDispatcher.dispatch('no-listeners', { x: 1 })).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('dispatch with no detail sends undefined as detail argument', async () => {
|
|
const fn = vi.fn();
|
|
eventDispatcher.on('no-detail', fn);
|
|
await eventDispatcher.dispatch('no-detail');
|
|
// CustomEvent({ detail: undefined }) results in detail being null per spec
|
|
expect(fn.mock.calls[0]![0].detail).toBeNull();
|
|
eventDispatcher.off('no-detail', fn);
|
|
});
|
|
|
|
it('does not call listeners added during an in-progress dispatch (snapshot semantics)', async () => {
|
|
// Regression: a listener that (re)subscribes another listener mid-dispatch
|
|
// must not have that new listener fire for the *current* event. The live
|
|
// Set used to be iterated directly, so a handler added during an awaited
|
|
// listener got invoked in the same dispatch — causing paragraph mode to
|
|
// toggle twice per keypress (#4717).
|
|
const added = vi.fn();
|
|
const adder = vi.fn(async () => {
|
|
await Promise.resolve();
|
|
eventDispatcher.on('reentrant', added);
|
|
});
|
|
eventDispatcher.on('reentrant', adder);
|
|
|
|
await eventDispatcher.dispatch('reentrant');
|
|
expect(adder).toHaveBeenCalledOnce();
|
|
expect(added).not.toHaveBeenCalled();
|
|
|
|
// The newly-added listener takes effect on the next dispatch.
|
|
await eventDispatcher.dispatch('reentrant');
|
|
expect(added).toHaveBeenCalledOnce();
|
|
|
|
eventDispatcher.off('reentrant', adder);
|
|
eventDispatcher.off('reentrant', added);
|
|
});
|
|
|
|
it('awaits async listeners sequentially', async () => {
|
|
const order: string[] = [];
|
|
const slowFn = vi.fn(async () => {
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
order.push('slow');
|
|
});
|
|
const fastFn = vi.fn(() => {
|
|
order.push('fast');
|
|
});
|
|
eventDispatcher.on('seq', slowFn);
|
|
eventDispatcher.on('seq', fastFn);
|
|
await eventDispatcher.dispatch('seq');
|
|
// slow should complete before fast starts because dispatch awaits each
|
|
expect(order).toEqual(['slow', 'fast']);
|
|
eventDispatcher.off('seq', slowFn);
|
|
eventDispatcher.off('seq', fastFn);
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Sync listeners (onSync / offSync / dispatchSync)
|
|
// -----------------------------------------------------------------------
|
|
describe('sync listeners', () => {
|
|
it('registers and dispatches a sync listener', () => {
|
|
const fn = vi.fn((_event: CustomEvent) => false);
|
|
eventDispatcher.onSync('sync-test', fn);
|
|
eventDispatcher.dispatchSync('sync-test', 42);
|
|
expect(fn).toHaveBeenCalledOnce();
|
|
expect(fn.mock.calls[0]![0].detail).toBe(42);
|
|
eventDispatcher.offSync('sync-test', fn);
|
|
});
|
|
|
|
it('returns true when a listener consumes the event', () => {
|
|
const consumer = vi.fn(() => true);
|
|
eventDispatcher.onSync('consume', consumer);
|
|
const result = eventDispatcher.dispatchSync('consume');
|
|
expect(result).toBe(true);
|
|
eventDispatcher.offSync('consume', consumer);
|
|
});
|
|
|
|
it('returns false when no listener consumes the event', () => {
|
|
const ignorer = vi.fn(() => false);
|
|
eventDispatcher.onSync('ignore', ignorer);
|
|
const result = eventDispatcher.dispatchSync('ignore');
|
|
expect(result).toBe(false);
|
|
eventDispatcher.offSync('ignore', ignorer);
|
|
});
|
|
|
|
it('stops at the first consumer (reverse order)', () => {
|
|
const fn1 = vi.fn(() => true);
|
|
const fn2 = vi.fn(() => false);
|
|
// fn1 is registered first, fn2 second. Dispatch iterates in reverse.
|
|
eventDispatcher.onSync('stop-early', fn1);
|
|
eventDispatcher.onSync('stop-early', fn2);
|
|
|
|
const result = eventDispatcher.dispatchSync('stop-early');
|
|
|
|
// fn2 (last registered) is called first in reverse, returns false
|
|
// then fn1 is called, returns true (consumed)
|
|
expect(fn2).toHaveBeenCalledOnce();
|
|
expect(fn1).toHaveBeenCalledOnce();
|
|
expect(result).toBe(true);
|
|
|
|
eventDispatcher.offSync('stop-early', fn1);
|
|
eventDispatcher.offSync('stop-early', fn2);
|
|
});
|
|
|
|
it('stops at consumer and does not call earlier listeners', () => {
|
|
const earlyFn = vi.fn(() => true);
|
|
const lateFn = vi.fn(() => true);
|
|
eventDispatcher.onSync('stop', earlyFn);
|
|
eventDispatcher.onSync('stop', lateFn);
|
|
|
|
eventDispatcher.dispatchSync('stop');
|
|
|
|
// lateFn (last registered) is called first in reverse, returns true -> stop
|
|
expect(lateFn).toHaveBeenCalledOnce();
|
|
expect(earlyFn).not.toHaveBeenCalled();
|
|
|
|
eventDispatcher.offSync('stop', earlyFn);
|
|
eventDispatcher.offSync('stop', lateFn);
|
|
});
|
|
|
|
it('removes a sync listener with offSync()', () => {
|
|
const fn = vi.fn(() => false);
|
|
eventDispatcher.onSync('off-sync', fn);
|
|
eventDispatcher.offSync('off-sync', fn);
|
|
eventDispatcher.dispatchSync('off-sync');
|
|
expect(fn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('offSync on non-existent event does not throw', () => {
|
|
const fn = vi.fn(() => false);
|
|
expect(() => eventDispatcher.offSync('nope', fn)).not.toThrow();
|
|
});
|
|
|
|
it('dispatchSync on event with no listeners returns false', () => {
|
|
expect(eventDispatcher.dispatchSync('nobody-listens')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Isolation between event names
|
|
// -----------------------------------------------------------------------
|
|
describe('event isolation', () => {
|
|
it('does not cross-fire between different event names', async () => {
|
|
const fn1 = vi.fn();
|
|
const fn2 = vi.fn();
|
|
eventDispatcher.on('event-a', fn1);
|
|
eventDispatcher.on('event-b', fn2);
|
|
await eventDispatcher.dispatch('event-a');
|
|
expect(fn1).toHaveBeenCalledOnce();
|
|
expect(fn2).not.toHaveBeenCalled();
|
|
eventDispatcher.off('event-a', fn1);
|
|
eventDispatcher.off('event-b', fn2);
|
|
});
|
|
|
|
it('sync and async listeners with same event name are independent', async () => {
|
|
const asyncFn = vi.fn();
|
|
const syncFn = vi.fn(() => false);
|
|
eventDispatcher.on('shared-name', asyncFn);
|
|
eventDispatcher.onSync('shared-name', syncFn);
|
|
|
|
await eventDispatcher.dispatch('shared-name');
|
|
expect(asyncFn).toHaveBeenCalledOnce();
|
|
expect(syncFn).not.toHaveBeenCalled();
|
|
|
|
eventDispatcher.dispatchSync('shared-name');
|
|
expect(syncFn).toHaveBeenCalledOnce();
|
|
expect(asyncFn).toHaveBeenCalledOnce(); // still only once
|
|
|
|
eventDispatcher.off('shared-name', asyncFn);
|
|
eventDispatcher.offSync('shared-name', syncFn);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('settled events (markSettled / onSettled)', () => {
|
|
afterEach(() => {
|
|
__resetSettledEventsForTests();
|
|
});
|
|
|
|
it('fires listeners that subscribed BEFORE settling', async () => {
|
|
const fn = vi.fn();
|
|
onSettled('boot', fn);
|
|
expect(fn).not.toHaveBeenCalled();
|
|
await markSettled('boot', { ready: true });
|
|
expect(fn).toHaveBeenCalledOnce();
|
|
expect(fn).toHaveBeenCalledWith({ ready: true });
|
|
});
|
|
|
|
it('replays synchronously for listeners that subscribe AFTER settling', async () => {
|
|
await markSettled('boot', { ready: true });
|
|
const fn = vi.fn();
|
|
onSettled('boot', fn);
|
|
expect(fn).toHaveBeenCalledOnce();
|
|
expect(fn).toHaveBeenCalledWith({ ready: true });
|
|
});
|
|
|
|
it('fires multiple listeners; each fires exactly once', async () => {
|
|
const a = vi.fn();
|
|
const b = vi.fn();
|
|
onSettled('boot', a);
|
|
onSettled('boot', b);
|
|
await markSettled('boot');
|
|
expect(a).toHaveBeenCalledOnce();
|
|
expect(b).toHaveBeenCalledOnce();
|
|
// Idempotent: a second markSettled is a no-op.
|
|
await markSettled('boot');
|
|
expect(a).toHaveBeenCalledOnce();
|
|
expect(b).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('auto-unsubscribes after fire — repeated dispatches do NOT re-fire', async () => {
|
|
const fn = vi.fn();
|
|
onSettled('boot', fn);
|
|
await markSettled('boot');
|
|
expect(fn).toHaveBeenCalledOnce();
|
|
// The bare eventDispatcher would normally fire again on re-dispatch;
|
|
// settled-event listeners must not.
|
|
await eventDispatcher.dispatch('boot', { stale: true });
|
|
expect(fn).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('returned unsubscribe cancels a pre-settle subscription', async () => {
|
|
const fn = vi.fn();
|
|
const unsub = onSettled('boot', fn);
|
|
unsub();
|
|
await markSettled('boot');
|
|
expect(fn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returned unsubscribe is a no-op for late (already-replayed) subscribers', async () => {
|
|
await markSettled('boot');
|
|
const fn = vi.fn();
|
|
const unsub = onSettled('boot', fn);
|
|
expect(fn).toHaveBeenCalledOnce();
|
|
expect(() => unsub()).not.toThrow();
|
|
});
|
|
|
|
it('preserves typed payload through onSettled<T>', async () => {
|
|
interface BootPayload {
|
|
version: number;
|
|
}
|
|
let captured: BootPayload | null = null;
|
|
onSettled<BootPayload>('typed-boot', (p) => {
|
|
captured = p;
|
|
});
|
|
await markSettled('typed-boot', { version: 7 });
|
|
expect(captured).toEqual({ version: 7 });
|
|
});
|
|
|
|
it('different event names are independent', async () => {
|
|
const a = vi.fn();
|
|
const b = vi.fn();
|
|
onSettled('a', a);
|
|
onSettled('b', b);
|
|
await markSettled('a');
|
|
expect(a).toHaveBeenCalledOnce();
|
|
expect(b).not.toHaveBeenCalled();
|
|
});
|
|
});
|