diff --git a/examples/features/spell-check/typo-js/src/createTypoJsProvider.ts b/examples/features/spell-check/typo-js/src/createTypoJsProvider.ts index 46e47bced2..fbe9756b9c 100644 --- a/examples/features/spell-check/typo-js/src/createTypoJsProvider.ts +++ b/examples/features/spell-check/typo-js/src/createTypoJsProvider.ts @@ -1,16 +1,55 @@ -import Typo from 'typo-js'; -import affUrl from 'typo-js/dictionaries/en_US/en_US.aff?url'; -import dicUrl from 'typo-js/dictionaries/en_US/en_US.dic?url'; +import type { TypoWorkerCancelMessage, TypoWorkerIssue, TypoWorkerRequest, TypoWorkerResponse } from './typoWorkerMessages'; -const WORD_RE = /[a-zA-Z'\u2019]+/g; +type PendingRequest = { + resolve: (value: { issues: TypoWorkerIssue[] }) => void; + reject: (error: unknown) => void; + cleanup: () => void; +}; + +function createAbortError(): DOMException | Error { + try { + return new DOMException('The operation was aborted.', 'AbortError'); + } catch { + const error = new Error('The operation was aborted.'); + error.name = 'AbortError'; + return error; + } +} export async function createTypoJsProvider() { - const [affData, dicData] = await Promise.all([ - fetch(affUrl).then((r) => r.text()), - fetch(dicUrl).then((r) => r.text()), - ]); + // Run Typo.js work inside a dedicated worker to avoid UI stalls. + const worker = new Worker(new URL('./typoWorker.ts', import.meta.url), { type: 'module' }); + const pending = new Map(); + let nextRequestId = 0; + + const handleMessage = (event: MessageEvent) => { + const message = event.data; + const entry = pending.get(message.id); + if (!entry) return; + + pending.delete(message.id); + entry.cleanup(); + + if (message.type === 'result') { + entry.resolve({ issues: message.issues }); + } else { + entry.reject(new Error(message.error)); + } + }; - const dictionary = new Typo('en_US', affData, dicData); + const handleError = (event: ErrorEvent) => { + const error = new Error(event.message || 'Typo worker crashed'); + + for (const [, entry] of pending) { + entry.cleanup(); + entry.reject(error); + } + + pending.clear(); + }; + + worker.addEventListener('message', handleMessage); + worker.addEventListener('error', handleError); return { id: 'typo-js', @@ -26,42 +65,60 @@ export async function createTypoJsProvider() { async check(request: { segments: { id: string; text: string }[]; maxSuggestions?: number; + signal?: AbortSignal; }) { - const issues: { - segmentId: string; - start: number; - end: number; - kind: 'spelling'; - message: string; - replacements: string[]; - }[] = []; - - const maxSuggestions = request.maxSuggestions ?? 5; - - for (const segment of request.segments) { - let match: RegExpExecArray | null; - WORD_RE.lastIndex = 0; - - while ((match = WORD_RE.exec(segment.text)) !== null) { - const word = match[0]; - - // Skip very short words and apostrophe-only tokens - if (word.replace(/['\u2019]/g, '').length < 2) continue; - - if (!dictionary.check(word)) { - issues.push({ - segmentId: segment.id, - start: match.index, - end: match.index + word.length, - kind: 'spelling', - message: `Unknown word: "${word}"`, - replacements: dictionary.suggest(word).slice(0, maxSuggestions), - }); - } - } + if (request.signal?.aborted) { + throw createAbortError(); + } + + return new Promise<{ issues: TypoWorkerIssue[] }>((resolve, reject) => { + const requestId = ++nextRequestId; + const maxSuggestions = request.maxSuggestions ?? 5; + + const cleanup = () => { + request.signal?.removeEventListener('abort', onAbort); + }; + + const onAbort = () => { + pending.delete(requestId); + cleanup(); + const cancel: TypoWorkerCancelMessage = { type: 'cancel', id: requestId }; + worker.postMessage(cancel); + reject(createAbortError()); + }; + + pending.set(requestId, { + resolve, + reject, + cleanup, + }); + + request.signal?.addEventListener('abort', onAbort); + + const payload: TypoWorkerRequest = { + id: requestId, + type: 'check', + payload: { + segments: request.segments, + maxSuggestions, + }, + }; + + worker.postMessage(payload); + }); + }, + + dispose() { + worker.removeEventListener('message', handleMessage); + worker.removeEventListener('error', handleError); + worker.terminate(); + + for (const [, entry] of pending) { + entry.cleanup(); + entry.reject(new Error('Typo.js provider disposed')); } - return { issues }; + pending.clear(); }, }; } diff --git a/examples/features/spell-check/typo-js/src/typoWorker.ts b/examples/features/spell-check/typo-js/src/typoWorker.ts new file mode 100644 index 0000000000..18970cd55c --- /dev/null +++ b/examples/features/spell-check/typo-js/src/typoWorker.ts @@ -0,0 +1,114 @@ +/// + +import Typo from 'typo-js'; +import affUrl from 'typo-js/dictionaries/en_US/en_US.aff?url'; +import dicUrl from 'typo-js/dictionaries/en_US/en_US.dic?url'; +import type { + TypoWorkerIssue, + TypoWorkerRequest, + TypoWorkerResponse, + TypoWorkerIncomingMessage, +} from './typoWorkerMessages'; + +const ctx: DedicatedWorkerGlobalScope = self as unknown as DedicatedWorkerGlobalScope; +const WORD_PATTERN = /[a-zA-Z'\u2019]+/g; + +/** Yields to the worker event loop so `cancel` messages can be processed mid-check. */ +const YIELD_EVERY_WORDS = 25; + +let dictionaryPromise: Promise | null = null; + +/** Cancelled request ids (added by `cancel` messages from the main thread). */ +const cancelledIds = new Set(); + +async function loadDictionary(): Promise { + if (!dictionaryPromise) { + dictionaryPromise = Promise.all([ + fetch(affUrl).then((r) => r.text()), + fetch(dicUrl).then((r) => r.text()), + ]).then(([affData, dicData]) => new Typo('en_US', affData, dicData)); + } + + return dictionaryPromise; +} + +/** + * Returns issues, or `null` if the request was cancelled (caller must not post a result). + * Yields periodically so abort can be observed while Typo runs synchronously per word. + */ +async function collectIssues( + payload: TypoWorkerRequest['payload'], + dictionary: Typo, + isAborted: () => boolean, +): Promise { + const issues: TypoWorkerIssue[] = []; + const maxSuggestions = payload.maxSuggestions ?? 5; + let wordCount = 0; + + for (const segment of payload.segments) { + for (const match of segment.text.matchAll(WORD_PATTERN)) { + if (isAborted()) return null; + + const word = match[0]; + if (word.replace(/['\u2019]/g, '').length < 2) continue; + + if (!dictionary.check(word)) { + issues.push({ + segmentId: segment.id, + start: match.index, + end: match.index + word.length, + kind: 'spelling', + message: `Unknown word: "${word}"`, + replacements: maxSuggestions > 0 ? dictionary.suggest(word).slice(0, maxSuggestions) : [], + }); + } + + wordCount++; + if (wordCount % YIELD_EVERY_WORDS === 0) { + // Yield to the event loop so abort can be observed mid-check. + await new Promise((resolve) => setTimeout(resolve, 0)); + if (isAborted()) return null; + } + } + } + + return issues; +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + return 'Typo worker failed'; +} + +async function handleCheck(data: TypoWorkerRequest): Promise { + const id = data.id; + + try { + if (cancelledIds.has(id)) return; + + const dictionary = await loadDictionary(); + if (cancelledIds.has(id)) return; + + const collected = await collectIssues(data.payload, dictionary, () => cancelledIds.has(id)); + if (collected === null) return; + + ctx.postMessage({ id, type: 'result', issues: collected } satisfies TypoWorkerResponse); + } catch (error) { + ctx.postMessage({ id, type: 'error', error: toErrorMessage(error) } satisfies TypoWorkerResponse); + } finally { + cancelledIds.delete(id); + } +} + +ctx.addEventListener('message', (event: MessageEvent) => { + const { data } = event; + + if (data.type === 'cancel') { + cancelledIds.add(data.id); + return; + } + + if (data.type !== 'check') return; + + handleCheck(data); +}); diff --git a/examples/features/spell-check/typo-js/src/typoWorkerMessages.ts b/examples/features/spell-check/typo-js/src/typoWorkerMessages.ts new file mode 100644 index 0000000000..ced3706986 --- /dev/null +++ b/examples/features/spell-check/typo-js/src/typoWorkerMessages.ts @@ -0,0 +1,41 @@ +export type TypoWorkerIssue = { + segmentId: string; + start: number; + end: number; + kind: 'spelling'; + message: string; + replacements: string[]; +}; + +export type TypoWorkerPayload = { + segments: { id: string; text: string }[]; + maxSuggestions: number; +}; + +export type TypoWorkerRequest = { + id: number; + type: 'check'; + payload: TypoWorkerPayload; +}; + +/** Tells the worker to stop work for a timed-out or aborted check (id matches the check request). */ +export type TypoWorkerCancelMessage = { + type: 'cancel'; + id: number; +}; + +export type TypoWorkerIncomingMessage = TypoWorkerRequest | TypoWorkerCancelMessage; + +type TypoWorkerResultMessage = { + id: number; + type: 'result'; + issues: TypoWorkerIssue[]; +}; + +type TypoWorkerErrorMessage = { + id: number; + type: 'error'; + error: string; +}; + +export type TypoWorkerResponse = TypoWorkerResultMessage | TypoWorkerErrorMessage;