From a0785243f30c0a508d1a8854654acb8a75216c75 Mon Sep 17 00:00:00 2001 From: Odin Thomas Rochmann Date: Sat, 8 Aug 2026 19:23:21 +0200 Subject: [PATCH 1/4] feat(module-event): add EventModuleConfigurator, waitForEvent/watchEvents, and operators subpath Add EventModuleConfigurator, a BaseConfigBuilder-based configurator with fluent setOnDispatch/setOnBubble setters, replacing direct property assignment on the config object (kept as a deprecated path, no major bump needed). IEventModuleConfigurator is renamed to EventModuleConfig and converted from an interface to a type; IEventModuleConfigurator is kept as a deprecated alias. Narrow the dispatchEvent return type for registered FrameworkEventMap keys and pre-constructed event instances so callers get back the specific event type. Fix event$ to emit only after listeners and the bubble hook have run, matching its documented contract (it previously emitted before dispatch). Add waitForEvent/watchEvents test helpers and a ./operators subpath export for filterEvent (moved from src/filter-event.ts to src/operators/filter-event.ts). Rename configurator.ts -> EventModuleConfigurator.ts and provider.ts -> EventModuleProvider.ts for filename-convention compliance, and add a docs/ folder (configuration, observable-patterns, lifecycle, testing). --- .changeset/module-event_configurator.md | 29 +++ .changeset/module-event_utils-helpers.md | 47 ++++ packages/modules/event/README.md | 153 ++---------- packages/modules/event/docs/configuration.md | 97 ++++++++ packages/modules/event/docs/lifecycle.md | 102 ++++++++ .../modules/event/docs/observable-patterns.md | 34 +++ packages/modules/event/docs/testing.md | 88 +++++++ packages/modules/event/package.json | 20 +- .../event/src/EventModuleConfigurator.ts | 127 ++++++++++ .../{provider.ts => EventModuleProvider.ts} | 35 +-- packages/modules/event/src/FrameworkEvent.ts | 2 +- .../event/src/__tests__/waitForEvent.test.ts | 92 ++++++++ .../event/src/__tests__/watchEvents.test.ts | 121 ++++++++++ packages/modules/event/src/configurator.ts | 41 ---- packages/modules/event/src/index.ts | 12 +- packages/modules/event/src/module.ts | 15 +- .../event/src/{ => operators}/filter-event.ts | 2 +- packages/modules/event/src/operators/index.ts | 7 + .../event/src/utils/apply-event-matcher.ts | 43 ++++ packages/modules/event/src/utils/index.ts | 9 + .../modules/event/src/utils/wait-for-event.ts | 130 ++++++++++ .../modules/event/src/utils/watch-events.ts | 74 ++++++ packages/modules/event/vitest.config.ts | 13 + pnpm-lock.yaml | 222 ++++++++++++++++++ vue-press/src/modules/event/react.md | 118 ---------- 25 files changed, 1307 insertions(+), 326 deletions(-) create mode 100644 .changeset/module-event_configurator.md create mode 100644 .changeset/module-event_utils-helpers.md create mode 100644 packages/modules/event/docs/configuration.md create mode 100644 packages/modules/event/docs/lifecycle.md create mode 100644 packages/modules/event/docs/observable-patterns.md create mode 100644 packages/modules/event/docs/testing.md create mode 100644 packages/modules/event/src/EventModuleConfigurator.ts rename packages/modules/event/src/{provider.ts => EventModuleProvider.ts} (88%) create mode 100644 packages/modules/event/src/__tests__/waitForEvent.test.ts create mode 100644 packages/modules/event/src/__tests__/watchEvents.test.ts delete mode 100644 packages/modules/event/src/configurator.ts rename packages/modules/event/src/{ => operators}/filter-event.ts (92%) create mode 100644 packages/modules/event/src/operators/index.ts create mode 100644 packages/modules/event/src/utils/apply-event-matcher.ts create mode 100644 packages/modules/event/src/utils/index.ts create mode 100644 packages/modules/event/src/utils/wait-for-event.ts create mode 100644 packages/modules/event/src/utils/watch-events.ts create mode 100644 packages/modules/event/vitest.config.ts delete mode 100644 vue-press/src/modules/event/react.md diff --git a/.changeset/module-event_configurator.md b/.changeset/module-event_configurator.md new file mode 100644 index 0000000000..59a156d6e5 --- /dev/null +++ b/.changeset/module-event_configurator.md @@ -0,0 +1,29 @@ +--- +"@equinor/fusion-framework-module-event": minor +--- + +Add `EventModuleConfigurator`, a `BaseConfigBuilder`-based configurator with fluent +`setOnDispatch`/`setOnBubble` setters, replacing direct property assignment on the config +object. + +```ts +// Before (still works, but deprecated) +config.event.onDispatch = (event) => { ... }; +delete config.event.onBubble; + +// After +configurator.setOnDispatch((event) => { ... }); +configurator.setOnBubble(undefined); +``` + +`IEventModuleConfigurator` is renamed to `EventModuleConfig` and converted from an `interface` +to a `type`. `IEventModuleConfigurator` is kept as a deprecated type alias for backward +compatibility. + +Also narrows the `dispatchEvent` return type for registered {@link FrameworkEventMap} keys and +pre-constructed event instances, so callers get back the specific event type instead of the +generic `FrameworkEvent`. + +**Deprecated (since 6.1.0), no migration required yet:** +- `EventModuleConfigurator#onDispatch`/`#onBubble` property assignment — use `setOnDispatch`/`setOnBubble`. +- `IEventModuleConfigurator` type — use `EventModuleConfig`. diff --git a/.changeset/module-event_utils-helpers.md b/.changeset/module-event_utils-helpers.md new file mode 100644 index 0000000000..0998ec594d --- /dev/null +++ b/.changeset/module-event_utils-helpers.md @@ -0,0 +1,47 @@ +--- +"@equinor/fusion-framework-module-event": minor +--- + +Add `waitForEvent` and `watchEvents` helper utilities and `./operators` subpath. + +**New subpath exports:** + +```ts +import { filterEvent } from '@equinor/fusion-framework-module-event/operators'; +import { waitForEvent, watchEvents } from '@equinor/fusion-framework-module-event/utils'; +``` + +### `waitForEvent(provider, matcher, options?)` + +Resolves with the next event matching `matcher`. Accepts a single event type string (uses the type-scoped `filterEvent` path and preserves type narrowing), an array of type strings, or a predicate function. Supports an optional `timeout` (ms) and `AbortSignal` so a test cannot hang indefinitely. + +```ts +// Single type — typed result +const event = await waitForEvent(provider, 'onModulesLoaded'); + +// Array of types +const event = await waitForEvent(provider, ['myFeature.saved', 'myFeature.updated']); + +// Predicate matching on payload +const event = await waitForEvent(provider, (e) => e.detail?.id === 1); + +// With timeout +const event = await waitForEvent(provider, 'myFeature.saved', { timeout: 1000 }); +``` + +### `watchEvents(provider, matcher)` + +Collects all events matching `matcher` into an array. Only matching events are ever stored — a high volume of non-matching dispatches does not cause unbounded memory growth. Returns a handle with `events`, `lastEvent(type?)`, and `dispose()`. + +```ts +const handle = watchEvents(provider, ['myFeature.saved', 'myFeature.deleted']); +// ... run code under test ... +expect(handle.lastEvent('myFeature.saved')?.detail).toEqual({ id: 1 }); +handle.dispose(); +``` + +### `./operators` subpath + +`filterEvent` is now also exported from `@equinor/fusion-framework-module-event/operators`. The existing root export is preserved — no migration required for current consumers. + +Resolves [equinor/fusion-core-tasks#1656](https://github.com/equinor/fusion-core-tasks/issues/1656). diff --git a/packages/modules/event/README.md b/packages/modules/event/README.md index a28f08a6f3..6e0965df25 100644 --- a/packages/modules/event/README.md +++ b/packages/modules/event/README.md @@ -10,6 +10,15 @@ Async event dispatching module for the Fusion Framework. Enables type-safe commu - **Application developers** that want to intercept, log, or cancel events flowing through the framework. - **Library consumers** that subscribe to event streams for analytics, debugging, or cross-cutting concerns. +## Documentation + +| Topic | Description | +|---|---| +| [Configuration](docs/configuration.md) | `onDispatch`/`onBubble` hooks and registering custom event types via `FrameworkEventMap` | +| [Observable Patterns](docs/observable-patterns.md) | `event$`, `filterEvent`, and the `./operators` subpath | +| [Lifecycle](docs/lifecycle.md) | Dispatch sequence, cancelable events, and bubbling | +| [Testing](docs/testing.md) | `waitForEvent`, `watchEvents`, and using a bespoke `ModulesConfigurator` in tests | + ## Quick start ### Install @@ -54,143 +63,15 @@ if (!event.canceled) { | `FrameworkEventHandler` | Type | Listener callback signature (sync or async) | | `IEventModuleProvider` | Interface | Public API for the event provider (`addEventListener`, `dispatchEvent`, `event$`) | | `EventModuleProvider` | Class | Default provider implementation | -| `IEventModuleConfigurator` | Interface | Configuration hooks (`onDispatch`, `onBubble`) | +| `EventModuleConfig` | Type | Resolved configuration hooks (`onDispatch`, `onBubble`) | +| `EventModuleConfigurator` | Class | Fluent config builder (`setOnDispatch`, `setOnBubble`) — see [Configuration](docs/configuration.md) | +| `IEventModuleConfigurator` | Type | _Deprecated_ alias for `EventModuleConfig` | | `filterEvent` | Function | RxJS operator to narrow `event$` to a single registered event type | | `EventModule` / `eventModuleKey` | Type / Const | Module definition and key (`'event'`) | -## Configuration - -Configure the event module during framework setup to hook into dispatch lifecycle: - -```ts -import type { FrameworkEvent } from '@equinor/fusion-framework-module-event'; - -const configurator = (config) => { - // Inspect or cancel events before listeners run - config.event.onDispatch = (event: FrameworkEvent) => { - if (!isAllowed(event)) { - event.preventDefault(); - } - }; - - // Disable bubbling to parent providers - delete config.event.onBubble; -}; -``` - -### `onDispatch` - -Called **before** registered listeners. Use it to log, validate, or cancel events globally. - -### `onBubble` - -Called **after** all listeners if the event still bubbles. By default, the framework wires this to forward events to the parent provider. Delete it to isolate events to the current scope. - -## Registering custom event types - -Extend `FrameworkEventMap` via TypeScript declaration merging to get type-safe `addEventListener` and `dispatchEvent` calls: - -```ts -import type { - FrameworkEvent, - FrameworkEventInit, -} from '@equinor/fusion-framework-module-event'; - -interface MyPayload { - id: string; - value: number; -} - -declare module '@equinor/fusion-framework-module-event' { - interface FrameworkEventMap { - 'myFeature': FrameworkEvent>; - } -} -``` - -After registration, both the event name and payload are type-checked: - -```ts -modules.event.addEventListener('myFeature', (event) => { - // event.detail is typed as MyPayload - console.log(event.detail.id); -}); -``` - -## Observable event stream - -The `event$` observable emits every dispatched event. Subscribers receive events **after** dispatch and **cannot** call `preventDefault` or `stopPropagation` — use `addEventListener` for side-effect-capable handling. - -```ts -import { filterEvent } from '@equinor/fusion-framework-module-event'; - -// Subscribe to all events -const sub = modules.event.event$.subscribe((event) => { - console.log(event.type, event.detail); -}); - -// Or filter to a specific registered event type -const filtered = modules.event.event$.pipe( - filterEvent('onModulesLoaded'), -).subscribe((event) => { - // event is narrowed to the registered type - console.log(event.detail); -}); - -// Unsubscribe on teardown -sub.unsubscribe(); -filtered.unsubscribe(); -``` - -## Event lifecycle - -1. `dispatchEvent` is called with a name + init or a `FrameworkEvent` instance. -2. The `onDispatch` hook runs (if configured). Canceling here stops all listeners. -3. Registered listeners execute sequentially. For cancelable events each listener is `await`ed; non-cancelable listeners fire without awaiting. -4. If the event still bubbles, the `onBubble` hook runs (typically forwarding to a parent provider). -5. The event is pushed to `event$` for observable subscribers. - -## Cancelable events - -Mark an event as `cancelable` in its init and `await` dispatch: - -```ts -const event = await modules.event.dispatchEvent('myEvent', { - detail: data, - cancelable: true, -}); - -if (event.canceled) { - // A listener called event.preventDefault() - return; -} -``` +### Subpaths -A listener cancels the event by calling `preventDefault()`: - -```ts -modules.event.addEventListener('myEvent', (event) => { - if (shouldBlock(event.detail)) { - event.preventDefault(); - } -}); -``` - -## Bubbling - -Events bubble to parent providers by default (`canBubble: true`). A listener can stop propagation: - -```ts -modules.event.addEventListener('myEvent', (event) => { - event.stopPropagation(); // prevents bubbling to parent -}); -``` - -Or disable bubbling for a specific event at dispatch time: - -```ts -await modules.event.dispatchEvent('myEvent', { - detail: data, - canBubble: false, -}); -``` \ No newline at end of file +| Subpath | Exports | Purpose | +|---|---|---| +| `./operators` | `filterEvent` | RxJS pipeable operators over `event$` (also re-exported from the package root) | +| `./utils` | `waitForEvent`, `watchEvents` | Plain helpers for waiting on / collecting dispatched events, in application code or tests | diff --git a/packages/modules/event/docs/configuration.md b/packages/modules/event/docs/configuration.md new file mode 100644 index 0000000000..7ee56d51d3 --- /dev/null +++ b/packages/modules/event/docs/configuration.md @@ -0,0 +1,97 @@ +# Configuration + +## Dispatch hooks + +The event module is configured through `EventModuleConfigurator`, a `BaseConfigBuilder` with +fluent `setOnDispatch`/`setOnBubble` setters: + +```ts +import { EventModuleConfigurator } from '@equinor/fusion-framework-module-event'; + +const doNotHandleEvents = ['onMyEvent']; +const doNotPropagateEvents = ['myOtherEvent']; + +const configurator = new EventModuleConfigurator(); + +// Inspect or cancel events before listeners run +configurator.setOnDispatch((event) => { + if (doNotHandleEvents.includes(event.type)) { + event.preventDefault(); + } + if (doNotPropagateEvents.includes(event.type)) { + event.stopPropagation(); + } +}); + +// Disable bubbling to parent providers +configurator.setOnBubble(undefined); +``` + +> **Deprecated:** assigning `configurator.onDispatch`/`configurator.onBubble` directly still +> works but is deprecated since `6.1.0` — use `setOnDispatch`/`setOnBubble` instead. + +### `onDispatch` + +Called **before** registered listeners. Use it to log, validate, or cancel events globally. + +### `onBubble` + +Called **after** all listeners if the event still bubbles. By default, the framework wires this to forward events to the parent provider. Pass `undefined` to isolate events to the current scope. + +## Registering custom event types + +Extend `FrameworkEventMap` via TypeScript declaration merging to get type-safe `addEventListener` and `dispatchEvent` calls. Declaring the map entry adds type hinting only — it does not add any runtime behavior: + +```ts +import type { + FrameworkEvent, + FrameworkEventInit, +} from '@equinor/fusion-framework-module-event'; + +interface MyPayload { + id: string; + value: number; +} + +declare module '@equinor/fusion-framework-module-event' { + interface FrameworkEventMap { + 'myFeature': FrameworkEvent>; + } +} +``` + +After registration, both the event name and payload are type-checked: + +```ts +modules.event.addEventListener('myFeature', (event) => { + // event.detail is typed as MyPayload + console.log(event.detail.id); +}); +``` + +## Custom event classes + +For behavior beyond a typed `detail`, subclass `FrameworkEvent` directly: + +```ts +class MyEvent extends FrameworkEvent { + constructor(readonly obj: MyObj, init: FrameworkEventInit) { + super('onMyEvent', init); + } +} + +// add type hinting +declare module '@equinor/fusion-framework-module-event' { + interface FrameworkEventMap { + onMyEvent: MyEvent; + } +} + +modules.event.dispatchEvent(new MyEvent(someObj, { detail, source })); + +modules.event.addEventListener('onMyEvent', (event) => { + console.log('is my custom event:', event instanceof MyEvent); + console.log('my custom obj', event.obj); +}); +``` + diff --git a/packages/modules/event/docs/lifecycle.md b/packages/modules/event/docs/lifecycle.md new file mode 100644 index 0000000000..d7d052b32a --- /dev/null +++ b/packages/modules/event/docs/lifecycle.md @@ -0,0 +1,102 @@ +# Lifecycle + +> **Async listeners:** listeners are allowed to run async, so when `cancelable: false` the +> dispatcher does not await their resolution — cancellation order across listeners is not +> guaranteed in that case. + +## Dispatch sequence + +```mermaid +sequenceDiagram + autonumber + Caller->>+Provider: dispatchEvent + Provider->>+Listeners: notify listeners + opt + Listeners->>Event: preventDefault() + end + opt + Listeners->>Event: stopPropagation() + end + opt event.canBubble + Provider->>Parent Provider: dispatchEvent + end +``` + +1. `dispatchEvent` is called with a name + init or a `FrameworkEvent` instance. +2. The `onDispatch` hook runs (if configured). Canceling here stops all listeners. +3. Registered listeners execute sequentially. For cancelable events each listener is `await`ed; non-cancelable listeners fire without awaiting. +4. If the event still bubbles, the `onBubble` hook runs (typically forwarding to a parent provider). +5. The event is pushed to `event$` for observable subscribers. + +## Cancelable events + +```mermaid +sequenceDiagram + autonumber + Caller->>+Provider: dispatchEvent + Provider-->>Listeners: await listeners + opt + Listeners->>Event: preventDefault() + end + opt + Listeners->>Event: stopPropagation() + end + opt event.canBubble + note over Provider,Parent Provider: preventDefault() makes canBubble return false + Provider->>Parent Provider: dispatchEvent + end + Provider-->>-Caller: resolve dispatch +``` + +Mark an event as `cancelable` in its init and `await` dispatch: + +```ts +const event = await modules.event.dispatchEvent('myEvent', { + detail: data, + cancelable: true, +}); + +if (event.canceled) { + // A listener called event.preventDefault() + return; +} +``` + +A listener cancels the event by calling `preventDefault()`: + +```ts +modules.event.addEventListener('myEvent', (event) => { + if (shouldBlock(event.detail)) { + event.preventDefault(); + } +}); +``` + +> **Important:** When dispatching a `cancelable` event you **must** `await` the `dispatchEvent` call. Firing without `await` means `preventDefault()` calls from listeners will not be respected. + +## Bubbling + +> **Event bubbling:** when a module instance is initialized with a reference to a parent +> instance, the event module subscribes to the parent's event provider by default — a +> consumer (e.g. an App) dispatching a `canBubble` event forwards it to its parent (e.g. a +> Portal) automatically. + +Events bubble to parent providers by default (`canBubble: true`). A listener can stop propagation: + +```ts +modules.event.addEventListener('myEvent', (event) => { + event.stopPropagation(); // prevents bubbling to parent +}); +``` + +Or disable bubbling for a specific event at dispatch time: + +```ts +await modules.event.dispatchEvent('myEvent', { + detail: data, + canBubble: false, +}); +``` + + +``` diff --git a/packages/modules/event/docs/observable-patterns.md b/packages/modules/event/docs/observable-patterns.md new file mode 100644 index 0000000000..01c9d43295 --- /dev/null +++ b/packages/modules/event/docs/observable-patterns.md @@ -0,0 +1,34 @@ +# Observable Patterns + +## The `event$` stream + +The `event$` observable emits every dispatched event. Subscribers receive events **after** dispatch and **cannot** call `preventDefault` or `stopPropagation` — use `addEventListener` for side-effect-capable handling. + +```ts +import { filterEvent } from '@equinor/fusion-framework-module-event'; + +// Subscribe to all events +const sub = modules.event.event$.subscribe((event) => { + console.log(event.type, event.detail); +}); + +// Or filter to a specific registered event type +const filtered = modules.event.event$.pipe( + filterEvent('onModulesLoaded'), +).subscribe((event) => { + // event is narrowed to the registered type + console.log(event.detail); +}); + +// Unsubscribe on teardown +sub.unsubscribe(); +filtered.unsubscribe(); +``` + +## The `./operators` subpath + +`filterEvent` is also exported from `@equinor/fusion-framework-module-event/operators`, alongside any future RxJS pipeable operators for the event module. The root-level export shown above is preserved for backward compatibility, so either import path works: + +```ts +import { filterEvent } from '@equinor/fusion-framework-module-event/operators'; +``` diff --git a/packages/modules/event/docs/testing.md b/packages/modules/event/docs/testing.md new file mode 100644 index 0000000000..9251e59fbb --- /dev/null +++ b/packages/modules/event/docs/testing.md @@ -0,0 +1,88 @@ +# Testing + +`@equinor/fusion-framework-module-event/utils` provides standalone helper functions for waiting on and collecting dispatched events, so tests (and application code) don't need to hand-roll `event$` subscriptions. + +These are plain functions, not test doubles — they work identically against any real `IEventModuleProvider`, whether that provider comes from a mocked host Fusion (app or portal test), or a hand-rolled `ModulesConfigurator` in a 3rd-party bespoke integration. No mock, no test-runner dependency, and no consumer-specific code path. + +```ts +import { waitForEvent, watchEvents } from '@equinor/fusion-framework-module-event/utils'; +``` + +## `waitForEvent` + +Resolves with the next event matching `matcher`, built on `provider.event$`. + +```ts +// Single registered type — resolves with the typed FrameworkEventMap entry +const event = await waitForEvent(modules.event, 'myFeature.saved'); +expect(event.detail).toEqual({ id: 1 }); +``` + +`matcher` also accepts an array of type strings, or a predicate that filters on the event payload instead of just its type: + +```ts +// Array of types — resolves on whichever fires first +const event = await waitForEvent(modules.event, ['myFeature.saved', 'myFeature.updated']); + +// Predicate — matches on payload, not just type +const event = await waitForEvent( + modules.event, + (e) => e.type === 'myFeature.saved' && e.detail.id === 1, +); +``` + +`string`/`string[]` matchers reuse the same type-scoped `filterEvent` path used by `event$.pipe(filterEvent(type))` elsewhere in the module. A bare predicate has no type to scope on, so it filters the raw `event$` stream directly. + +### Timing out + +Pass `timeout` (milliseconds) or an `AbortSignal` so a test fails fast instead of hanging when the expected event never fires: + +```ts +// Rejects after 1000ms if the event never fires +const event = await waitForEvent(modules.event, 'myFeature.saved', { timeout: 1000 }); + +// Rejects immediately when the signal aborts +const controller = new AbortController(); +const event = await waitForEvent(modules.event, 'myFeature.saved', { signal: controller.signal }); +``` + +`waitForEvent` is one-shot: it resolves once and retains nothing beyond the single matched event. + +## `watchEvents` + +Collects every event matching `matcher`, for assertions across multiple occurrences or ordering. Only events that pass `matcher` are ever stored — a high volume of non-matching events dispatched elsewhere in the app does not grow memory. This is deliberate: there is no unscoped "record everything" mode. + +```ts +const events = watchEvents(modules.event, ['myFeature.saved', 'myFeature.deleted']); + +// ... run the code under test ... + +expect(events.events).toHaveLength(2); +expect(events.lastEvent('myFeature.saved')?.detail).toEqual({ id: 1 }); + +// stop collecting once assertions are done +events.dispose(); +``` + +`WatchEventsHandle` exposes: + +- `events` — all matching events collected so far, in dispatch order +- `lastEvent(type?)` — the most recently collected event, optionally narrowed to a specific type +- `dispose()` — stops collecting; already-collected events remain accessible + +## Using a bespoke `ModulesConfigurator` + +Both helpers take an `IEventModuleProvider`, so they work the same way with a manually composed set of modules — no app or portal host required: + +```ts +import { ModulesConfigurator } from '@equinor/fusion-framework-module'; +import eventModule from '@equinor/fusion-framework-module-event'; +import { waitForEvent } from '@equinor/fusion-framework-module-event/utils'; + +const instances = await new ModulesConfigurator([eventModule /* + other modules */]).initialize(); +const event = await waitForEvent(instances.event, 'theirCustomEvent'); +``` + +## Intercepting or canceling events + +`waitForEvent` and `watchEvents` are for **observing** events that have already been dispatched — they cannot cancel or alter an event before its listeners run. To intercept events before dispatch (for example, to block one in a test), configure the module's `onDispatch` hook instead — see [Configuration](configuration.md). diff --git a/packages/modules/event/package.json b/packages/modules/event/package.json index 2870d8b918..b467d5e80b 100644 --- a/packages/modules/event/package.json +++ b/packages/modules/event/package.json @@ -9,18 +9,33 @@ ".": { "import": "./dist/esm/index.js", "types": "./dist/types/index.d.ts" + }, + "./operators": { + "import": "./dist/esm/operators/index.js", + "types": "./dist/types/operators/index.d.ts" + }, + "./utils": { + "import": "./dist/esm/utils/index.js", + "types": "./dist/types/utils/index.d.ts" } }, "typesVersions": { "*": { ".": [ "dist/types/index.d.ts" + ], + "operators": [ + "dist/types/operators/index.d.ts" + ], + "utils": [ + "dist/types/utils/index.d.ts" ] } }, "scripts": { "build": "tsc -b", - "prepack": "pnpm build" + "prepack": "pnpm build", + "test": "vitest run" }, "keywords": [ "config" @@ -40,6 +55,7 @@ }, "devDependencies": { "rxjs": "^7.8.1", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "vitest": "^3.2.4" } } diff --git a/packages/modules/event/src/EventModuleConfigurator.ts b/packages/modules/event/src/EventModuleConfigurator.ts new file mode 100644 index 0000000000..77facaeaa8 --- /dev/null +++ b/packages/modules/event/src/EventModuleConfigurator.ts @@ -0,0 +1,127 @@ +import { BaseConfigBuilder } from '@equinor/fusion-framework-module'; + +import type { FrameworkEvent } from './FrameworkEvent'; + +/** + * Resolved configuration for the event module. + * + * Allows consumers to hook into the event dispatch lifecycle by providing + * optional `onDispatch` and `onBubble` callbacks during module setup. + * + * @example + * ```ts + * const configurator: EventModuleConfig = { + * onDispatch: (event) => { + * if (!isAllowed(event)) { + * event.preventDefault(); + * } + * }, + * }; + * ``` + */ +export type EventModuleConfig = { + /** + * Callback invoked **before** listeners when an event is dispatched. + * + * Use this hook to inspect, log, or cancel events before they reach + * registered listeners. Calling `event.preventDefault()` here prevents + * listeners from executing. + * + * @param event - The event about to be dispatched. + */ + onDispatch?: (event: FrameworkEvent) => Promise | void; + + /** + * Callback invoked **after** all listeners when an event still bubbles. + * + * Typically used internally to propagate events to a parent provider. + * Not called if `preventDefault` or `stopPropagation` was invoked. + * + * @param event - The event that completed listener dispatch. + */ + onBubble?: (event: FrameworkEvent) => Promise | void; +}; + +/** + * Configuration builder for the event module. + * + * Provides `setOnDispatch`/`setOnBubble` fluent setters for the event dispatch + * lifecycle hooks, following the same {@link BaseConfigBuilder} pattern used by + * other Fusion Framework module configurators. + * + * @example + * ```ts + * const configurator = new EventModuleConfigurator(); + * configurator.setOnDispatch((event) => { + * if (!isAllowed(event)) { + * event.preventDefault(); + * } + * }); + * ``` + */ +export class EventModuleConfigurator extends BaseConfigBuilder { + #onDispatch?: EventModuleConfig['onDispatch']; + #onBubble?: EventModuleConfig['onBubble']; + + /** Creates a new event module configurator with no hooks set. */ + constructor() { + super(); + // Read the private fields lazily so they reflect whichever setter ran last + this._set('onDispatch', async () => this.#onDispatch); + this._set('onBubble', async () => this.#onBubble); + } + + /** + * Sets the callback invoked **before** listeners when an event is dispatched. + * + * @param handler - Callback to inspect, log, or cancel the event via `event.preventDefault()`. + * @returns The configurator instance, for chaining. + */ + setOnDispatch(handler: EventModuleConfig['onDispatch']): this { + this.#onDispatch = handler; + return this; + } + + /** + * Sets the callback invoked **after** all listeners when an event still bubbles. + * + * @param handler - Callback typically used to forward the event to a parent provider. + * @returns The configurator instance, for chaining. + */ + setOnBubble(handler: EventModuleConfig['onBubble']): this { + this.#onBubble = handler; + return this; + } + + /** + * @param handler - Callback to inspect, log, or cancel the event via `event.preventDefault()`. + * @deprecated Since 6.1.0. Use {@link EventModuleConfigurator.setOnDispatch} instead. + */ + set onDispatch(handler: EventModuleConfig['onDispatch']) { + this.#onDispatch = handler; + } + + /** + * @returns The currently configured `onDispatch` handler, if any. + * @deprecated Since 6.1.0. Use {@link EventModuleConfigurator.setOnDispatch} instead. + */ + get onDispatch(): EventModuleConfig['onDispatch'] { + return this.#onDispatch; + } + + /** + * @param handler - Callback typically used to forward the event to a parent provider. + * @deprecated Since 6.1.0. Use {@link EventModuleConfigurator.setOnBubble} instead. + */ + set onBubble(handler: EventModuleConfig['onBubble']) { + this.#onBubble = handler; + } + + /** + * @returns The currently configured `onBubble` handler, if any. + * @deprecated Since 6.1.0. Use {@link EventModuleConfigurator.setOnBubble} instead. + */ + get onBubble(): EventModuleConfig['onBubble'] { + return this.#onBubble; + } +} diff --git a/packages/modules/event/src/provider.ts b/packages/modules/event/src/EventModuleProvider.ts similarity index 88% rename from packages/modules/event/src/provider.ts rename to packages/modules/event/src/EventModuleProvider.ts index 7d57a3048a..e28d432a54 100644 --- a/packages/modules/event/src/provider.ts +++ b/packages/modules/event/src/EventModuleProvider.ts @@ -3,7 +3,7 @@ import { type Observable, Subject } from 'rxjs'; import { BaseModuleProvider } from '@equinor/fusion-framework-module/provider'; import { version } from './version.js'; -import type { IEventModuleConfigurator } from './configurator'; +import type { EventModuleConfig } from './EventModuleConfigurator'; import { FrameworkEventDispatcher, type FrameworkEventHandler } from './FrameworkEventDispatcher'; import { @@ -23,7 +23,8 @@ import { */ export interface IEventModuleProvider { /** Observable stream of all dispatched events, useful for logging or analysis. - * Subscribers receive read-only copies and cannot call `preventDefault`. + * Events are emitted after listeners and the bubble hook have run, so calling + * `preventDefault`/`stopPropagation` on them at this point has no effect. */ readonly event$: Observable; @@ -64,7 +65,7 @@ export interface IEventModuleProvider { dispatchEvent( type: TType, args: FrameworkEventInitType, - ): Promise; + ): Promise; /** * Dispatches an event with an arbitrary name and typed payload. @@ -89,7 +90,7 @@ export interface IEventModuleProvider { */ dispatchEvent( event: TType, - ): Promise; + ): Promise; /** Disposes the provider, completing `event$` and removing all listeners. */ dispose: VoidFunction; @@ -106,7 +107,7 @@ export interface IEventModuleProvider { * the observable stream. */ export class EventModuleProvider - extends BaseModuleProvider + extends BaseModuleProvider implements IEventModuleProvider { private __listeners: Array<{ @@ -121,9 +122,9 @@ export class EventModuleProvider /** * Observable stream of all events dispatched through this provider. * - * Subscribers receive events after dispatch but **cannot** call - * `preventDefault` or `stopPropagation` — use `addEventListener` for - * side-effect-capable handling. + * Events are emitted after listeners and the bubble hook have run, so + * `preventDefault`/`stopPropagation` calls at this point have no effect — + * use `addEventListener` for side-effect-capable handling. * * @returns An observable of framework events. */ @@ -141,7 +142,7 @@ export class EventModuleProvider * * @param config - Configuration with optional `onDispatch` and `onBubble` hooks. */ - constructor(config: IEventModuleConfigurator) { + constructor(config: EventModuleConfig) { super({ version, config }); this.__dispatcher = new FrameworkEventDispatcher({ onDispatch: config.onDispatch, @@ -224,17 +225,17 @@ export class EventModuleProvider throw Error('Cannot dispatch events when provider is closed!'); } - this.__event$.next(event); + const listeners = this.__listeners + // Resolve only the handlers registered for this event's type + .filter((listener) => listener.type === event.type) + // Extract just the handler functions to invoke + .map(({ handler }) => handler); try { - const listeners = this.__listeners - // Resolve only the handlers registered for this event's type - .filter((listener) => listener.type === event.type) - // Extract just the handler functions to invoke - .map(({ handler }) => handler); await this.__dispatcher.dispatch(event, listeners); - } catch (err) { - throw err as Error; + } finally { + // publish to event$ only after listeners have run, matching the documented order + this.__event$.next(event); } return event; } diff --git a/packages/modules/event/src/FrameworkEvent.ts b/packages/modules/event/src/FrameworkEvent.ts index 5568a7b176..21b91f1da2 100644 --- a/packages/modules/event/src/FrameworkEvent.ts +++ b/packages/modules/event/src/FrameworkEvent.ts @@ -1,6 +1,6 @@ // biome-ignore-all lint/suspicious/noUnsafeDeclarationMerging: FrameworkEvent classes are intentionally merged with a constructible interface import type { ModuleInstance } from '@equinor/fusion-framework-module'; -import type { IEventModuleProvider } from './provider'; +import type { IEventModuleProvider } from './EventModuleProvider'; /** * Registry of known framework event names mapped to their event types. diff --git a/packages/modules/event/src/__tests__/waitForEvent.test.ts b/packages/modules/event/src/__tests__/waitForEvent.test.ts new file mode 100644 index 0000000000..178c89751f --- /dev/null +++ b/packages/modules/event/src/__tests__/waitForEvent.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { EventModuleProvider } from '../EventModuleProvider'; +import type { IFrameworkEvent } from '../FrameworkEvent'; +import { waitForEvent } from '../utils'; + +/** Creates a bare provider — no bubbling, no dispatch hooks. */ +const createProvider = () => new EventModuleProvider({} as never); + +describe('waitForEvent', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves when a matching single-type event fires', async () => { + const provider = createProvider(); + + const promise = waitForEvent(provider, 'onModulesLoaded'); + await provider.dispatchEvent('onModulesLoaded', { detail: {} as never, source: provider }); + const event = await promise; + + expect(event.type).toBe('onModulesLoaded'); + provider.dispose(); + }); + + it('resolves when one of the types in an array fires', async () => { + const provider = createProvider(); + + const promise = waitForEvent(provider, ['myFeature.saved', 'myFeature.updated'] as string[]); + await provider.dispatchEvent('myFeature.saved', { detail: { id: 1 } }); + const event = await promise; + + expect(event.type).toBe('myFeature.saved'); + provider.dispose(); + }); + + it('resolves via predicate matcher', async () => { + const provider = createProvider(); + + const promise = waitForEvent( + provider, + (e: IFrameworkEvent) => e.type === 'myFeature.saved' && (e.detail as { id: number }).id === 42, + ); + // Non-matching dispatch — should not resolve yet. + await provider.dispatchEvent('myFeature.saved', { detail: { id: 1 } }); + // Matching dispatch. + await provider.dispatchEvent('myFeature.saved', { detail: { id: 42 } }); + const event = await promise; + + expect((event.detail as { id: number }).id).toBe(42); + provider.dispose(); + }); + + it('rejects when the timeout elapses before an event fires', async () => { + vi.useFakeTimers(); + const provider = createProvider(); + + const promise = waitForEvent(provider, 'myFeature.saved', { timeout: 500 }); + vi.advanceTimersByTime(501); + + await expect(promise).rejects.toThrow('waitForEvent timed out after 500ms'); + provider.dispose(); + }); + + it('rejects when the AbortSignal fires before an event', async () => { + const provider = createProvider(); + const controller = new AbortController(); + + const promise = waitForEvent(provider, 'myFeature.saved', { signal: controller.signal }); + controller.abort(); + + await expect(promise).rejects.toThrow(); + provider.dispose(); + }); + + it('rejects immediately when passed an already-aborted signal', async () => { + const provider = createProvider(); + const controller = new AbortController(); + controller.abort(); + + await expect(waitForEvent(provider, 'myFeature.saved', { signal: controller.signal })).rejects.toThrow(); + provider.dispose(); + }); + + it('rejects when the event stream completes before an event resolves', async () => { + const provider = createProvider(); + + const promise = waitForEvent(provider, 'myFeature.saved'); + provider.dispose(); // completes event$ + + await expect(promise).rejects.toThrow('Event stream completed'); + }); +}); diff --git a/packages/modules/event/src/__tests__/watchEvents.test.ts b/packages/modules/event/src/__tests__/watchEvents.test.ts new file mode 100644 index 0000000000..7d323a064a --- /dev/null +++ b/packages/modules/event/src/__tests__/watchEvents.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; +import { EventModuleProvider } from '../EventModuleProvider'; +import type { IFrameworkEvent } from '../FrameworkEvent'; +import { watchEvents } from '../utils'; + +const createProvider = () => new EventModuleProvider({} as never); + +describe('watchEvents', () => { + it('collects matching events in dispatch order', async () => { + const provider = createProvider(); + const handle = watchEvents(provider, 'myFeature.saved'); + + await provider.dispatchEvent('myFeature.saved', { detail: { id: 1 } }); + await provider.dispatchEvent('myFeature.saved', { detail: { id: 2 } }); + + expect(handle.events).toHaveLength(2); + expect((handle.events[0].detail as { id: number }).id).toBe(1); + expect((handle.events[1].detail as { id: number }).id).toBe(2); + + handle.dispose(); + provider.dispose(); + }); + + it('only stores events that pass the matcher', async () => { + const provider = createProvider(); + const handle = watchEvents(provider, ['myFeature.saved'] as string[]); + + await provider.dispatchEvent('myFeature.saved', { detail: {} }); + await provider.dispatchEvent('myFeature.deleted', { detail: {} }); + await provider.dispatchEvent('unrelated', { detail: {} }); + + expect(handle.events).toHaveLength(1); + expect(handle.events[0].type).toBe('myFeature.saved'); + + handle.dispose(); + provider.dispose(); + }); + + it('lastEvent() returns the most recent collected event', async () => { + const provider = createProvider(); + const handle = watchEvents(provider, ['myFeature.saved', 'myFeature.deleted'] as string[]); + + await provider.dispatchEvent('myFeature.saved', { detail: { id: 1 } }); + await provider.dispatchEvent('myFeature.deleted', { detail: { id: 1 } }); + + expect(handle.lastEvent()?.type).toBe('myFeature.deleted'); + + handle.dispose(); + provider.dispose(); + }); + + it('lastEvent(type) returns the last event of that specific type', async () => { + const provider = createProvider(); + const handle = watchEvents(provider, ['myFeature.saved', 'myFeature.deleted'] as string[]); + + await provider.dispatchEvent('myFeature.saved', { detail: { id: 1 } }); + await provider.dispatchEvent('myFeature.saved', { detail: { id: 2 } }); + await provider.dispatchEvent('myFeature.deleted', { detail: { id: 1 } }); + + const last = handle.lastEvent('myFeature.saved'); + expect((last?.detail as { id: number } | undefined)?.id).toBe(2); + + handle.dispose(); + provider.dispose(); + }); + + it('lastEvent(type) returns undefined when no event of that type was collected', async () => { + const provider = createProvider(); + const handle = watchEvents(provider, 'myFeature.saved'); + + expect(handle.lastEvent('myFeature.deleted')).toBeUndefined(); + + handle.dispose(); + provider.dispose(); + }); + + it('stops collecting after dispose()', async () => { + const provider = createProvider(); + const handle = watchEvents(provider, 'myFeature.saved'); + + await provider.dispatchEvent('myFeature.saved', { detail: { id: 1 } }); + handle.dispose(); + await provider.dispatchEvent('myFeature.saved', { detail: { id: 2 } }); + + expect(handle.events).toHaveLength(1); + + provider.dispose(); + }); + + it('works with a predicate matcher', async () => { + const provider = createProvider(); + const handle = watchEvents( + provider, + (e: IFrameworkEvent) => e.type === 'myFeature.saved' && (e.detail as { id: number }).id > 10, + ); + + await provider.dispatchEvent('myFeature.saved', { detail: { id: 5 } }); + await provider.dispatchEvent('myFeature.saved', { detail: { id: 15 } }); + + expect(handle.events).toHaveLength(1); + expect((handle.events[0].detail as { id: number }).id).toBe(15); + + handle.dispose(); + provider.dispose(); + }); + + it('does not grow memory when many non-matching events fire', async () => { + const provider = createProvider(); + const handle = watchEvents(provider, 'myFeature.saved'); + + const dispatches = Array.from({ length: 1000 }, (_, i) => + provider.dispatchEvent('unrelated', { detail: { i } }), + ); + await Promise.all(dispatches); + + expect(handle.events).toHaveLength(0); + + handle.dispose(); + provider.dispose(); + }); +}); diff --git a/packages/modules/event/src/configurator.ts b/packages/modules/event/src/configurator.ts deleted file mode 100644 index ea7a3dc6aa..0000000000 --- a/packages/modules/event/src/configurator.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { FrameworkEvent } from './FrameworkEvent'; - -/** - * Configuration interface for the event module. - * - * Allows consumers to hook into the event dispatch lifecycle by providing - * optional `onDispatch` and `onBubble` callbacks during module setup. - * - * @example - * ```ts - * const configurator: IEventModuleConfigurator = { - * onDispatch: (event) => { - * if (!isAllowed(event)) { - * event.preventDefault(); - * } - * }, - * }; - * ``` - */ -export interface IEventModuleConfigurator { - /** - * Callback invoked **before** listeners when an event is dispatched. - * - * Use this hook to inspect, log, or cancel events before they reach - * registered listeners. Calling `event.preventDefault()` here prevents - * listeners from executing. - * - * @param event - The event about to be dispatched. - */ - onDispatch?: (event: FrameworkEvent) => Promise | void; - - /** - * Callback invoked **after** all listeners when an event still bubbles. - * - * Typically used internally to propagate events to a parent provider. - * Not called if `preventDefault` or `stopPropagation` was invoked. - * - * @param event - The event that completed listener dispatch. - */ - onBubble?: (event: FrameworkEvent) => Promise | void; -} diff --git a/packages/modules/event/src/index.ts b/packages/modules/event/src/index.ts index 7fbfd7c092..5246c6eb1a 100644 --- a/packages/modules/event/src/index.ts +++ b/packages/modules/event/src/index.ts @@ -21,10 +21,16 @@ export type { FrameworkEventInitType, } from './FrameworkEvent'; -export { IEventModuleConfigurator } from './configurator'; -export { IEventModuleProvider, EventModuleProvider } from './provider'; +import type { EventModuleConfig } from './EventModuleConfigurator'; + +export { EventModuleConfig, EventModuleConfigurator } from './EventModuleConfigurator'; + +/** @deprecated Since 6.1.0. Use {@link EventModuleConfig} instead. */ +export type IEventModuleConfigurator = EventModuleConfig; + +export { IEventModuleProvider, EventModuleProvider } from './EventModuleProvider'; export { EventModule, moduleKey as eventModuleKey } from './module'; -export { filterEvent } from './filter-event'; +export { filterEvent } from './operators/filter-event'; export { default } from './module'; diff --git a/packages/modules/event/src/module.ts b/packages/modules/event/src/module.ts index 5ccf6a433c..fd66584499 100644 --- a/packages/modules/event/src/module.ts +++ b/packages/modules/event/src/module.ts @@ -1,13 +1,14 @@ import type { Module, ModuleInstance, ModulesInstanceType } from '@equinor/fusion-framework-module'; import type { FrameworkEvent, FrameworkEventInit } from './FrameworkEvent'; -import type { IEventModuleConfigurator } from './configurator'; -import { EventModuleProvider, type IEventModuleProvider } from './provider'; +import { EventModuleConfigurator } from './EventModuleConfigurator'; +import { EventModuleProvider, type IEventModuleProvider } from './EventModuleProvider'; /** Module key used to identify the event module in the Fusion module system. */ export const moduleKey = 'event'; /** Type alias for the event module definition. */ -export type EventModule = Module; +export type EventModule = Module; + /** * Event type dispatched when all framework modules have finished loading. @@ -31,17 +32,17 @@ export type FrameworkEventModuleLoadedEvent = FrameworkEvent< export const module: EventModule = { name: moduleKey, configure: (ref?: Partial>) => { - const configurator = {} as IEventModuleConfigurator; + const configurator = new EventModuleConfigurator(); const parentProvider = ref?.event; // Only wire up bubbling when a parent event provider actually exists if (parentProvider) { - configurator.onBubble = async (e) => { + configurator.setOnBubble(async (e) => { await parentProvider.dispatchEvent(e); - }; + }); } return configurator; }, - initialize: ({ config }) => new EventModuleProvider(config), + initialize: async (init) => new EventModuleProvider(await init.config.createConfigAsync(init)), postInitialize: async ({ instance, modules }) => { instance.dispatchEvent('onModulesLoaded', { detail: modules, source: instance }); }, diff --git a/packages/modules/event/src/filter-event.ts b/packages/modules/event/src/operators/filter-event.ts similarity index 92% rename from packages/modules/event/src/filter-event.ts rename to packages/modules/event/src/operators/filter-event.ts index cd7b6ba730..17ff79695e 100644 --- a/packages/modules/event/src/filter-event.ts +++ b/packages/modules/event/src/operators/filter-event.ts @@ -1,5 +1,5 @@ import { filter } from 'rxjs'; -import type { FrameworkEventMap, IFrameworkEvent } from './FrameworkEvent'; +import type { FrameworkEventMap, IFrameworkEvent } from '../FrameworkEvent'; /** * Creates an RxJS `filter` operator that narrows the observable stream to a diff --git a/packages/modules/event/src/operators/index.ts b/packages/modules/event/src/operators/index.ts new file mode 100644 index 0000000000..8d5a1d1a46 --- /dev/null +++ b/packages/modules/event/src/operators/index.ts @@ -0,0 +1,7 @@ +/** + * RxJS pipeable operators for the Fusion event module. + * + * @module @equinor/fusion-framework-module-event/operators + */ + +export { filterEvent } from './filter-event'; diff --git a/packages/modules/event/src/utils/apply-event-matcher.ts b/packages/modules/event/src/utils/apply-event-matcher.ts new file mode 100644 index 0000000000..b67d5900e7 --- /dev/null +++ b/packages/modules/event/src/utils/apply-event-matcher.ts @@ -0,0 +1,43 @@ +import { filter, type Observable } from 'rxjs'; + +import type { IEventModuleProvider } from '../EventModuleProvider'; +import type { FrameworkEventMap, IFrameworkEvent } from '../FrameworkEvent'; +import { filterEvent } from '../operators/filter-event'; + +/** + * A matcher that selects which events `waitForEvent` or `watchEvents` act on. + * + * - `string` — a single registered event type; uses the type-scoped `filterEvent` path. + * - `string[]` — multiple event types; an event matching any entry passes. + * - `(event) => boolean` — arbitrary predicate; filters the raw `event$` stream. + */ +export type EventMatcher = + | keyof FrameworkEventMap + | (string & Record) + | string[] + | ((event: IFrameworkEvent) => boolean); + +/** + * Applies an {@link EventMatcher} to a provider's event stream. + * + * @param provider - The event module provider to observe. + * @param matcher - Event type string, array of type strings, or a predicate. + * @returns An observable emitting only events that pass `matcher`. + */ +export function applyEventMatcher( + provider: IEventModuleProvider, + matcher: EventMatcher, +): Observable { + // Single type: prefer the type-scoped filterEvent path over a raw predicate. + if (typeof matcher === 'string') { + // Narrow the stream to the matching FrameworkEventMap entry. + return provider.event$.pipe(filterEvent(matcher as keyof FrameworkEventMap)); + } + // Multiple types: match if the event type is any of the given entries. + if (Array.isArray(matcher)) { + // Keep only events whose type is in the matcher array. + return provider.event$.pipe(filter((e) => matcher.includes(e.type))); + } + // Bare predicate: no type to scope on, filter the raw stream directly. + return provider.event$.pipe(filter(matcher)); +} diff --git a/packages/modules/event/src/utils/index.ts b/packages/modules/event/src/utils/index.ts new file mode 100644 index 0000000000..48e0928ef8 --- /dev/null +++ b/packages/modules/event/src/utils/index.ts @@ -0,0 +1,9 @@ +/** + * Plain helper functions for waiting on and collecting dispatched framework events. + * + * @module @equinor/fusion-framework-module-event/utils + */ + +export type { EventMatcher } from './apply-event-matcher'; +export { waitForEvent, type WaitForEventOptions } from './wait-for-event'; +export { watchEvents, type WatchEventsHandle } from './watch-events'; diff --git a/packages/modules/event/src/utils/wait-for-event.ts b/packages/modules/event/src/utils/wait-for-event.ts new file mode 100644 index 0000000000..a1cb796bd1 --- /dev/null +++ b/packages/modules/event/src/utils/wait-for-event.ts @@ -0,0 +1,130 @@ +import type { IEventModuleProvider } from '../EventModuleProvider'; +import type { FrameworkEventMap, IFrameworkEvent } from '../FrameworkEvent'; +import { applyEventMatcher, type EventMatcher } from './apply-event-matcher'; + +/** Options accepted by {@link waitForEvent}. */ +export interface WaitForEventOptions { + /** + * Maximum time in milliseconds to wait for a matching event. + * When elapsed the returned promise rejects. + */ + timeout?: number; + /** + * AbortSignal that can cancel the wait early. + * When aborted the returned promise rejects with the signal's reason. + */ + signal?: AbortSignal; +} + +/** + * Waits for the next event that matches `matcher` and resolves with it. + * + * Uses `filterEvent` internally when `matcher` is a single registered type, + * giving the same type narrowing already provided by that operator. + * + * @template TType - A registered event name from {@link FrameworkEventMap}. + * @param provider - The event module provider to observe. + * @param matcher - Event type string, array of type strings, or a predicate. + * @param options - Optional timeout (ms) or AbortSignal. + * @returns A promise that resolves with the first matching event. + * + * @example + * ```ts + * // Single type — resolves with FrameworkEventMap['onModulesLoaded'] + * const event = await waitForEvent(provider, 'onModulesLoaded'); + * + * // Array — resolves on whichever fires first + * const event = await waitForEvent(provider, ['myFeature.saved', 'myFeature.updated']); + * + * // Predicate — filters on payload, not just type + * const event = await waitForEvent(provider, (e) => e.detail?.id === 1); + * + * // With a timeout so a missing event fails fast + * const event = await waitForEvent(provider, 'myFeature.saved', { timeout: 1000 }); + * ``` + */ +export function waitForEvent( + provider: IEventModuleProvider, + matcher: TType, + options?: WaitForEventOptions, +): Promise; + +/** + * Overload for an array of event types or a payload predicate, where the + * result cannot be narrowed to a single {@link FrameworkEventMap} entry. + * + * @param provider - The event module provider to observe. + * @param matcher - An array of event type strings, or a predicate. + * @param options - Optional timeout (ms) or AbortSignal. + * @returns A promise that resolves with the first matching event. + */ +export function waitForEvent( + provider: IEventModuleProvider, + matcher: string | string[] | ((event: IFrameworkEvent) => boolean), + options?: WaitForEventOptions, +): Promise; + +/** + * Implementation shared by both {@link waitForEvent} overloads above. + * + * @param provider - The event module provider to observe. + * @param matcher - Event type string, array of type strings, or a predicate. + * @param options - Optional timeout (ms) or AbortSignal. + * @returns A promise that resolves with the first matching event. + */ +export function waitForEvent( + provider: IEventModuleProvider, + matcher: EventMatcher, + options?: WaitForEventOptions, +): Promise { + return new Promise((resolve, reject) => { + const { timeout: ms, signal } = options ?? {}; + + // Fail fast without subscribing when the caller already aborted. + if (signal?.aborted) { + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + return; + } + + const source$ = applyEventMatcher(provider, matcher); + let timer: ReturnType | undefined; + + const cleanup = (err?: unknown) => { + clearTimeout(timer); + sub.unsubscribe(); + // Only reject when cleanup was triggered by an error, not a resolved match. + if (err !== undefined) reject(err); + }; + + const sub = source$.subscribe({ + next: (event) => { + cleanup(); + resolve(event); + }, + error: (err) => cleanup(err), + complete: () => + cleanup(new Error('Event stream completed before a matching event was received')), + }); + + // Only arm a timeout when the caller opted in. + if (ms !== undefined) { + timer = setTimeout(() => { + sub.unsubscribe(); + reject(new Error(`waitForEvent timed out after ${ms}ms`)); + }, ms); + } + + // Only wire abort handling when the caller passed a signal. + if (signal) { + signal.addEventListener( + 'abort', + () => { + sub.unsubscribe(); + clearTimeout(timer); + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + }, + { once: true }, + ); + } + }); +} diff --git a/packages/modules/event/src/utils/watch-events.ts b/packages/modules/event/src/utils/watch-events.ts new file mode 100644 index 0000000000..aace57baa6 --- /dev/null +++ b/packages/modules/event/src/utils/watch-events.ts @@ -0,0 +1,74 @@ +import type { IEventModuleProvider } from '../EventModuleProvider'; +import type { IFrameworkEvent } from '../FrameworkEvent'; +import { applyEventMatcher, type EventMatcher } from './apply-event-matcher'; + +/** + * A handle returned by {@link watchEvents} that exposes collected events and a + * dispose method. + */ +export interface WatchEventsHandle { + /** + * All matching events collected so far, in dispatch order. + * Only events passing the original matcher are ever stored. + */ + readonly events: readonly IFrameworkEvent[]; + /** + * Returns the most recently collected event, optionally narrowed to a + * specific type. + * + * @param type - When provided, returns the last event with this type. + */ + lastEvent(type?: string): IFrameworkEvent | undefined; + /** Stops collecting events. Already-collected events remain accessible. */ + dispose(): void; +} + +/** + * Starts collecting events that match `matcher`, returning a handle for + * reading collected events and stopping collection. + * + * Only events that pass `matcher` are ever stored — a high volume of + * non-matching events does not cause unbounded memory growth. + * + * @param provider - The event module provider to observe. + * @param matcher - Event type string, array of type strings, or a predicate. + * @returns A handle with the collected events and a `dispose` method. + * + * @example + * ```ts + * const handle = watchEvents(provider, ['myFeature.saved', 'myFeature.deleted']); + * + * // ... run the code under test ... + * + * expect(handle.lastEvent('myFeature.saved')?.detail).toEqual({ id: 1 }); + * handle.dispose(); + * ``` + */ +export function watchEvents( + provider: IEventModuleProvider, + matcher: EventMatcher, +): WatchEventsHandle { + const collected: IFrameworkEvent[] = []; + const sub = applyEventMatcher(provider, matcher).subscribe((event) => collected.push(event)); + + return { + get events(): readonly IFrameworkEvent[] { + return collected; + }, + lastEvent(type?: string): IFrameworkEvent | undefined { + // Narrow to a specific type only when the caller asked for one. + if (type !== undefined) { + // Walk backward — avoids creating a reversed copy. + for (let i = collected.length - 1; i >= 0; i--) { + // Return on the first (most recent) match. + if (collected[i].type === type) return collected[i]; + } + return undefined; + } + return collected[collected.length - 1]; + }, + dispose() { + sub.unsubscribe(); + }, + }; +} diff --git a/packages/modules/event/vitest.config.ts b/packages/modules/event/vitest.config.ts new file mode 100644 index 0000000000..9a6dbacba7 --- /dev/null +++ b/packages/modules/event/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineProject } from 'vitest/config'; + +import { name, version } from './package.json'; + +export default defineProject({ + test: { + include: ['src/__tests__/**/*.test.ts'], + name: `${name}@${version}`, + environment: 'node', + globals: true, + testTimeout: 2000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8652346020..7e11abace6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1849,6 +1849,9 @@ importers: typescript: specifier: ^7.0.2 version: 7.0.2 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/debug@4.1.13)(@types/node@26.1.2)(happy-dom@20.11.1)(jsdom@30.0.1)(lightningcss@1.33.0)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1) packages/modules/feature-flag: dependencies: @@ -7138,9 +7141,23 @@ packages: '@vitest/browser': optional: true + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + '@vitest/expect@4.1.4': resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.1.4': resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==} peerDependencies: @@ -7152,18 +7169,33 @@ packages: vite: optional: true + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + '@vitest/pretty-format@4.1.4': resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + '@vitest/runner@4.1.4': resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==} + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + '@vitest/snapshot@4.1.4': resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==} + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + '@vitest/spy@4.1.4': resolution: {integrity: sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==} + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + '@vitest/utils@4.1.4': resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} @@ -7820,6 +7852,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -7855,6 +7891,10 @@ packages: resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} engines: {pnpm: '>=8'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} @@ -8214,6 +8254,10 @@ packages: babel-plugin-macros: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -8991,6 +9035,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.15.0: resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} hasBin: true @@ -9287,6 +9334,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lowlight@1.20.0: resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} @@ -9858,6 +9908,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -10599,6 +10653,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} @@ -10647,6 +10704,9 @@ packages: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + structured-source@4.0.0: resolution: {integrity: sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==} @@ -10756,6 +10816,9 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.2.4: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} @@ -10764,10 +10827,22 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + tldts-core@7.4.9: resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} @@ -11210,6 +11285,34 @@ packages: yaml: optional: true + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.1.4: resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -15931,6 +16034,14 @@ snapshots: tinyrainbow: 3.1.0 vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + '@vitest/expect@4.1.4': dependencies: '@standard-schema/spec': 1.1.0 @@ -15940,6 +16051,15 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@3.2.7(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@7.3.6(@types/node@26.1.2)(lightningcss@1.33.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.15.0(@types/node@26.1.2)(typescript@7.0.2) + vite: 7.3.6(@types/node@26.1.2)(lightningcss@1.33.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1) + '@vitest/mocker@4.1.4(msw@2.15.0(@types/node@24.12.2)(typescript@7.0.2))(vite@8.2.0(@types/node@24.12.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1))': dependencies: '@vitest/spy': 4.1.4 @@ -15958,15 +16078,31 @@ snapshots: msw: 2.15.0(@types/node@26.1.2)(typescript@7.0.2) vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0) + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.1.4': dependencies: tinyrainbow: 3.1.0 + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + '@vitest/runner@4.1.4': dependencies: '@vitest/utils': 4.1.4 pathe: 2.0.3 + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/snapshot@4.1.4': dependencies: '@vitest/pretty-format': 4.1.4 @@ -15974,8 +16110,18 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + '@vitest/spy@4.1.4': {} + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@vitest/utils@4.1.4': dependencies: '@vitest/pretty-format': 4.1.4 @@ -17038,6 +17184,14 @@ snapshots: ccount@2.0.1: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chai@6.2.2: {} chalk@4.1.2: @@ -17063,6 +17217,8 @@ snapshots: dependencies: '@kurkle/color': 0.3.4 + check-error@2.1.3: {} + cheerio-select@2.1.0: dependencies: boolbase: 1.0.0 @@ -17431,6 +17587,8 @@ snapshots: dedent@1.7.2: {} + deep-eql@5.0.2: {} + deep-extend@0.6.0: optional: true @@ -18244,6 +18402,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@3.15.0: dependencies: argparse: 1.0.10 @@ -18587,6 +18747,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lowlight@1.20.0: dependencies: fault: 1.0.4 @@ -19325,6 +19487,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + pend@1.2.0: {} perfect-debounce@2.1.0: {} @@ -20223,6 +20387,8 @@ snapshots: statuses@2.0.2: {} + std-env@3.10.0: {} + std-env@4.0.0: {} stdin-discarder@0.3.2: {} @@ -20267,6 +20433,10 @@ snapshots: strip-json-comments@2.0.1: optional: true + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + structured-source@4.0.0: dependencies: boundary: 2.0.0 @@ -20377,6 +20547,8 @@ snapshots: tinybench@2.9.0: {} + tinyexec@0.3.2: {} + tinyexec@1.2.4: {} tinyglobby@0.2.17: @@ -20384,8 +20556,14 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + tinyrainbow@3.1.0: {} + tinyspy@4.0.4: {} + tldts-core@7.4.9: {} tldts@7.4.9: @@ -20789,6 +20967,50 @@ snapshots: tsx: 4.23.1 yaml: 2.9.0 + vitest@3.2.7(@types/debug@4.1.13)(@types/node@26.1.2)(happy-dom@20.11.1)(jsdom@30.0.1)(lightningcss@1.33.0)(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(msw@2.15.0(@types/node@26.1.2)(typescript@7.0.2))(vite@7.3.6(@types/node@26.1.2)(lightningcss@1.33.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@26.1.2)(lightningcss@1.33.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1) + vite-node: 3.2.4(@types/node@26.1.2)(lightningcss@1.33.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + '@types/node': 26.1.2 + happy-dom: 20.11.1 + jsdom: 30.0.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@30.0.1)(msw@2.15.0(@types/node@24.12.2)(typescript@7.0.2))(vite@8.2.0(@types/node@24.12.2)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.0)(tsx@4.23.1)): dependencies: '@vitest/expect': 4.1.4 diff --git a/vue-press/src/modules/event/react.md b/vue-press/src/modules/event/react.md deleted file mode 100644 index 1b632a4251..0000000000 --- a/vue-press/src/modules/event/react.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -title: Event Module - React -category: Module -tag: - - react - - event ---- - - - -```ts -import { useEventProvider } from '@equinor/fusion-framework-react-module-event'; - -/* fetch the `ÌEventModuleProvider` from the closes module provder */ -const eventProvider = useEventProvider(); -``` - -## EventProvider - -if needed, the resolving of which `ÌEventModuleProvider` the `useEventProvider` will provide can be altered by using -the `EventProvider` component - -**example app:** -```tsx -import { EventProvider, EventConsumer } = from '@equinor/fusion-framework-react-module-event'; -import { useFramework } = from '@equinor/fusion-framework-react-app/framework'; -const Content = () => { - const framework = useFramework().modules.event; - return ( - - - - - ); -}; -``` -```tsx -import { useEventHandler } = from '@equinor/fusion-framework-react-module-event'; - -const eventHandler = (event: FrameworkEventMap['some_event']) => { - console.log(event.detail); -}; - -const EventLogger = () => useEventHandler('some_event', eventHandler); -``` - -```tsx -import { EventConsumer } = from '@equinor/fusion-framework-react-module-event'; - -const InlineEventConsumer = () => ( - - { - (provider) => provider.dispatch( - 'some_event'), - { detail: { foo: 'bar' } } - } - -) -`````` - -## Hooks - -### useEventProvider - -use `IEventModuleProvider` from current context see [EventProvider](#EventProvider) -```ts -import { useEventProvider } from '@equinor/fusion-framework-react-module-event'; -``` - -### useEventModuleProvider - -use `IEventModuleProvider` from closes module provider - -```ts -import { useEventModuleProvider } from '@equinor/fusion-framework-react-module-event'; -``` - - -### useEventHandler -```ts -import { useEventHandler } from '@equinor/fusion-framework-react-module-event'; - -useEventHandler( - 'onContextChange', - /** note that callback must be memorized */ - useCallback((e) => { - console.log(e.detail); - }, [deps]); -); -``` - -### useEventStream - -```ts -import { useEventStream, EventStream } from '@equinor/fusion-framework-react-module-event'; - -/* simple usage */ -const { value: someEvent } = useObservableState(useEventStream('some_event')); - -/* observe stream of events */ -const someEvent$ = useEventStream( - 'some_event', - /* note that callback must be memorized */ - useCallback( - /* note react.useCallback cannot resolve source input */ - (event$: EventStream<'some_event'>) => event$.pipe( - /* only some events */ - filter(e => e.detail.foo === dep.foo), - /* mutate data */ - map(e => e.detail) - ), - [dep] - ) -); -/* use state of stream */ -const { value: foo } = useObservableState(someEvent$); - -``` From c011377e792e87ea5769b9ed5d6c6762f5b0dae3 Mon Sep 17 00:00:00 2001 From: Odin Thomas Rochmann Date: Sat, 8 Aug 2026 19:23:49 +0200 Subject: [PATCH 2/4] docs(vue-press): align event module docs with docs/ folder, move React page Trim vue-press/src/modules/event/README.md to an @include of the package README, add a docs/ mirror (configuration, observable-patterns, lifecycle, testing) that @includes the package's own docs, matching the http/module pattern. Move the event React bindings page from event/react.md to react/event/README.md, alongside react/router/, as an @include of @equinor/fusion-framework-react-module-event's README. Update the sidebar to match. --- .changeset/docs_event-module-docs.md | 11 ++ vue-press/src/.vuepress/sidebar.ts | 20 ++- vue-press/src/modules/event/README.md | 156 +----------------- .../src/modules/event/docs/configuration.md | 11 ++ vue-press/src/modules/event/docs/lifecycle.md | 11 ++ .../modules/event/docs/observable-patterns.md | 11 ++ vue-press/src/modules/event/docs/testing.md | 11 ++ vue-press/src/modules/react/event/README.md | 11 ++ 8 files changed, 85 insertions(+), 157 deletions(-) create mode 100644 .changeset/docs_event-module-docs.md create mode 100644 vue-press/src/modules/event/docs/configuration.md create mode 100644 vue-press/src/modules/event/docs/lifecycle.md create mode 100644 vue-press/src/modules/event/docs/observable-patterns.md create mode 100644 vue-press/src/modules/event/docs/testing.md create mode 100644 vue-press/src/modules/react/event/README.md diff --git a/.changeset/docs_event-module-docs.md b/.changeset/docs_event-module-docs.md new file mode 100644 index 0000000000..d2f0b999a7 --- /dev/null +++ b/.changeset/docs_event-module-docs.md @@ -0,0 +1,11 @@ +--- +"@equinor/fusion-framework-docs": patch +--- + +Align the event module's vue-press documentation with its `docs/` folder: `README.md` and the +new `docs/{configuration,observable-patterns,lifecycle,testing}.md` pages now `@include` the +package's own docs instead of duplicating content, matching the `http`/`module` pattern. + +The event module's React bindings page moves from `event/react.md` to `react/event/README.md`, +alongside `react/router/`, and is now an `@include` of `@equinor/fusion-framework-react-module-event`'s +README. The sidebar is updated to match. diff --git a/vue-press/src/.vuepress/sidebar.ts b/vue-press/src/.vuepress/sidebar.ts index 018fda6c16..d4e05f4c4f 100644 --- a/vue-press/src/.vuepress/sidebar.ts +++ b/vue-press/src/.vuepress/sidebar.ts @@ -367,8 +367,20 @@ export default sidebar({ link: 'README.md', }, { - text: 'React', - link: 'react.md', + text: 'Configuration', + link: 'docs/configuration.md', + }, + { + text: 'Observable Patterns', + link: 'docs/observable-patterns.md', + }, + { + text: 'Lifecycle', + link: 'docs/lifecycle.md', + }, + { + text: 'Testing', + link: 'docs/testing.md', }, ], }, @@ -414,6 +426,10 @@ export default sidebar({ }, ], }, + { + text: 'Event', + link: 'event/', + }, ], }, { diff --git a/vue-press/src/modules/event/README.md b/vue-press/src/modules/event/README.md index 621d621936..556c625819 100644 --- a/vue-press/src/modules/event/README.md +++ b/vue-press/src/modules/event/README.md @@ -8,159 +8,5 @@ tag: -## Concept + -Since module instances are loosely coupled, the message module allows modules to communicate together internally. - -::: tip Event Bubbling -when initializing modules with reference to instance of modules, the event module will by default subscribe to reference event provider. - -For example when the Provider (Portal) initializes a consumer (App), the app will dispatch events that `canBubble` to its parent event module instance. -::: - -::: warning Async listeners -event handlers are allowed to execute async, so when `event.cancelable = false` the dispatcher will not await resolution, which means the cancellation might not happen in the order which handlers where added -::: - -### Dispatch event -```mermaid -sequenceDiagram - autonumber - Event->>+Provider: dispatch event - Provider->>+Listeners: notify listeners - opt - Listeners->>Event: prevent default - end - opt - Listeners->>Event: stop propagation - end - opt event.canBubble - Provider->>Parent Provider: dispatch event - end -``` - -```ts -modules.event.dispatchEvent( - 'myEvent', - { detail: myObj, source: mySource } -); -// alternative -modules.event.dispatchEvent( - new FrameworkEvent( - 'myEvent', - { detail: myObj, source: mySource } - ) -); - -``` - -### Cancelable events - - - -``` mermaid -sequenceDiagram - autonumber - Event->>+Provider: dispatch event - Provider-->>Listeners: await listeners - opt - Listeners->>Event: prevent default - end - opt - Listeners->>Event: stop propagation - end - opt event can bubble - note over Provider,Parent Provider:when preventDefault is called, canBubble will return false - Provider->>Parent Provider: dispatch event - end - Provider-->>-Event: resolve dispatch -``` - -```ts -const event = await module.event.dispatchEvent( - 'myEvent', - { detail: myObj, source: mySource, cancelable: true } -); - -if(event.defaultPrevented){ - console.log('event was canceled'); -} else { - console.log('event was dispatched successfully'); -} -``` - -## Customize - -### Declare event type - -declaring a module will not add any functionality, __but__ provide type hinting/completion - -```ts -declare module '@equinor/fusion-framework-module-event' { - interface FrameworkEventMap { - myOwnType: FrameworkEvent< - FrameworkEventInit< - MyDetailType, - MySourceType - > - >; - } -} -``` - -### Custom event -```ts -// define a custom event class -class MyEvent extends FrameworkEvent{ - constructor( - readonly obj: MyObj, - init: FrameworkEventInit - ) { - super('onMyEvent', init); - } -} - -// add type hinting -declare module '@equinor/fusion-framework-module-event' { - interface FrameworkEventMap { - onMyEvent: MyEvent - } -} - -module.event.dispatch(new MyEvent(someObj, {detail, source})); - -module.event.addEventListener('onMyEvent', (e) => { - console.log('is my custom object:', e instanceof MyEvent); - console.log('my custom obj', e.obj); -}); -``` - - -## Config - -### Handling events before dispatch -```ts -const doNotHandleEvents = ['onMyEvent']; -const doNotPropagateEvents = ['myOtherEvent']; - -config.event.onDispatch = (e) => { - if(doNotHandleEvents.includes(e.type)){ - e.preventDefault(); - } - if(doNotPropagateEvents.includes(e.type)){ - e.stopPropagation(); - } -} -``` - -### Handling bubbling -```ts -// prevent default bubbling to ref modules -config.event.onBubble = undefined; - -// custom handling -config.event.onBubble = (e) => { - console.log(`event [${e.type}] is bubbling`); - ref.event.dispatch(e); -} -``` diff --git a/vue-press/src/modules/event/docs/configuration.md b/vue-press/src/modules/event/docs/configuration.md new file mode 100644 index 0000000000..498fc02816 --- /dev/null +++ b/vue-press/src/modules/event/docs/configuration.md @@ -0,0 +1,11 @@ +--- +title: Event Configuration +description: EventModuleConfigurator setOnDispatch/setOnBubble hooks, registering custom event types, and custom event classes. +category: Module +tag: + - event + - core + - configuration +--- + + diff --git a/vue-press/src/modules/event/docs/lifecycle.md b/vue-press/src/modules/event/docs/lifecycle.md new file mode 100644 index 0000000000..33661958a5 --- /dev/null +++ b/vue-press/src/modules/event/docs/lifecycle.md @@ -0,0 +1,11 @@ +--- +title: Event Lifecycle +description: Dispatch sequence, cancelable events, and bubbling. +category: Module +tag: + - event + - core + - lifecycle +--- + + diff --git a/vue-press/src/modules/event/docs/observable-patterns.md b/vue-press/src/modules/event/docs/observable-patterns.md new file mode 100644 index 0000000000..279b6f201a --- /dev/null +++ b/vue-press/src/modules/event/docs/observable-patterns.md @@ -0,0 +1,11 @@ +--- +title: Event Observable Patterns +description: Subscribing to event$, filterEvent, and the ./operators subpath. +category: Module +tag: + - event + - core + - rxjs +--- + + diff --git a/vue-press/src/modules/event/docs/testing.md b/vue-press/src/modules/event/docs/testing.md new file mode 100644 index 0000000000..5842368a7d --- /dev/null +++ b/vue-press/src/modules/event/docs/testing.md @@ -0,0 +1,11 @@ +--- +title: Event Testing +description: waitForEvent, watchEvents, and using a bespoke ModulesConfigurator in tests. +category: Module +tag: + - event + - core + - testing +--- + + diff --git a/vue-press/src/modules/react/event/README.md b/vue-press/src/modules/react/event/README.md new file mode 100644 index 0000000000..90ee78a159 --- /dev/null +++ b/vue-press/src/modules/react/event/README.md @@ -0,0 +1,11 @@ +--- +title: Event +category: Module +tag: + - event + - react +--- + + + + From 09955d3826fed51ebb1c781a9bb6308f32b66c42 Mon Sep 17 00:00:00 2001 From: Odin Thomas Rochmann Date: Sat, 8 Aug 2026 19:32:00 +0200 Subject: [PATCH 3/4] style(module-event): apply Biome formatting fixes flagged by reviewdog --- packages/modules/event/src/EventModuleProvider.ts | 4 +--- packages/modules/event/src/__tests__/waitForEvent.test.ts | 7 +++++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/modules/event/src/EventModuleProvider.ts b/packages/modules/event/src/EventModuleProvider.ts index e28d432a54..abd7781816 100644 --- a/packages/modules/event/src/EventModuleProvider.ts +++ b/packages/modules/event/src/EventModuleProvider.ts @@ -88,9 +88,7 @@ export interface IEventModuleProvider { * @param event - The event instance to dispatch. * @returns The same event after all listeners have run. */ - dispatchEvent( - event: TType, - ): Promise; + dispatchEvent(event: TType): Promise; /** Disposes the provider, completing `event$` and removing all listeners. */ dispose: VoidFunction; diff --git a/packages/modules/event/src/__tests__/waitForEvent.test.ts b/packages/modules/event/src/__tests__/waitForEvent.test.ts index 178c89751f..4771da37a8 100644 --- a/packages/modules/event/src/__tests__/waitForEvent.test.ts +++ b/packages/modules/event/src/__tests__/waitForEvent.test.ts @@ -38,7 +38,8 @@ describe('waitForEvent', () => { const promise = waitForEvent( provider, - (e: IFrameworkEvent) => e.type === 'myFeature.saved' && (e.detail as { id: number }).id === 42, + (e: IFrameworkEvent) => + e.type === 'myFeature.saved' && (e.detail as { id: number }).id === 42, ); // Non-matching dispatch — should not resolve yet. await provider.dispatchEvent('myFeature.saved', { detail: { id: 1 } }); @@ -77,7 +78,9 @@ describe('waitForEvent', () => { const controller = new AbortController(); controller.abort(); - await expect(waitForEvent(provider, 'myFeature.saved', { signal: controller.signal })).rejects.toThrow(); + await expect( + waitForEvent(provider, 'myFeature.saved', { signal: controller.signal }), + ).rejects.toThrow(); provider.dispose(); }); From e584b842d87a3b4c06f3cfe30e5984f16e6cb8ae Mon Sep 17 00:00:00 2001 From: Odin Thomas Rochmann Date: Sun, 9 Aug 2026 12:39:00 +0200 Subject: [PATCH 4/4] fix(module-event): fix waitForEvent TDZ on sync completion, correct doc inaccuracies --- packages/modules/event/docs/configuration.md | 6 ++++-- packages/modules/event/docs/lifecycle.md | 8 ++++---- packages/modules/event/src/utils/wait-for-event.ts | 11 +++++++---- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/modules/event/docs/configuration.md b/packages/modules/event/docs/configuration.md index 7ee56d51d3..3741df1ab6 100644 --- a/packages/modules/event/docs/configuration.md +++ b/packages/modules/event/docs/configuration.md @@ -71,10 +71,12 @@ modules.event.addEventListener('myFeature', (event) => { ## Custom event classes -For behavior beyond a typed `detail`, subclass `FrameworkEvent` directly: +For behavior beyond a typed `detail`, subclass `FrameworkEvent` directly. Its first generic is a +{@link FrameworkEventInit}, not the payload type itself — wrap `detail`/`source` in +`FrameworkEventInit`, and pass the event name as the second generic: ```ts -class MyEvent extends FrameworkEvent { +class MyEvent extends FrameworkEvent, 'onMyEvent'> { constructor(readonly obj: MyObj, init: FrameworkEventInit) { super('onMyEvent', init); } diff --git a/packages/modules/event/docs/lifecycle.md b/packages/modules/event/docs/lifecycle.md index d7d052b32a..34ca20c50e 100644 --- a/packages/modules/event/docs/lifecycle.md +++ b/packages/modules/event/docs/lifecycle.md @@ -72,7 +72,10 @@ modules.event.addEventListener('myEvent', (event) => { }); ``` -> **Important:** When dispatching a `cancelable` event you **must** `await` the `dispatchEvent` call. Firing without `await` means `preventDefault()` calls from listeners will not be respected. +> **Note:** The dispatcher `await`s each cancelable listener internally, so `preventDefault()` +> calls are always respected in listener order — even if the caller doesn't `await` the +> `dispatchEvent` call. `await` is only needed when the caller must inspect the resolved +> event (e.g. `event.canceled`) or wait for dispatch to fully complete. ## Bubbling @@ -97,6 +100,3 @@ await modules.event.dispatchEvent('myEvent', { canBubble: false, }); ``` - - -``` diff --git a/packages/modules/event/src/utils/wait-for-event.ts b/packages/modules/event/src/utils/wait-for-event.ts index a1cb796bd1..da9027a095 100644 --- a/packages/modules/event/src/utils/wait-for-event.ts +++ b/packages/modules/event/src/utils/wait-for-event.ts @@ -88,15 +88,18 @@ export function waitForEvent( const source$ = applyEventMatcher(provider, matcher); let timer: ReturnType | undefined; + // Declared before subscribing so `complete`/`error` can reach it even when + // the source is already closed and fires synchronously during `subscribe`. + let sub: ReturnType | undefined; const cleanup = (err?: unknown) => { clearTimeout(timer); - sub.unsubscribe(); + sub?.unsubscribe(); // Only reject when cleanup was triggered by an error, not a resolved match. if (err !== undefined) reject(err); }; - const sub = source$.subscribe({ + sub = source$.subscribe({ next: (event) => { cleanup(); resolve(event); @@ -109,7 +112,7 @@ export function waitForEvent( // Only arm a timeout when the caller opted in. if (ms !== undefined) { timer = setTimeout(() => { - sub.unsubscribe(); + sub?.unsubscribe(); reject(new Error(`waitForEvent timed out after ${ms}ms`)); }, ms); } @@ -119,7 +122,7 @@ export function waitForEvent( signal.addEventListener( 'abort', () => { - sub.unsubscribe(); + sub?.unsubscribe(); clearTimeout(timer); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); },