diff --git a/bun.lock b/bun.lock index b6272c5..135ba6f 100644 --- a/bun.lock +++ b/bun.lock @@ -24,7 +24,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "graphql": "^16.11.0", - "human-readable-checksum": "./package/human-readable-checksum-0.3.0.tgz", + "human-readable-checksum": "./package/human-readable-checksum-0.4.0.tgz", "lucide-react": "^0.516.0", "nuqs": "2.4.3", "react": "19.1.1", @@ -1519,7 +1519,7 @@ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "human-readable-checksum": ["human-readable-checksum@./package/human-readable-checksum-0.3.0.tgz", { "dependencies": { "pbkdf2": "^3.1.3" } }, "sha512-B1O/e+o+dzo4NPIS4RER+rRQis5SJh/oGNht+ZyFJ+YgJpEcWghJyV3fnTuA1m+2ucgExI3myldZ5JuJRj8eeA=="], + "human-readable-checksum": ["human-readable-checksum@./package/human-readable-checksum-0.4.0.tgz", {}, "sha512-n+u8qpkRgHepujv0nVRQMgBhwxYHnJ95LahML21YwhRL9H1g1V9AqVcr1j/AqZBfmMaHCvICrKNlKwS78USD1A=="], "human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="], diff --git a/package.json b/package.json index 7eeb0ed..1acf9ae 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "graphql": "^16.11.0", - "human-readable-checksum": "./package/human-readable-checksum-0.3.0.tgz", + "human-readable-checksum": "./package/human-readable-checksum-0.4.0.tgz", "lucide-react": "^0.516.0", "nuqs": "2.4.3", "react": "19.1.1", diff --git a/package/human-readable-checksum-0.3.0.tgz b/package/human-readable-checksum-0.3.0.tgz deleted file mode 100644 index f6b439e..0000000 Binary files a/package/human-readable-checksum-0.3.0.tgz and /dev/null differ diff --git a/package/human-readable-checksum-0.4.0.tgz b/package/human-readable-checksum-0.4.0.tgz new file mode 100644 index 0000000..527fb7c Binary files /dev/null and b/package/human-readable-checksum-0.4.0.tgz differ diff --git a/src/components/ui/composites/account-address-cell/AccountAddressCell.test.tsx b/src/components/ui/composites/account-address-cell/AccountAddressCell.test.tsx index c69d1ec..0d9dbdb 100644 --- a/src/components/ui/composites/account-address-cell/AccountAddressCell.test.tsx +++ b/src/components/ui/composites/account-address-cell/AccountAddressCell.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; import { JSDOM } from 'jsdom'; import { CheckphraseReadyProvider } from '@/components/ui/composites/checkphrase-ready/CheckphraseReady'; @@ -50,15 +50,73 @@ jest.mock('@/components/ui/composites/text-with-copy/TextWithCopy', () => ({ } })); -const renderedPhrases = () => - ((globalThis as { __checkPhrases?: string[] }).__checkPhrases ??= []); +const renderedPhrases = () => { + const holder = globalThis as { __checkPhrases?: string[] }; + holder.__checkPhrases ??= []; + return holder.__checkPhrases; +}; const getChecksumMock = getChecksum as jest.Mock; const ADDRESS = 'qz1234567890address'; const CHECK_PHRASE = 'alpha-bravo-charlie'; +class MockIntersectionObserver { + static instances: MockIntersectionObserver[] = []; + + static visible = true; + + callback: IntersectionObserverCallback; + + target: Element | null = null; + + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + MockIntersectionObserver.instances.push(this); + } + + observe = (target: Element) => { + this.target = target; + this.emit(MockIntersectionObserver.visible); + }; + + emit(isIntersecting: boolean) { + if (!this.target) return; + this.callback( + [ + { + isIntersecting, + target: this.target + } as IntersectionObserverEntry + ], + this as unknown as IntersectionObserver + ); + } + + unobserve() {} + + disconnect() {} + + takeRecords(): IntersectionObserverEntry[] { + return []; + } + + root = null; + + rootMargin = '0px'; + + thresholds = [0]; +} + describe('AccountAddressCell', () => { + beforeEach(() => { + MockIntersectionObserver.instances = []; + MockIntersectionObserver.visible = true; + Object.assign(globalThis, { + IntersectionObserver: MockIntersectionObserver + }); + }); + it('shows the address immediately and skeletons only the check phrase', async () => { let resolveChecksum: (value: string) => void = () => {}; getChecksumMock.mockReturnValue( @@ -120,8 +178,111 @@ describe('AccountAddressCell', () => { ); await waitFor(() => { - expect(getChecksumMock).toHaveBeenCalledWith(ADDRESS); + expect(getChecksumMock).toHaveBeenCalledWith( + ADDRESS, + expect.any(AbortSignal) + ); + }); + }); + + it('computes a check phrase only after the address is on screen', async () => { + MockIntersectionObserver.visible = false; + getChecksumMock.mockClear(); + getChecksumMock.mockReturnValue(new Promise(() => {})); + + render( + + ); + + await waitFor(() => { + expect(MockIntersectionObserver.instances.length).toBeGreaterThan(0); + }); + expect(getChecksumMock).not.toHaveBeenCalled(); + + const observer = MockIntersectionObserver.instances.at(-1); + if (!observer) throw new Error('expected an intersection observer'); + + await act(async () => { + observer.emit(true); + }); + + await waitFor(() => { + expect(getChecksumMock).toHaveBeenCalledWith( + ADDRESS, + expect.any(AbortSignal) + ); + }); + }); + + it('stops a check phrase that leaves the screen before it finishes', async () => { + let signal: AbortSignal | undefined; + getChecksumMock.mockImplementation( + (_address: string, next?: AbortSignal) => { + signal = next; + return new Promise(() => {}); + } + ); + + render( + + ); + + await waitFor(() => { + expect(signal?.aborted).toBe(false); }); + + const observer = MockIntersectionObserver.instances.at(-1); + if (!observer) throw new Error('expected an intersection observer'); + + await act(async () => { + observer.emit(false); + }); + + expect(signal?.aborted).toBe(true); + }); + + it('keeps a resolved check phrase visible when the address leaves and returns', async () => { + let calls = 0; + let resolveChecksum: (value: string) => void = () => {}; + getChecksumMock.mockImplementation(() => { + calls += 1; + if (calls === 1) { + return new Promise((resolve) => { + resolveChecksum = resolve; + }); + } + return new Promise(() => {}); + }); + + const view = render( + + ); + + await waitFor(() => { + expect(getChecksumMock).toHaveBeenCalled(); + }); + resolveChecksum(CHECK_PHRASE); + + await waitFor(() => { + expect(view.getByText(CHECK_PHRASE)).toBeTruthy(); + }); + + const observer = MockIntersectionObserver.instances.at(-1); + if (!observer) throw new Error('expected an intersection observer'); + + await act(async () => { + observer.emit(false); + }); + expect(view.getByText(CHECK_PHRASE)).toBeTruthy(); + + await act(async () => { + observer.emit(true); + }); + + expect( + view.queryByRole('status', { name: 'Loading check phrase' }) + ).toBeNull(); + expect(view.getByText(CHECK_PHRASE)).toBeTruthy(); }); it('does not offer the previous check phrase after the address changes', async () => { diff --git a/src/components/ui/composites/account-address-cell/AccountAddressCell.tsx b/src/components/ui/composites/account-address-cell/AccountAddressCell.tsx index e87e92e..d14d26a 100644 --- a/src/components/ui/composites/account-address-cell/AccountAddressCell.tsx +++ b/src/components/ui/composites/account-address-cell/AccountAddressCell.tsx @@ -15,6 +15,26 @@ export interface AccountAddressCellProps { className?: string; } +const useOnScreen = (ref: React.RefObject) => { + const [onScreen, setOnScreen] = React.useState(false); + + React.useEffect(() => { + const element = ref.current; + if (!element) return undefined; + + const observer = new IntersectionObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + setOnScreen(entry.isIntersecting); + }); + + observer.observe(element); + return () => observer.disconnect(); + }, [ref]); + + return onScreen; +}; + export const AccountAddressCell: React.FC = ({ address, href, @@ -22,11 +42,16 @@ export const AccountAddressCell: React.FC = ({ truncate = true, className }) => { + const rootRef = React.useRef(null); const dataReady = useCheckphraseReady(); - const { checksum, loading, error } = useChecksum(!dataReady, address); + const onScreen = useOnScreen(rootRef); + const { checksum, loading, error } = useChecksum( + !dataReady || !onScreen, + address + ); return ( -
+
+ typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'AbortError'; + export const useChecksum = (wait: boolean, id?: string) => { const [resolved, setResolved] = useState(null); const [loading, setLoading] = useState(true); + const resolvedRef = useRef(null); + resolvedRef.current = resolved; useEffect(() => { - if (!id || wait) return; + if (!id || wait) return undefined; + const controller = new AbortController(); let cancelled = false; - const fetchChecksum = async () => { - setLoading(true); + if (resolvedRef.current?.id !== id) setLoading(true); + const fetchChecksum = async () => { try { - const response = await getChecksum(id); + const response = await getChecksum(id, controller.signal); if (cancelled) return; setResolved({ id, checksum: response, error: null }); setLoading(false); } catch (caught) { - if (cancelled) return; + if (cancelled || isAbort(caught)) return; setResolved({ id, checksum: null, @@ -43,6 +52,7 @@ export const useChecksum = (wait: boolean, id?: string) => { return () => { cancelled = true; + controller.abort(); }; }, [id, wait]); diff --git a/src/utils/checksum-phrase.test.ts b/src/utils/checksum-phrase.test.ts new file mode 100644 index 0000000..d0b5ef9 --- /dev/null +++ b/src/utils/checksum-phrase.test.ts @@ -0,0 +1,40 @@ +import { addressToChecksum, loadWordList } from 'human-readable-checksum'; + +import { checksumPhrase } from './checksum-phrase'; + +jest.mock('human-readable-checksum', () => ({ + loadWordList: jest.fn(() => ['word']), + addressToChecksum: jest.fn(() => + Promise.resolve(['alpha', 'bravo', 'charlie', 'delta', 'echo']) + ) +})); + +const addressToChecksumMock = addressToChecksum as jest.Mock; +const loadWordListMock = loadWordList as jest.Mock; + +describe('checksumPhrase', () => { + it('awaits the async check phrase and joins the words', async () => { + addressToChecksumMock.mockClear(); + + await expect(checksumPhrase('qz-async')).resolves.toBe( + 'alpha-bravo-charlie-delta-echo' + ); + expect(addressToChecksumMock).toHaveBeenCalledWith('qz-async', ['word']); + + const loadsAfterFirst = loadWordListMock.mock.calls.length; + await expect(checksumPhrase('qz-async-again')).resolves.toBe( + 'alpha-bravo-charlie-delta-echo' + ); + expect(loadWordListMock.mock.calls.length).toBe(loadsAfterFirst); + }); + + it('rejects when the check phrase cannot be derived', async () => { + addressToChecksumMock.mockRejectedValueOnce( + new Error('Web Crypto is not available') + ); + + await expect(checksumPhrase('qz-derive-fail')).rejects.toThrow( + 'Web Crypto is not available' + ); + }); +}); diff --git a/src/utils/checksum-phrase.ts b/src/utils/checksum-phrase.ts new file mode 100644 index 0000000..33e7842 --- /dev/null +++ b/src/utils/checksum-phrase.ts @@ -0,0 +1,10 @@ +import { addressToChecksum, loadWordList } from 'human-readable-checksum'; + +let wordList: string[] = []; + +export const checksumPhrase = async (address: string) => { + if (wordList.length === 0) wordList = loadWordList(); + + const words = await addressToChecksum(address, wordList); + return words.join('-'); +}; diff --git a/src/utils/checksum-worker.ts b/src/utils/checksum-worker.ts new file mode 100644 index 0000000..e71cda9 --- /dev/null +++ b/src/utils/checksum-worker.ts @@ -0,0 +1,4 @@ +export const createChecksumWorker = (): Worker => + new Worker(new URL('./checksum.worker.ts', import.meta.url), { + type: 'module' + }); diff --git a/src/utils/checksum.worker.ts b/src/utils/checksum.worker.ts new file mode 100644 index 0000000..86d8ef3 --- /dev/null +++ b/src/utils/checksum.worker.ts @@ -0,0 +1,32 @@ +import { checksumPhrase } from './checksum-phrase'; + +type ChecksumRequest = { + id: number; + address: string; +}; + +type ChecksumResponse = + | { id: number; checksum: string } + | { id: number; error: string }; + +const scope = globalThis as typeof globalThis & { + onmessage: ((event: MessageEvent) => void) | null; + postMessage: (message: ChecksumResponse) => void; +}; + +const failureMessage = (caught: unknown) => { + if (caught instanceof Error && caught.message) return caught.message; + return 'Failed to load check phrase'; +}; + +scope.onmessage = (event) => { + const { id, address } = event.data; + + checksumPhrase(address) + .then((checksum) => { + scope.postMessage({ id, checksum }); + }) + .catch((caught: unknown) => { + scope.postMessage({ id, error: failureMessage(caught) }); + }); +}; diff --git a/src/utils/get-checksum.test.ts b/src/utils/get-checksum.test.ts index e49767c..b34b60c 100644 --- a/src/utils/get-checksum.test.ts +++ b/src/utils/get-checksum.test.ts @@ -1,6 +1,4 @@ -import { addressToChecksum } from 'human-readable-checksum'; - -import { getChecksum } from './get-checksum'; +import { createChecksumQueue } from './get-checksum'; const store = new Map(); Object.assign(globalThis, { @@ -18,42 +16,166 @@ Object.assign(globalThis, { } }); -jest.mock('human-readable-checksum', () => ({ - loadWordList: () => ['word'], - addressToChecksum: jest.fn(() => [ - 'alpha', - 'bravo', - 'charlie', - 'delta', - 'echo' - ]) -})); +type PostedChecksum = { + id: number; + address: string; +}; + +type FakeChecksumWorker = { + onmessage: ((event: MessageEvent) => void) | null; + onerror: ((event: ErrorEvent) => void) | null; + posted: PostedChecksum[]; + sent: PostedChecksum[]; + terminated: boolean; + postMessage: (data: PostedChecksum) => void; + terminate: () => void; +}; -const addressToChecksumMock = addressToChecksum as jest.Mock; +const createFakeWorker = (): FakeChecksumWorker => { + const worker: FakeChecksumWorker = { + onmessage: null, + onerror: null, + posted: [], + sent: [], + terminated: false, + postMessage(data) { + worker.posted.push(data); + worker.sent.push(data); + }, + terminate() { + worker.terminated = true; + } + }; + + return worker; +}; describe('getChecksum', () => { - it('does not compute the check phrase in the turn that starts loading', async () => { - addressToChecksumMock.mockClear(); + let created: FakeChecksumWorker[] = []; + let getChecksum: ( + address?: string, + signal?: AbortSignal + ) => Promise; - const pending = getChecksum('qz-defer-1'); + const latestWorker = () => { + const worker = created.at(-1); + if (!worker) throw new Error('checksum worker was not created'); + return worker; + }; - expect(addressToChecksumMock).not.toHaveBeenCalled(); + const respond = (data: { checksum: string } | { error: string }) => { + const worker = latestWorker(); + const message = worker.posted.shift(); + if (!message) throw new Error('No checksum job is waiting'); + worker.onmessage?.({ + data: { id: message.id, ...data } + } as MessageEvent); + }; - await expect(pending).resolves.toBe('alpha-bravo-charlie-delta-echo'); - expect(addressToChecksumMock).toHaveBeenCalledTimes(1); + beforeEach(() => { + store.clear(); + created = []; + ({ getChecksum } = createChecksumQueue(() => { + const worker = createFakeWorker(); + created.push(worker); + return worker; + })); }); - it('computes a repeated address once', async () => { - addressToChecksumMock.mockClear(); + it('returns nothing for a missing address', async () => { + await expect(getChecksum()).resolves.toBeNull(); + expect(created).toHaveLength(0); + }); + + it('waits for the worker instead of resolving in the turn that starts loading', async () => { + let settled = false; + const pending = getChecksum('qz-defer').then(() => { + settled = true; + }); + + await Promise.resolve(); + + expect(settled).toBe(false); + expect(latestWorker().sent.map((job) => job.address)).toEqual(['qz-defer']); + + respond({ checksum: 'alpha-bravo-charlie-delta-echo' }); + await pending; + expect(settled).toBe(true); + }); + + it('sends one address to the worker at a time', async () => { + const first = getChecksum('qz-seq-a'); + const second = getChecksum('qz-seq-b'); - const [first, second] = await Promise.all([ - getChecksum('qz-shared'), - getChecksum('qz-shared') + expect(latestWorker().sent.map((job) => job.address)).toEqual(['qz-seq-a']); + + respond({ checksum: 'phrase-a' }); + await expect(first).resolves.toBe('phrase-a'); + expect(latestWorker().sent.map((job) => job.address)).toEqual([ + 'qz-seq-a', + 'qz-seq-b' ]); - expect(first).toBe('alpha-bravo-charlie-delta-echo'); - expect(second).toBe(first); - expect(addressToChecksumMock).toHaveBeenCalledTimes(1); + respond({ checksum: 'phrase-b' }); + await expect(second).resolves.toBe('phrase-b'); + }); + + it('computes a repeated address once', async () => { + const first = getChecksum('qz-shared'); + const second = getChecksum('qz-shared'); + + expect(latestWorker().sent).toHaveLength(1); + respond({ checksum: 'alpha-bravo-charlie-delta-echo' }); + + await expect(first).resolves.toBe('alpha-bravo-charlie-delta-echo'); + await expect(second).resolves.toBe('alpha-bravo-charlie-delta-echo'); + + await expect(getChecksum('qz-shared')).resolves.toBe( + 'alpha-bravo-charlie-delta-echo' + ); + expect(latestWorker().sent).toHaveLength(1); + }); + + it('still resolves a shared address when one caller leaves', async () => { + const controller = new AbortController(); + const left = getChecksum('qz-shared-live', controller.signal); + const stayed = getChecksum('qz-shared-live'); + + controller.abort(); + + await expect(left).rejects.toMatchObject({ name: 'AbortError' }); + respond({ checksum: 'alpha-bravo-charlie-delta-echo' }); + await expect(stayed).resolves.toBe('alpha-bravo-charlie-delta-echo'); + expect(latestWorker().sent).toHaveLength(1); + }); + + it('does not send an address that left before its turn', async () => { + const first = getChecksum('qz-hold'); + const controller = new AbortController(); + const skipped = getChecksum('qz-offscreen', controller.signal); + + controller.abort(); + + await expect(skipped).rejects.toMatchObject({ name: 'AbortError' }); + expect(latestWorker().sent.map((job) => job.address)).toEqual(['qz-hold']); + + respond({ checksum: 'phrase-hold' }); + await expect(first).resolves.toBe('phrase-hold'); + expect(latestWorker().sent.map((job) => job.address)).toEqual(['qz-hold']); + }); + + it('keeps a phrase that finished after its caller left', async () => { + const controller = new AbortController(); + const pending = getChecksum('qz-late', controller.signal); + + controller.abort(); + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + + respond({ checksum: 'alpha-bravo-charlie-delta-echo' }); + await expect(getChecksum('qz-late')).resolves.toBe( + 'alpha-bravo-charlie-delta-echo' + ); + expect(latestWorker().sent).toHaveLength(1); }); it('reuses a check phrase saved in localStorage without computing', async () => { @@ -61,19 +183,54 @@ describe('getChecksum', () => { 'quantus:checkphrase:qz-stored', 'alpha-bravo-charlie-delta-echo' ); - addressToChecksumMock.mockClear(); await expect(getChecksum('qz-stored')).resolves.toBe( 'alpha-bravo-charlie-delta-echo' ); - expect(addressToChecksumMock).not.toHaveBeenCalled(); + expect(created).toHaveLength(0); }); it('saves a newly computed check phrase in localStorage', async () => { - await getChecksum('qz-new-store'); + const pending = getChecksum('qz-new-store'); + respond({ checksum: 'alpha-bravo-charlie-delta-echo' }); + await pending; expect(localStorage.getItem('quantus:checkphrase:qz-new-store')).toBe( 'alpha-bravo-charlie-delta-echo' ); }); + + it('rejects a derivation error from the worker and continues the queue', async () => { + const pending = getChecksum('qz-derive-error'); + const next = getChecksum('qz-after-derive-error'); + + respond({ error: 'Web Crypto is not available' }); + + await expect(pending).rejects.toThrow('Web Crypto is not available'); + expect(latestWorker().posted.map((job) => job.address)).toEqual([ + 'qz-after-derive-error' + ]); + + respond({ checksum: 'alpha-bravo-charlie-delta-echo' }); + await expect(next).resolves.toBe('alpha-bravo-charlie-delta-echo'); + }); + + it('reports a worker failure without computing on the page', async () => { + const pending = getChecksum('qz-worker-fail'); + const failedWorker = latestWorker(); + + failedWorker.onerror?.({} as ErrorEvent); + + await expect(pending).rejects.toThrow('Check phrase worker failed'); + expect(failedWorker.terminated).toBe(true); + + const next = getChecksum('qz-worker-next'); + expect(latestWorker()).not.toBe(failedWorker); + expect(latestWorker().sent.map((job) => job.address)).toEqual([ + 'qz-worker-next' + ]); + + respond({ checksum: 'alpha-bravo-charlie-delta-echo' }); + await expect(next).resolves.toBe('alpha-bravo-charlie-delta-echo'); + }); }); diff --git a/src/utils/get-checksum.ts b/src/utils/get-checksum.ts index da7687d..563311f 100644 --- a/src/utils/get-checksum.ts +++ b/src/utils/get-checksum.ts @@ -1,10 +1,28 @@ -import { addressToChecksum, loadWordList } from 'human-readable-checksum'; +import { createChecksumWorker } from './checksum-worker'; const STORAGE_PREFIX = 'quantus:checkphrase:'; -let wordList: string[] = []; -const cache = new Map(); -const inflight = new Map>(); +export type ChecksumComputeWorker = { + onmessage: ((event: MessageEvent) => void) | null; + onerror: ((event: ErrorEvent) => void) | null; + postMessage: (message: { id: number; address: string }) => void; + terminate: () => void; +}; + +type Waiter = { + signal?: AbortSignal; + resolve: (value: string | null) => void; + reject: (error: unknown) => void; +}; + +type Job = { + address: string; + waiters: Waiter[]; +}; + +type ChecksumResponse = + | { id: number; checksum: string } + | { id: number; error: string }; const storageKey = (address: string) => `${STORAGE_PREFIX}${address}`; @@ -24,77 +42,214 @@ const writeStored = (address: string, checksum: string) => { } }; -type Job = { - address: string; - resolve: (value: string | null) => void; - reject: (error: unknown) => void; -}; - -const queue: Job[] = []; -let scheduled = false; - -const compute = (address: string) => { - const cached = cache.get(address); - if (cached) return cached; +const abortError = () => + new DOMException('The operation was aborted.', 'AbortError'); - if (wordList.length === 0) wordList = loadWordList(); - - const checksum = addressToChecksum(address, wordList).join('-'); - cache.set(address, checksum); - writeStored(address, checksum); - return checksum; +const workerFailure = (event: ErrorEvent) => { + if (event.message) return new Error(event.message); + return new Error('Check phrase worker failed'); }; -const drainOne = () => { - scheduled = false; - const job = queue.shift(); - if (!job) return; - - try { - job.resolve(compute(job.address)); - } catch (error) { - job.reject(error); +const isChecksumResponse = (value: unknown): value is ChecksumResponse => { + if (typeof value !== 'object' || value === null || !('id' in value)) { + return false; } - if (queue.length > 0) scheduleDrain(); -}; + const { id } = value; + if (typeof id !== 'number') return false; + if ('checksum' in value && typeof value.checksum === 'string') return true; + if ('error' in value && typeof value.error === 'string' && value.error) { + return true; + } -const scheduleDrain = () => { - if (scheduled) return; - scheduled = true; - setTimeout(drainOne, 0); + return false; }; -export const getChecksum = (address?: string): Promise => { - if (!address) return Promise.resolve(null); +// The worker hashes one address at a time. A queued address is dropped once +// every caller has left the screen. +export const createChecksumQueue = ( + createWorker: () => ChecksumComputeWorker +) => { + const cache = new Map(); + const jobsByAddress = new Map(); + const queue: Job[] = []; + const settled = new WeakSet(); + let worker: ChecksumComputeWorker | null = null; + let current: { id: number; job: Job } | null = null; + let nextId = 0; + let pump = () => {}; + + const isActive = (waiter: Waiter) => + !settled.has(waiter) && !waiter.signal?.aborted; + + const settle = (waiter: Waiter, action: () => void) => { + if (settled.has(waiter)) return; + settled.add(waiter); + action(); + }; + + const forget = (job: Job) => { + if (jobsByAddress.get(job.address) === job) { + jobsByAddress.delete(job.address); + } + }; + + const rejectActive = (job: Job, error: unknown) => { + job.waiters.forEach((waiter) => { + if (!isActive(waiter)) return; + settle(waiter, () => waiter.reject(error)); + }); + }; - const cached = cache.get(address); - if (cached) return Promise.resolve(cached); + const resolveActive = (job: Job, checksum: string) => { + job.waiters.forEach((waiter) => { + if (!isActive(waiter)) return; + settle(waiter, () => waiter.resolve(checksum)); + }); + }; + + const failWorker = (error: Error) => { + const failed = worker; + const running = current; + current = null; + worker = null; + if (failed) { + failed.onmessage = null; + failed.onerror = null; + failed.terminate(); + } + + if (running) { + forget(running.job); + rejectActive(running.job, error); + } + + queue.splice(0, queue.length).forEach((job) => { + forget(job); + rejectActive(job, error); + }); + }; + + const handleMessage = (event: MessageEvent) => { + const running = current; + if (!running) return; + + const data = isChecksumResponse(event.data) ? event.data : null; + if (!data || data.id !== running.id) return; + + current = null; + forget(running.job); + + if ('checksum' in data) { + cache.set(running.job.address, data.checksum); + writeStored(running.job.address, data.checksum); + resolveActive(running.job, data.checksum); + } else { + rejectActive(running.job, new Error(data.error)); + } + + pump(); + }; + + const ensureWorker = () => { + if (worker) return worker; + + const created = createWorker(); + created.onmessage = (event) => { + try { + handleMessage(event); + } catch (error) { + failWorker( + error instanceof Error + ? error + : new Error('Check phrase worker failed') + ); + } + }; + created.onerror = (event) => { + failWorker(workerFailure(event)); + }; + worker = created; + return created; + }; + + pump = () => { + if (current) return; + + while (queue.length > 0) { + const job = queue.shift(); + if (!job) return; + + if (!job.waiters.some(isActive)) { + forget(job); + continue; + } - const stored = readStored(address); - if (stored) { - cache.set(address, stored); - return Promise.resolve(stored); - } + const id = nextId; + nextId += 1; + current = { id, job }; + + try { + ensureWorker().postMessage({ id, address: job.address }); + return; + } catch (error) { + failWorker( + error instanceof Error + ? error + : new Error('Check phrase worker failed') + ); + return; + } + } + }; + + const getChecksum = (address?: string, signal?: AbortSignal) => { + if (!address) return Promise.resolve(null); + if (signal?.aborted) return Promise.reject(abortError()); + + const cached = cache.get(address) ?? readStored(address); + if (cached) { + cache.set(address, cached); + return Promise.resolve(cached); + } + + return new Promise((resolve, reject) => { + const waiter: Waiter = { signal, resolve, reject }; + const existing = jobsByAddress.get(address); + const job = existing ?? { address, waiters: [] }; + job.waiters.push(waiter); + + if (!existing) { + jobsByAddress.set(address, job); + queue.push(job); + } - const existing = inflight.get(address); - if (existing) return existing; - - const promise = new Promise((resolve, reject) => { - queue.push({ - address, - resolve: (value) => { - inflight.delete(address); - resolve(value); - }, - reject: (error) => { - inflight.delete(address); - reject(error); + if (signal) { + signal.addEventListener( + 'abort', + () => { + settle(waiter, () => waiter.reject(abortError())); + if (current?.job === job) return; + if (job.waiters.some(isActive)) return; + + const index = queue.indexOf(job); + if (index >= 0) queue.splice(index, 1); + forget(job); + }, + { once: true } + ); } + + pump(); }); - scheduleDrain(); - }); + }; - inflight.set(address, promise); - return promise; + return { getChecksum }; }; + +const checksums = createChecksumQueue(createChecksumWorker); + +export const getChecksum = ( + address?: string, + signal?: AbortSignal +): Promise => checksums.getChecksum(address, signal);