Skip to content

Commit

Permalink
fix: compat with XState v5.17.4
Browse files Browse the repository at this point in the history
- fix some patching problems
- refactor patch logic
- add tests for `unpatch()`
- removed default noop-logger; loggers no longer "combine"
- update `README.md` with `unpatch()` docs

# Conflicts:
#	README.md
  • Loading branch information
boneskull committed Aug 20, 2024
1 parent 60fd27c commit 2c603e8
Show file tree
Hide file tree
Showing 13 changed files with 375 additions and 168 deletions.
43 changes: 28 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- [`runUntilEventSent()`](#rununtileventsent)
- [`createActorFromLogic(logic, options)`](#createactorfromlogiclogic-options)
- [`createActorWith(options, logic)`](#createactorwithoptions-logic)
- [`unpatchActor()`](#unpatchactor)
- [Requirements](#requirements)
- [Installation](#installation)
- [API Notes](#api-notes)
Expand All @@ -35,9 +36,9 @@

> Run a State Machine Until It Emits Events
`runUntilEmitted(actor, eventTypes)` / `runUntilEmittedWith(actor, options, eventTypes)` are curried function that will start an actor and run it until emits one or more events of the specified `type`. Once the events have been emitted, the actor will immediately be stopped.
`runUntilEmitted(actorRef, eventTypes)` / `runUntilEmittedWith(actorRef, options, eventTypes)` are curried function that will start an actor and run it until emits one or more events of the specified `type`. Once the events have been emitted, the actor will immediately be stopped.

`waitForEmitted(actor, eventTypes)` / `waitForEmittedWith(actor, options, eventTypes)` are similar, but do not stop the actor.
`waitForEmitted(actorRef, eventTypes)` / `waitForEmittedWith(actorRef, options, eventTypes)` are similar, but do not stop the actor.

> [!NOTE]
>
Expand Down Expand Up @@ -93,9 +94,9 @@ describe('emitterMachine', () => {

> Run a State Machine Until It Transitions from One State to Another
`runUntilTransition(actor, fromStateId, toStateId)` / `runUntilTransitionWith(actor, options, fromStateId, toStateId)` are curried functions that will start an actor and run it until it transitions from state with ID `fromStateId` to state with ID `toStateId`. Once the actor transitions to the specified state, it will immediately be stopped.
`runUntilTransition(actorRef, fromStateId, toStateId)` / `runUntilTransitionWith(actorRef, options, fromStateId, toStateId)` are curried functions that will start an actor and run it until it transitions from state with ID `fromStateId` to state with ID `toStateId`. Once the actor transitions to the specified state, it will immediately be stopped.

`waitForTransition(actor, fromStateId, toStateId)` / `waitForStateWith(actor, options, fromStateId, toStateId)` are similar, but do not stop the actor.
`waitForTransition(actorRef, fromStateId, toStateId)` / `waitForStateWith(actorRef, options, fromStateId, toStateId)` are similar, but do not stop the actor.

```ts
import {strict as assert} from 'node:assert';
Expand Down Expand Up @@ -133,7 +134,7 @@ describe('transitionMachine', () => {
beforeEach(() => {
actor = createActor(transitionMachine);
// curried
runWithFirst = runUntilTransition(actor, 'transitionMachine.first');
runWithFirst = runUntilTransition(actorRef, 'transitionMachine.first');
});

it('should transition from "first" to "second"', async () => {
Expand All @@ -150,7 +151,7 @@ describe('transitionMachine', () => {

> Run a Promise Actor or State Machine to Completion
`runUntilDone(actor)` / `runUntilDoneWith(actor, options)` are curried functions that will start a [Promise Actor][] or [State Machine Actor][] and run it until it reaches a final state. Once the actor reaches a final state, it will immediately be stopped. The `Promise` will be resolved with the output of the actor.
`runUntilDone(actor)` / `runUntilDoneWith(actorRef, options)` are curried functions that will start a [Promise Actor][] or [State Machine Actor][] and run it until it reaches a final state. Once the actor reaches a final state, it will immediately be stopped. The `Promise` will be resolved with the output of the actor.

> [!NOTE]
>
Expand Down Expand Up @@ -205,7 +206,7 @@ describe('logic', () => {

it('should abort when provided a too-short timeout', async () => {
await assert.rejects(
runUntilDoneWith(actor, {timeout: 100}),
runUntilDoneWith(actorRef, {timeout: 100}),
(err: Error) => {
assert.equal(err.message, 'Actor did not complete in 100ms');

Expand All @@ -220,7 +221,7 @@ describe('logic', () => {

> Run a Actor Until It Satisfies a Snapshot Predicate
`runUntilSnapshot(actor, predicate)` / `runUntilSnapshotWith(actor, options, predicate)` are curried functions that will start an actor and run it until the actor's [Snapshot][snapshot] satisfies `predicate` (which is the same type as the `predicate` parameter of [`xstate.waitFor()`][waitFor]). Once the snapshot matches the predicate, the actor will immediately be stopped.
`runUntilSnapshot(actorRef, predicate)` / `runUntilSnapshotWith(actorRef, options, predicate)` are curried functions that will start an actor and run it until the actor's [Snapshot][snapshot] satisfies `predicate` (which is the same type as the `predicate` parameter of [`xstate.waitFor()`][waitFor]). Once the snapshot matches the predicate, the actor will immediately be stopped.

> [!NOTE]
>
Expand Down Expand Up @@ -275,7 +276,7 @@ describe('snapshotLogic', () => {
it('should contain word "bar" in state "second"', async () => {
const actor = createActor(snapshotLogic);

const snapshot = await runUntilSnapshot(actor, (snapshot) =>
const snapshot = await runUntilSnapshot(actorRef, (snapshot) =>
snapshot.matches('second'),
);

Expand All @@ -299,9 +300,9 @@ describe('snapshotLogic', () => {

> Run a State Machine Actor Until Its System Spawns a Child Actor
`runUntilSpawn(actor, childId)` / `runUntilSpawnWith(actor, options, childId)` are curried functions that will start an actor and run it until it spawns a child actor with `id` matching `childId` (which may be a `RegExp`). Once the child actor is spawned, the actor will immediately be stopped. The `Promise` will be resolved with a reference to the spawned actor (an `xstate.ActorRef`).
`runUntilSpawn(actorRef, childId)` / `runUntilSpawnWith(actorRef, options, childId)` are curried functions that will start an actor and run it until it spawns a child actor with `id` matching `childId` (which may be a `RegExp`). Once the child actor is spawned, the actor will immediately be stopped. The `Promise` will be resolved with a reference to the spawned actor (an `xstate.ActorRef`).

`waitForSpawn(actor, childId)` / `waitForSpawnWith(actor, options, childId)` are similar, but do not stop the actor.
`waitForSpawn(actorRef, childId)` / `waitForSpawnWith(actorRef, options, childId)` are similar, but do not stop the actor.

The root State Machine Actor itself needn't spawn the child with the matching `id`, but _any_ actor within the root actor's system may spawn the child. As of this writing, there is no way to specify the _parent_ of the spawned actor.

Expand Down Expand Up @@ -366,23 +367,23 @@ describe('spawnerMachine', () => {

> Run an Actor Until It Receives an Event
`runUntilEventReceived(actor, eventTypes)` / `runUntilEventReceivedWith(actor, options, eventTypes)` are curried functions that will start a [State Machine Actor][], [Callback Actor][], or [Transition Actor][] and run it until it receives event(s) of the specified `type`. Once the event(s) are received, the actor will immediately be stopped. The `Promise` will be resolved with the received event(s).
`runUntilEventReceived(actorRef, eventTypes)` / `runUntilEventReceivedWith(actorRef, options, eventTypes)` are curried functions that will start a [State Machine Actor][], [Callback Actor][], or [Transition Actor][] and run it until it receives event(s) of the specified `type`. Once the event(s) are received, the actor will immediately be stopped. The `Promise` will be resolved with the received event(s).

`runUntilEventReceived()`'s `options` parameter accepts an `otherActorId` (`string` or `RegExp`) property. If set, this will ensure the event was _received from_ the actor with ID matching `otherActorId`.

`withForEventReceived(actor, eventTypes)` / `waitForEventReceivedWith(actor, options, eventTypes)` are similar, but do not stop the actor.
`withForEventReceived(actorRef, eventTypes)` / `waitForEventReceivedWith(actorRef, options, eventTypes)` are similar, but do not stop the actor.

Usage is similar to [`runUntilEmitted()`](#rununtilemitted)—with the exception of the `otherActorId` property as described above.

### `runUntilEventSent()`

> Run an Actor Until It Sends an Event
`runUntilEventSent(actor, eventTypes)` / `runUntilEventSentWith(actor, options, eventTypes)` are curried functions that will start an Actor and run it until it sends event(s) of the specified `type`. Once the event(s) are sent, the actor will immediately be stopped. The `Promise` will be resolved with the sent event(s).
`runUntilEventSent(actorRef, eventTypes)` / `runUntilEventSentWith(actorRef, options, eventTypes)` are curried functions that will start an Actor and run it until it sends event(s) of the specified `type`. Once the event(s) are sent, the actor will immediately be stopped. The `Promise` will be resolved with the sent event(s).

`runUntilEventSentWith()`'s `options` parameter accepts an `otherActorId` (`string` or `RegExp`) property. If set, this will ensure the event was _sent to_ the actor with ID matching `otherActorId`.

`waitForEventSent(actor, eventTypes)` / `waitForEventSentWith(actor, options, eventTypes)` are similar, but do not stop the actor.
`waitForEventSent(actorRef, eventTypes)` / `waitForEventSentWith(actorRef, options, eventTypes)` are similar, but do not stop the actor.

Usage is similar to [`runUntilEmitted()`](#rununtilemitted)—with the exception of the `otherActorId` property as described above.

Expand Down Expand Up @@ -430,6 +431,18 @@ it('should do x2 with BarMachine', () => {

See also [`createActorFromLogic()`](#createactorfromlogiclogic-options).

### `unpatchActor()`

> Revert Modifications Made to an Actor by **xstate-audition**
> [!WARNING]
>
> _This function is experimental and may be removed in a future release._
`unpatchActor(actorRef)` will "undo" what **xstate-inspector** did (e.g., unsubscribe its inspector and reset the logger), you can call this function with the `ActorRef`.

If **xstate-audition** has never touched the `ActorRef`, this function is a no-nop.

## Requirements

- Node.js v20.0.0+ or modern browser
Expand Down
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"tshy",
"uncurried",
"unpatch",
"unpatching",
"xstate"
],
"enabledLanguageIds": [
Expand Down
111 changes: 73 additions & 38 deletions src/actor.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import {type AnyActorRef, type Subscription} from 'xstate';
import {
type AnyActorRef,
type InspectionEvent,
type Subscription,
} from 'xstate';

import {type AuditionOptions, type LoggerFn} from './types.js';
import {noop} from './util.js';
import {isActorRef} from './util.js';

/**
* Data stored for each `ActorRef` that has been patched.
*
* @internal
*/
type PatchData = {inspectorSubscription: Subscription; logger: LoggerFn};
type PatchData = {inspectorSubscription?: Subscription; logger?: LoggerFn};

/**
* A `WeakMap` to store the data for each `ActorRef` that has been patched. Used
Expand Down Expand Up @@ -59,49 +63,54 @@ const patchData = new WeakMap<AnyActorRef, PatchData[]>();
*/
export function patchActor<Actor extends AnyActorRef>(
actor: Actor,
{inspector: inspect = noop, logger = noop}: AuditionOptions = {},
{inspector, logger}: AuditionOptions = {},
): Actor {
const subscription = actor.system.inspect(inspect);
if (!isActorRef(actor)) {
throw new TypeError('patchActor() called with a non-ActorRef', {
cause: actor,
});
}

const data: PatchData[] = patchData.get(actor) ?? [];
let newData: PatchData | undefined;

// if there's a reason to prefer a Proxy here, I don't know what it is.
if (inspector) {
newData = {inspectorSubscription: actor.system.inspect(inspector)};
}

if (actor._parent) {
// @ts-expect-error private
const oldLogger = actor.logger as LoggerFn;
const actorData: PatchData[] = patchData.get(actor) ?? [];

data.push({inspectorSubscription: subscription, logger: oldLogger});
// if there's a reason to prefer a Proxy here, I don't know what it is.

// in this case, ref.logger should be the same as ref._actorScope.logger
// https://github.com/statelyai/xstate/blob/12bde7a3ff47be6bb5a54b03282a77baf76c2bd6/packages/core/src/createActor.ts#L167
if (logger) {
if (actor._parent) {
// @ts-expect-error private
const oldLogger = actor.logger as LoggerFn;

// in this case, ref.logger should be the same as ref._actorScope.logger
// https://github.com/statelyai/xstate/blob/12bde7a3ff47be6bb5a54b03282a77baf76c2bd6/packages/core/src/createActor.ts#L167

// @ts-expect-error private
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
actor.logger = actor._actorScope.logger = (...args: any[]) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
oldLogger(...args);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
logger(...args);
};
} else {
const oldLogger = actor.system._logger;

data.push({inspectorSubscription: subscription, logger: oldLogger});
// @ts-expect-error private
actor.logger =
actor.system._logger =
// @ts-expect-error private
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
actor._actorScope.logger =
(...args: any[]) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
oldLogger(...args);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
logger(...args);
};
actor.logger = actor._actorScope.logger = logger;

newData = {...newData, logger: oldLogger};
} else {
const oldLogger = actor.system._logger;

// @ts-expect-error private
actor.logger =
actor.system._logger =
// @ts-expect-error private
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
actor._actorScope.logger =
logger;
newData = {...newData, logger: oldLogger};
}
}
if (newData) {
actorData.push(newData);
}
patchData.set(actor, data);
patchData.set(actor, actorData);

return actor;
}
Expand Down Expand Up @@ -136,8 +145,12 @@ export function unpatchActor<Actor extends AnyActorRef>(actor: Actor): Actor {
return actor;
}

for (const {inspectorSubscription, logger} of data.reverse()) {
const {inspectorSubscription, logger} = data.pop()!;

if (inspectorSubscription) {
inspectorSubscription.unsubscribe();
}
if (logger) {
// @ts-expect-error private
actor.logger = actor._parent
? // @ts-expect-error private
Expand All @@ -148,7 +161,29 @@ export function unpatchActor<Actor extends AnyActorRef>(actor: Actor): Actor {
(actor._actorScope.logger = actor.system._logger = logger);
}

patchData.delete(actor);
if (!data.length) {
patchData.delete(actor);
}

return actor;
}

/**
* @param param0
* @returns
*/
export function createPatcher(opts: AuditionOptions = {}) {
const knownActorRefs = new WeakSet<AnyActorRef>();

return (evt: AnyActorRef | InspectionEvent) => {
if (isActorRef(evt)) {
if (!knownActorRefs.has(evt)) {
patchActor(evt, opts);
knownActorRefs.add(evt);
}
} else if (isActorRef(evt.actorRef) && !knownActorRefs.has(evt.actorRef)) {
patchActor(evt.actorRef, opts);
knownActorRefs.add(evt.actorRef);
}
};
}
13 changes: 5 additions & 8 deletions src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,20 @@

import type {AuditionOptions} from './types.js';

import {DEFAULT_TIMEOUT, noop} from './util.js';
import {DEFAULT_TIMEOUT} from './util.js';

/**
* Applies defaults to an {@link AuditionOptions} object.
*
* Currently, this only applies a default `timeout` value.
*
* @param options Any options (or not)
* @returns A new `AuditionOptions` object with defaults applied
*/
export function applyDefaults<T extends AuditionOptions>(
options?: T,
): Omit<T, keyof Required<AuditionOptions>> & Required<AuditionOptions> {
const {
inspector = noop,
logger = noop,
timeout = DEFAULT_TIMEOUT,
...rest
} = options ?? {};
): {timeout: number} & AuditionOptions & Omit<T, keyof AuditionOptions> {
const {inspector, logger, timeout = DEFAULT_TIMEOUT, ...rest} = options ?? {};

const extra = rest as Omit<T, keyof AuditionOptions>;

Expand Down
17 changes: 6 additions & 11 deletions src/until-done.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as xs from 'xstate';

import {patchActor} from './actor.js';
import {createPatcher} from './actor.js';
import {applyDefaults} from './defaults.js';
import {createAbortablePromiseKit} from './promise-kit.js';
import {startTimer} from './timer.js';
Expand Down Expand Up @@ -137,7 +137,7 @@ const untilDone = <
): Promise<Output> => {
const opts = applyDefaults(options);

const {inspector, logger, timeout} = opts;
const {inspector, timeout} = opts;

const {abortController, promise, reject, resolve} =
createAbortablePromiseKit<Output>();
Expand All @@ -151,23 +151,18 @@ const untilDone = <

const inspectorObserver = xs.toObserver(inspector);

const seenActors: WeakSet<xs.AnyActorRef> = new WeakSet();

const doneInspector: xs.Observer<xs.InspectionEvent> = {
complete: inspectorObserver.complete,
error: inspectorObserver.error,
next: (evt) => {
inspectorObserver.next?.(evt);

if (!seenActors.has(evt.actorRef)) {
patchActor(evt.actorRef, {logger});
seenActors.add(evt.actorRef);
}
maybePatchActorRef(evt);
},
};

patchActor(actor, {...opts, inspector: doneInspector});
seenActors.add(actor);
const maybePatchActorRef = createPatcher({...opts, inspector: doneInspector});

maybePatchActorRef(actor);

// order is important: create promise, then start.
void xs.toPromise(actor).then(resolve, (err) => {
Expand Down
Loading

0 comments on commit 2c603e8

Please sign in to comment.