diff --git a/.changeset/clear-worker-scope-error.md b/.changeset/clear-worker-scope-error.md new file mode 100644 index 0000000..819bd1f --- /dev/null +++ b/.changeset/clear-worker-scope-error.md @@ -0,0 +1,9 @@ +--- +"@koale/useworker": patch +--- + +Surface an actionable `WorkerScopeError` (with a console hint and docs link) when a +worker throws a `ReferenceError: is not defined`. This is the common production-build +failure where a transpiler/minifier (Babel, Terser, …) hoists helper functions out of the +serialized worker function. The original `ErrorEvent` is preserved on `error.originalEvent`, +and the README "Known issues" section now explains the cause and fixes. diff --git a/README.md b/README.md index b4eb1c8..77a1261 100644 --- a/README.md +++ b/README.md @@ -151,11 +151,45 @@ If you are interested in changing the webpack configuration to manually manage y ## Known issues -There's a known issue related to transpiling tools such as Babel causing `Not refereced` errors. +#### `ReferenceError: is not defined` in production builds -Since the approach of this library is moving the entire function passed to the Hook to a worker, if the function gets transpiled, variable definitions used by the transpiling tool may get out of scope when the function gets moved to the worker, causing unexpected reference errors. +A common report (e.g. `Uncaught ReferenceError: f is not defined`) is a worker that +works in development (`npm start`) but breaks in a production build such as +Create React App. -If you're experimenting this type of issue, one workaround is wrapping your function declaration inside a function object as a string. +Since the approach of this library is moving the entire function passed to the Hook +into a worker (via `Function.prototype.toString`), the function **must be +self-contained**. In production, transpilers/minifiers such as Babel or Terser hoist +helper functions and rename them (often to single letters like `f`). Those helpers +live in your module scope and are **not** copied into the worker, so the serialized +function references identifiers that don't exist inside the worker — causing the +reference error. + +To help diagnose this, `useWorker` now rejects with a `WorkerScopeError` carrying an +actionable message (and logs it to the console) whenever a worker throws this kind of +`ReferenceError`. The original `ErrorEvent` is preserved on `error.originalEvent`. + +```js +import { useWorker, WorkerScopeError } from "@koale/useworker"; + +const [doWork] = useWorker(myFn); + +try { + await doWork(data); +} catch (error) { + if (error instanceof WorkerScopeError) { + // the function isn't self-contained — see error.message + } +} +``` + +##### How to fix it + +- Keep the function self-contained: don't reference outer-scope variables, imports, or + helpers defined elsewhere in your module. +- Load external scripts through the `remoteDependencies` option (they're added via + `importScripts` inside the worker) instead of closing over imported values. +- As a last resort, declare the function as a string so the transpiler can't rewrite it: ```js const sum = new Function(`a`, `b`, `return a + b`); diff --git a/packages/useWorker/__tests__/workerError.test.js b/packages/useWorker/__tests__/workerError.test.js new file mode 100644 index 0000000..24f244e --- /dev/null +++ b/packages/useWorker/__tests__/workerError.test.js @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + enhanceWorkerError, + isTranspileScopeError, + WorkerScopeError, +} from '../dist/index.mjs' + +test('isTranspileScopeError detects ReferenceError messages', () => { + assert.equal( + isTranspileScopeError('Uncaught ReferenceError: f is not defined'), + true, + ) + assert.equal(isTranspileScopeError('ReferenceError: x is not defined'), true) +}) + +test('isTranspileScopeError ignores unrelated messages', () => { + assert.equal(isTranspileScopeError('TypeError: x is not a function'), false) + assert.equal(isTranspileScopeError('Script error.'), false) + assert.equal(isTranspileScopeError(undefined), false) + assert.equal(isTranspileScopeError(42), false) +}) + +test('enhanceWorkerError upgrades reference errors with actionable guidance', () => { + const event = { message: 'Uncaught ReferenceError: f is not defined' } + const result = enhanceWorkerError(event) + + assert.ok(result instanceof WorkerScopeError) + assert.equal(result.name, 'WorkerScopeError') + // keeps the original message... + assert.match(result.message, /f is not defined/) + // ...and adds the actionable hint + docs link + assert.match(result.message, /\[useWorker\]/) + assert.match(result.message, /remoteDependencies/) + assert.match(result.message, /github\.com\/alewin\/useWorker#known-issues/) + // preserves the original event for backward compatibility + assert.equal(result.originalEvent, event) +}) + +test('enhanceWorkerError leaves unrelated errors untouched', () => { + const event = { message: 'TypeError: x is not a function' } + const result = enhanceWorkerError(event) + + assert.equal(result, event) + assert.ok(!(result instanceof WorkerScopeError)) +}) diff --git a/packages/useWorker/src/index.ts b/packages/useWorker/src/index.ts index 916818d..77e1fe0 100644 --- a/packages/useWorker/src/index.ts +++ b/packages/useWorker/src/index.ts @@ -1,3 +1,8 @@ export { AbortError } from './lib/abortError' export { WORKER_STATUS } from './lib/status' +export { + enhanceWorkerError, + isTranspileScopeError, + WorkerScopeError, +} from './lib/workerError' export { useWorker } from './useWorker' diff --git a/packages/useWorker/src/lib/workerError.ts b/packages/useWorker/src/lib/workerError.ts new file mode 100644 index 0000000..c2788b9 --- /dev/null +++ b/packages/useWorker/src/lib/workerError.ts @@ -0,0 +1,74 @@ +const KNOWN_ISSUES_URL = 'https://github.com/alewin/useWorker#known-issues' + +/** + * A worker error raised when the function passed to `useWorker` references + * identifiers that don't exist inside the worker. + * + * This is almost always caused by a transpiler/minifier (Babel, Terser, …) + * hoisting helper functions out of the user function. Since `useWorker` + * serializes the function with `Function.prototype.toString`, those external + * helpers are lost when the function is moved into the worker, producing a + * cryptic `ReferenceError: is not defined` at runtime. + * + * The original `ErrorEvent` is preserved on the `originalEvent` property so + * existing consumers can keep reading `message`, `filename`, `lineno`, etc. + */ +export class WorkerScopeError extends Error { + originalEvent?: ErrorEvent + + constructor(message: string, originalEvent?: ErrorEvent) { + super(message) + this.name = 'WorkerScopeError' + this.originalEvent = originalEvent + } +} + +/** + * Detects whether a worker error message looks like the "lost scope" problem + * described above, i.e. a `ReferenceError` for an undefined identifier. + * + * @param {unknown} message the `message` of the worker `ErrorEvent` + * @returns {boolean} true when the message matches a reference error + */ +export const isTranspileScopeError = (message: unknown): boolean => + typeof message === 'string' && + (/ReferenceError/.test(message) || /\bis not defined\b/.test(message)) + +/** + * Builds an actionable error message that explains the most common cause of a + * `ReferenceError` thrown inside a `useWorker` worker and how to fix it. + * + * @param {string} [original] the original worker error message + * @returns {string} the augmented, self-explanatory message + */ +export const buildTranspileScopeErrorMessage = (original?: string): string => + `${original || 'A worker error occurred'}\n\n` + + '[useWorker] The function passed to useWorker() referenced something that ' + + "doesn't exist inside the worker. This typically happens in production " + + 'builds (e.g. Create React App) when a transpiler/minifier such as Babel ' + + 'or Terser hoists helper functions out of your function — they are lost ' + + 'when the function is serialized and moved into the worker.\n' + + 'Make sure the function is fully self-contained: avoid referencing ' + + 'outer-scope variables and imports, and pass external scripts via the ' + + '`remoteDependencies` option instead.\n' + + `See ${KNOWN_ISSUES_URL}` + +/** + * Returns a clearer, actionable error when a worker `ErrorEvent` looks like the + * transpilation "lost scope" problem; otherwise returns the original event + * unchanged so existing behavior is preserved. + * + * @param {ErrorEvent} event the worker `ErrorEvent` + * @returns {ErrorEvent | WorkerScopeError} the original event or an enhanced error + */ +export const enhanceWorkerError = ( + event: ErrorEvent, +): ErrorEvent | WorkerScopeError => { + if (isTranspileScopeError(event?.message)) { + return new WorkerScopeError( + buildTranspileScopeErrorMessage(event.message), + event, + ) + } + return event +} diff --git a/packages/useWorker/src/useWorker.ts b/packages/useWorker/src/useWorker.ts index 1a0bfae..f83bed8 100644 --- a/packages/useWorker/src/useWorker.ts +++ b/packages/useWorker/src/useWorker.ts @@ -3,6 +3,7 @@ import { useDeepCallback } from './hook/useDeepCallback' import { AbortError } from './lib/abortError' import createWorkerBlobUrl from './lib/createWorkerBlobUrl' import WORKER_STATUS from './lib/status' +import { enhanceWorkerError, WorkerScopeError } from './lib/workerError' type WorkerController = { status: WORKER_STATUS @@ -45,7 +46,9 @@ export const useWorker = any>( const worker = React.useRef() const isRunning = React.useRef(false) const promise = React.useRef<{ - [PROMISE_REJECT]?: (result: ReturnType | ErrorEvent | AbortError) => void + [PROMISE_REJECT]?: ( + result: ReturnType | ErrorEvent | AbortError | WorkerScopeError, + ) => void [PROMISE_RESOLVE]?: (result: ReturnType) => void }>({}) const timeoutId = React.useRef() @@ -108,7 +111,11 @@ export const useWorker = any>( } newWorker.onerror = (e: ErrorEvent) => { - promise.current[PROMISE_REJECT]?.(e) + const enhancedError = enhanceWorkerError(e) + if (enhancedError instanceof WorkerScopeError) { + console.error(enhancedError.message) + } + promise.current[PROMISE_REJECT]?.(enhancedError) onWorkerEnd(WORKER_STATUS.ERROR) }