Skip to content
Open
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. The format

## [Unreleased]

### Fixed

- Fix a component whose lifecycle hook throws synchronously wedging the shared task queue and stopping every other component on the page from mounting — the batch was aborted, its remaining tasks orphaned and the scheduling flag left stuck, so no later batch was ever flushed ([#749](https://github.com/studiometa/js-toolkit/pull/749))
- Fix `Queue.add()` never settling when its task throws synchronously — the returned promise now rejects with the error, exactly like an `async` task whose promise rejects ([#749](https://github.com/studiometa/js-toolkit/pull/749))
- Fix `registerComponent()` returning an instance that failed to mount as a successful registration — instances that are not mounted are skipped again, as documented in v3.8.0 ([#749](https://github.com/studiometa/js-toolkit/pull/749))

### Changed

- `$mount()`, `$update()`, `$destroy()` and `$terminate()` no longer reject when a lifecycle hook fails: they run detached on a shared queue and are commonly called fire-and-forget, so a rejection had no awaiter. The error is re-thrown from a microtask instead, keeping it visible to `window.onerror` and error monitors, wrapped with the instance `$id` and the failed lifecycle and carrying the original error as its `cause`. `after-mounted` and `after-destroyed` are still not emitted on failure ([#749](https://github.com/studiometa/js-toolkit/pull/749))
- With the `blocking` feature enabled the lifecycle methods keep rejecting — no queue is involved there, so `try { await createApp(App, { blocking: true }) } catch {}` still catches startup failures ([#749](https://github.com/studiometa/js-toolkit/pull/749))

## [v3.8.2](https://github.com/studiometa/js-toolkit/compare/3.8.1..3.8.2) (2026-08-06)

### Fixed
Expand Down
10 changes: 10 additions & 0 deletions packages/docs/api/instance-methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,13 @@ Terminate the component. Its instance becomes available for garbage collection.
:::warning
A terminated component can not be re-mounted, use with precaution.
:::

## Error handling

Lifecycle methods run their work through a shared task queue, and they are commonly called fire-and-forget (`$mount()` from the auto-mounting mutation observer, `$terminate()` when an element leaves the DOM). A rejection there would have no awaiter, so `$mount()`, `$update()`, `$destroy()` and `$terminate()` **resolve even when a lifecycle hook throws**.

The error is not swallowed: it is re-thrown from a microtask, so it reaches `window.onerror` and any error monitor, wrapped in an error naming the instance and the failed lifecycle, with the original error as its `cause`.

A failed lifecycle stops at the point of failure: `after-mounted` and `after-destroyed` are not emitted, and a component whose wiring failed stays unmounted. A component whose `mounted()` hook threw is wired, so `$isMounted` is `true` and `$destroy()` can still tear it down.

With the [`blocking` feature](./helpers/createApp.md) enabled no queue is involved — the work runs synchronously in the caller's stack — so the lifecycle methods keep rejecting and `try { await createApp(App, { blocking: true }) } catch {}` still catches startup failures.
27 changes: 27 additions & 0 deletions packages/docs/utils/Queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,30 @@ queue.add(() => console.log('2'));

- `concurrency` (`Number`): the number of tasks to execute at the same time, defaults to `10`
- `waiter` (`(cb: (...args:unknown[]) => unknown) => unknown`): a scheduler function for the next batch execution, defaults to an immediately invoked function `(cb) => cb()`

## `add(task)`

Add a task to the queue.

**Return value**

- `Promise<unknown>`: a promise resolving with the value returned by the task.

The promise **rejects when the task fails**, whether it throws synchronously or returns a rejected promise. The failure is contained: the other tasks of the batch still run, and the queue keeps scheduling later batches.

:::warning
A task that can fail must have its rejection observed, otherwise it becomes an unhandled rejection.

```js
// Fire-and-forget is fine for a task that can not fail.
queue.add(() => console.log('1'));

// Otherwise, await the promise or attach a `catch`.
try {
await queue.add(() => mightThrow());
} catch (error) {
// Handle the failure.
}
```

:::
110 changes: 69 additions & 41 deletions packages/js-toolkit/Base/Base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
deleteInstance,
addToRegistry,
hasInstance,
handleLifecycleError,
} from './utils.js';
import {
ChildrenManager,
Expand Down Expand Up @@ -474,18 +475,29 @@ export class Base<T extends BaseProps = BaseProps> {
this.__debug('$mount');
}

await Promise.all([
addToQueue(() => this.__children.registerAll()),
addToQueue(() => this.__refs.registerAll()),
addToQueue(() => this.__events.bindRootElement()),
addToQueue(() => this.__services.enableAll()),
addToQueue(() => this.__children.mountAll()),
]);

await addToQueue(() => {
this.$isMounted = true;
this.__callMethod('mounted');
});
try {
await Promise.all([
addToQueue(() => this.__children.registerAll()),
addToQueue(() => this.__refs.registerAll()),
addToQueue(() => this.__events.bindRootElement()),
addToQueue(() => this.__services.enableAll()),
addToQueue(() => this.__children.mountAll()),
]);

await addToQueue(() => {
this.$isMounted = true;
this.__callMethod('mounted');
});
} catch (error) {
// A queued mount task failed. Do not emit `after-mounted` as if init had
// completed, and leave `$isMounted` at whatever value it reached: `false`
// when the failure happened while wiring, `true` when the `mounted()` hook
// itself threw — the component is wired in that case, and `$destroy()`
// relies on the flag to tear it down.
this.__isMounting = false;
handleLifecycleError(this, '$mount', error);
return this;
}

this.__isMounting = false;
this.$emit('after-mounted');
Expand All @@ -502,19 +514,23 @@ export class Base<T extends BaseProps = BaseProps> {
this.__debug('$update');
}

await Promise.all([
// Undo
addToQueue(() => this.__refs.unregisterAll()),
addToQueue(() => this.__services.disableAll()),
// Redo
addToQueue(() => this.__children.registerAll()),
addToQueue(() => this.__refs.registerAll()),
addToQueue(() => this.__services.enableAll()),
// Update
addToQueue(() => this.__children.updateAll()),
]);

await addToQueue(() => this.__callMethod('updated'));
try {
await Promise.all([
// Undo
addToQueue(() => this.__refs.unregisterAll()),
addToQueue(() => this.__services.disableAll()),
// Redo
addToQueue(() => this.__children.registerAll()),
addToQueue(() => this.__refs.registerAll()),
addToQueue(() => this.__services.enableAll()),
// Update
addToQueue(() => this.__children.updateAll()),
]);

await addToQueue(() => this.__callMethod('updated'));
} catch (error) {
handleLifecycleError(this, '$update', error);
}

return this;
}
Expand All @@ -535,14 +551,22 @@ export class Base<T extends BaseProps = BaseProps> {
this.$emit('before-destroyed');
this.$isMounted = false;

await Promise.all([
addToQueue(() => this.__events.unbindRootElement()),
addToQueue(() => this.__refs.unregisterAll()),
addToQueue(() => this.__services.disableAll()),
addToQueue(() => this.__children.destroyAll()),
]);

await addToQueue(() => this.__callMethod('destroyed'));
try {
await Promise.all([
addToQueue(() => this.__events.unbindRootElement()),
addToQueue(() => this.__refs.unregisterAll()),
addToQueue(() => this.__services.disableAll()),
addToQueue(() => this.__children.destroyAll()),
]);

await addToQueue(() => this.__callMethod('destroyed'));
} catch (error) {
// A queued teardown task failed. Do not emit `after-destroyed` (which
// removes the instance from the global storage) as if teardown had
// completed.
handleLifecycleError(this, '$destroy', error);
return this;
}

this.$emit('after-destroyed');

Expand All @@ -558,14 +582,18 @@ export class Base<T extends BaseProps = BaseProps> {
this.__debug('$terminate');
}

await Promise.all([
// First, destroy the component.
addToQueue(() => this.$destroy()),
// Execute the `terminated` hook if it exists
addToQueue(() => this.__callMethod('terminated')),
// Delete instance
addToQueue(() => this.$el.__base__.set(this.$config.name, 'terminated')),
]);
try {
await Promise.all([
// First, destroy the component.
addToQueue(() => this.$destroy()),
// Execute the `terminated` hook if it exists
addToQueue(() => this.__callMethod('terminated')),
// Delete instance
addToQueue(() => this.$el.__base__.set(this.$config.name, 'terminated')),
]);
} catch (error) {
handleLifecycleError(this, '$terminate', error);
}
}

/**
Expand Down
19 changes: 15 additions & 4 deletions packages/js-toolkit/Base/managers/ChildrenManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Base, BaseConstructor, BaseAsyncConstructor, BaseEl } from '../index.js';
import { AbstractManager } from './AbstractManager.js';
import { getComponentElements, addToQueue } from '../utils.js';
import { getComponentElements, addToQueue, reportQueuedTaskError } from '../utils.js';

/**
* Children manager.
Expand Down Expand Up @@ -204,9 +204,20 @@ export class ChildrenManager<T> extends AbstractManager<T> {
for (const name of this.registeredNames) {
for (const instance of this.props[name]) {
if (instance instanceof Promise) {
instance.then((resolvedInstance) =>
addToQueue(() => this.__triggerHook(hook, resolvedInstance, name)),
);
// Fire-and-forget: this branch does not push into `promises`, so its
// result is never awaited. Attach a `.catch()` so a failure in the
// async child resolution or its queued hook reaches the global error
// handler instead of becoming an unhandled rejection.
instance
.then((resolvedInstance) =>
addToQueue(() => this.__triggerHook(hook, resolvedInstance, name)),
)
.catch((error) =>
reportQueuedTaskError(
error,
`[${this.__base.$id}] The \`${hook}\` hook failed for an async \`${name}\` child.`,
),
);
} else {
promises.push(addToQueue(() => this.__triggerHook(hook, instance, name)));
}
Expand Down
47 changes: 45 additions & 2 deletions packages/js-toolkit/Base/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,40 @@ export function addToQueue(fn: () => unknown) {
return queue.add(fn);
}

/**
* Surface an error raised by a queued task.
*
* Queued work runs detached from its original call site — fire-and-forget
* mounts, mutation-driven auto-mounting, async children — so the promise
* returned by `addToQueue` is usually discarded and a rejection would be lost.
* Re-throwing from a microtask puts the failure back on the global error
* channel (`window.onerror`, `uncaughtException`, error monitors) without
* wedging the queue or the caller's control flow. The original error is kept as
* the `cause`, so its stack and type survive; `context` adds the component
* identity a bare rejection can not carry.
*/
export function reportQueuedTaskError(error: unknown, context: string): void {
queueMicrotask(() => {
throw new Error(context, { cause: error });
});
}

/**
* Handle an error raised by a queued lifecycle task.
*
* In blocking mode `addToQueue` runs the task synchronously in the caller's own
* stack — no queue, nothing to wedge — so the error is re-thrown and the
* lifecycle promise rejects exactly as it always has. That keeps
* `try { await createApp(App, { blocking: true }) } catch` working.
*/
export function handleLifecycleError(instance: Base, phase: string, error: unknown): void {
if (features.get('blocking')) {
throw error;
}

reportQueuedTaskError(error, `[${instance.$id}] The \`${phase}\` lifecycle failed.`);
}

const selectors = new Map();

// Separator used for multi-component declaration in `data-component` attributeS.
Expand Down Expand Up @@ -181,6 +215,11 @@ function registry() {
}

function mutationCallback() {
// Fire-and-forget: the returned promise is discarded, so route a rejection (a
// constructor throwing while auto-mounting, for instance) to the global error
// handler instead of letting it become an unhandled rejection. In blocking
// mode `addToQueue` runs the task synchronously and returns `undefined` — the
// throw propagates to the mutation observer, hence the optional chaining.
addToQueue(() => {
for (const [nameOrSelector, ctor] of registry()) {
for (const el of getComponentElements(nameOrSelector)) {
Expand All @@ -189,15 +228,19 @@ function mutationCallback() {
}
}
}
});
})?.catch((error) =>
reportQueuedTaskError(error, 'Auto-mounting components after a DOM mutation failed.'),
);

addToQueue(() => {
for (const instance of getInstances()) {
if (!instance.$el.isConnected) {
instance.$terminate();
}
}
});
})?.catch((error) =>
reportQueuedTaskError(error, 'Auto-terminating components after a DOM mutation failed.'),
);
}

export function addToRegistry(nameOrSelector: string, ctor: BaseConstructor) {
Expand Down
15 changes: 12 additions & 3 deletions packages/js-toolkit/helpers/registerComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import { isDev, isFunction } from '../utils/index.js';
* - a factory function returning such a promise (`() => import(...)`).
*
* Instances are mounted independently: an element that fails to mount is
* skipped (and logged in development) instead of failing the whole call, so
* the resolved array contains every instance that mounted successfully.
* skipped instead of failing the whole call, so the resolved array contains
* every instance that mounted successfully. Mount failures reach the global
* error handler through `$mount`; a constructor that throws is logged in
* development.
*
* @link https://js-toolkit.studiometa.dev/api/helpers/registerComponent.html
* @param ctor The component constructor, or a way to resolve it.
Expand Down Expand Up @@ -45,9 +47,16 @@ export async function registerComponent<T extends BaseConstructor = BaseConstruc

return results.flatMap((result) => {
if (result.status === 'fulfilled') {
return [result.value];
// `$mount()` resolves with the instance even when a queued mount task
// fails — the error goes to the global handler instead of rejecting, so a
// fire-and-forget mount can not become an unhandled rejection. Check the
// flag rather than the promise state to keep the documented contract:
// only instances that actually mounted are returned.
return result.value.$isMounted ? [result.value] : [];
}

// The promise still rejects when the instance could not even be
// constructed, which no other channel reports.
if (isDev) {
console.error(
'[registerComponent] An instance failed to mount and was skipped.',
Expand Down
30 changes: 27 additions & 3 deletions packages/js-toolkit/utils/Queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,19 @@ export class Queue {
* Add a task to the queue.
*/
add(task: () => unknown) {
const p = new Promise((resolve) => {
this.tasks.push(() => resolve(task()));
const p = new Promise((resolve, reject) => {
this.tasks.push(() => {
try {
resolve(task());
} catch (err) {
// Containing the throw here is what keeps the queue alive: left to
// escape, it would abort `run()` mid-batch, orphan the remaining
// tasks and leave `isScheduled` stuck `true`, wedging the queue for
// good. Rejecting the returned promise also makes a synchronous throw
// behave exactly like an `async` task whose promise rejects.
reject(err);
}
});
});
this.scheduleFlush();
return p;
Expand All @@ -56,7 +67,20 @@ export class Queue {
}

this.isScheduled = true;
this.waiter(() => this.flush());

try {
this.waiter(() => this.flush());
} catch (err) {
// `waiter` is a public constructor parameter, so a faulty scheduler is
// reachable. Reset the flag before re-throwing: stuck `true`, it would
// make every later `scheduleFlush()` early-return and never re-arm a
// flush. The task `add()` just pushed stays queued on purpose — it runs on
// the next flush that does get scheduled, and its promise never reached
// the caller (the throw pre-empts `add()`'s `return`), so rejecting it
// here could only produce an unobservable rejection.
this.isScheduled = false;
throw err;
}
}

/**
Expand Down
Loading
Loading