From a0029095db3241ae5beab040b4d8f28dbc7ec08f Mon Sep 17 00:00:00 2001 From: Beast Date: Sun, 4 Oct 2026 14:10:43 +0800 Subject: [PATCH] feat: optimize checkphrase processing --- bun.lock | 4 +- package.json | 2 +- package/human-readable-checksum-0.3.0.tgz | Bin 14984 -> 0 bytes package/human-readable-checksum-0.4.0.tgz | Bin 0 -> 15422 bytes .../AccountAddressCell.test.tsx | 169 ++++++++++- .../AccountAddressCell.tsx | 29 +- src/hooks/useChecksum.tsx | 22 +- src/utils/checksum-phrase.test.ts | 40 +++ src/utils/checksum-phrase.ts | 10 + src/utils/checksum-worker.ts | 4 + src/utils/checksum.worker.ts | 32 ++ src/utils/get-checksum.test.ts | 219 ++++++++++++-- src/utils/get-checksum.ts | 281 ++++++++++++++---- 13 files changed, 703 insertions(+), 109 deletions(-) delete mode 100644 package/human-readable-checksum-0.3.0.tgz create mode 100644 package/human-readable-checksum-0.4.0.tgz create mode 100644 src/utils/checksum-phrase.test.ts create mode 100644 src/utils/checksum-phrase.ts create mode 100644 src/utils/checksum-worker.ts create mode 100644 src/utils/checksum.worker.ts 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 f6b439e6ca5631fb47aea4f18b58590bf3c69b44..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14984 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 S-v0e_{rzu;D;>lD0s#QqA^$4? 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 0000000000000000000000000000000000000000..527fb7cf9dab2c4f0dad8e5281e60f1ab74ff9f9 GIT binary patch literal 15422 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 o|DA{LzP ({ } })); -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);