Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/clear-worker-scope-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@koale/useworker": patch
---

Surface an actionable `WorkerScopeError` (with a console hint and docs link) when a
worker throws a `ReferenceError: <x> 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.
40 changes: 37 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <x> 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`);
Expand Down
46 changes: 46 additions & 0 deletions packages/useWorker/__tests__/workerError.test.js
Original file line number Diff line number Diff line change
@@ -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))
})
5 changes: 5 additions & 0 deletions packages/useWorker/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
74 changes: 74 additions & 0 deletions packages/useWorker/src/lib/workerError.ts
Original file line number Diff line number Diff line change
@@ -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: <x> 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
}
11 changes: 9 additions & 2 deletions packages/useWorker/src/useWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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
Expand Down Expand Up @@ -45,7 +46,9 @@
const worker = React.useRef<Worker & { _url?: string }>()
const isRunning = React.useRef(false)
const promise = React.useRef<{
[PROMISE_REJECT]?: (result: ReturnType<T> | ErrorEvent | AbortError) => void
[PROMISE_REJECT]?: (
result: ReturnType<T> | ErrorEvent | AbortError | WorkerScopeError,
) => void
[PROMISE_RESOLVE]?: (result: ReturnType<T>) => void
}>({})
const timeoutId = React.useRef<number>()
Expand All @@ -65,7 +68,7 @@
}
}, [])

// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>

Check warning on line 71 in packages/useWorker/src/useWorker.ts

View workflow job for this annotation

GitHub Actions / quality

suppressions/incorrect

A suppression shouldn't have an <explanation> placeholder. Example of suppression: // biome-ignore lint: false positive
const onWorkerEnd = React.useCallback(
(status: WORKER_STATUS) => {
const terminate =
Expand Down Expand Up @@ -108,7 +111,11 @@
}

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)
}

Expand All @@ -121,7 +128,7 @@
return newWorker
}, [fn, options, killWorker])

// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>

Check warning on line 131 in packages/useWorker/src/useWorker.ts

View workflow job for this annotation

GitHub Actions / quality

suppressions/incorrect

A suppression shouldn't have an <explanation> placeholder. Example of suppression: // biome-ignore lint: false positive
const callWorker = React.useCallback(
(...workerArgs: Parameters<T>) => {
const { transferable = DEFAULT_OPTIONS.transferable } = options
Expand Down Expand Up @@ -172,7 +179,7 @@
[options.autoTerminate, generateWorker, callWorker],
)

// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>

Check warning on line 182 in packages/useWorker/src/useWorker.ts

View workflow job for this annotation

GitHub Actions / quality

suppressions/incorrect

A suppression shouldn't have an <explanation> placeholder. Example of suppression: // biome-ignore lint: false positive
const killWorkerController = React.useCallback(() => {
killWorker()
setWorkerStatus(WORKER_STATUS.KILLED)
Expand Down
Loading