Skip to content

Commit

Permalink
Allow escaped periods in state IDs (#4685)
Browse files Browse the repository at this point in the history
* Support escaped periods in state IDs

* Changeset

* Add comment

* Don't escape dots on actual keys/ids

* add a failing test case

* Remove unnecessary regex from event descriptors

* fixed escaping

* Update packages/core/src/stateUtils.ts

Co-authored-by: Mateusz Burzyński <[email protected]>

* fix thing

* remove outdated comment

* cleanup things

---------

Co-authored-by: Mateusz Burzyński <[email protected]>
  • Loading branch information
davidkpiano and Andarist authored Jan 19, 2024
1 parent 40ec4fa commit e43eab1
Show file tree
Hide file tree
Showing 5 changed files with 145 additions and 12 deletions.
9 changes: 9 additions & 0 deletions .changeset/brave-llamas-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'xstate': patch
---

State IDs that have periods in them are now supported if those periods are escaped.

The motivation is that external tools, such as [Stately Studio](https://stately.ai/studio), may allow users to enter any text into the state ID field. This change allows those tools to escape periods in state IDs, so that they don't conflict with the internal path-based state IDs.

E.g. if a state ID of `"Loading..."` is entered into the state ID field, instead of crashing either the external tool and/or the XState state machine, it should be converted by the tool to `"Loading\\.\\.\\."`, and those periods will be ignored by XState.
5 changes: 2 additions & 3 deletions packages/core/src/StateMachine.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import isDevelopment from '#is-development';
import { assign } from './actions.ts';
import { STATE_DELIMITER } from './constants.ts';
import { $$ACTOR_TYPE, createActor } from './createActor.ts';
import { createInitEvent } from './eventUtils.ts';
import {
Expand Down Expand Up @@ -49,7 +48,7 @@ import type {
TODO,
TransitionDefinition
} from './types.ts';
import { resolveReferencedActor } from './utils.ts';
import { resolveReferencedActor, toStatePath } from './utils.ts';

export const STATE_IDENTIFIER = '#';
export const WILDCARD = '*';
Expand Down Expand Up @@ -401,7 +400,7 @@ export class StateMachine<
}

public getStateNodeById(stateId: string): StateNode<TContext, TEvent> {
const fullPath = stateId.split(STATE_DELIMITER);
const fullPath = toStatePath(stateId);
const relativePath = fullPath.slice(1);
const resolvedStateId = isStateId(fullPath[0])
? fullPath[0].slice(STATE_IDENTIFIER.length)
Expand Down
14 changes: 7 additions & 7 deletions packages/core/src/stateUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,24 +216,24 @@ export function getCandidates<TEvent extends EventObject>(
const candidates =
stateNode.transitions.get(receivedEventType) ||
[...stateNode.transitions.keys()]
.filter((descriptor) => {
.filter((eventDescriptor) => {
// check if transition is a wildcard transition,
// which matches any non-transient events
if (descriptor === WILDCARD) {
if (eventDescriptor === WILDCARD) {
return true;
}

if (!descriptor.endsWith('.*')) {
if (!eventDescriptor.endsWith('.*')) {
return false;
}

if (isDevelopment && /.*\*.+/.test(descriptor)) {
if (isDevelopment && /.*\*.+/.test(eventDescriptor)) {
console.warn(
`Wildcards can only be the last token of an event descriptor (e.g., "event.*") or the entire event descriptor ("*"). Check the "${descriptor}" event.`
`Wildcards can only be the last token of an event descriptor (e.g., "event.*") or the entire event descriptor ("*"). Check the "${eventDescriptor}" event.`
);
}

const partialEventTokens = descriptor.split('.');
const partialEventTokens = eventDescriptor.split('.');
const eventTokens = receivedEventType.split('.');

for (
Expand All @@ -249,7 +249,7 @@ export function getCandidates<TEvent extends EventObject>(

if (isDevelopment && !isLastToken) {
console.warn(
`Infix wildcards in transition events are not allowed. Check the "${descriptor}" transition.`
`Infix wildcards in transition events are not allowed. Check the "${eventDescriptor}" transition.`
);
}

Expand Down
28 changes: 26 additions & 2 deletions packages/core/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import isDevelopment from '#is-development';
import { isMachineSnapshot } from './State.ts';
import type { StateNode } from './StateNode.ts';
import { STATE_DELIMITER, TARGETLESS_KEY } from './constants.ts';
import { TARGETLESS_KEY } from './constants.ts';
import type {
ActorLogic,
AnyActorRef,
Expand Down Expand Up @@ -56,7 +56,31 @@ export function toStatePath(stateId: string | string[]): string[] {
return stateId;
}

return stateId.split(STATE_DELIMITER);
let result: string[] = [];
let segment = '';

for (let i = 0; i < stateId.length; i++) {
const char = stateId.charCodeAt(i);
switch (char) {
// \
case 92:
// consume the next character
segment += stateId[i + 1];
// and skip over it
i++;
continue;
// .
case 46:
result.push(segment);
segment = '';
continue;
}
segment += stateId[i];
}

result.push(segment);

return result;
}

export function toStateValue(
Expand Down
101 changes: 101 additions & 0 deletions packages/core/test/id.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { testAll } from './utils';
import { createMachine, createActor } from '../src/index.ts';
import { getInitialSnapshot, getNextSnapshot } from '../src/getNextSnapshot.ts';

const idMachine = createMachine({
initial: 'A',
Expand Down Expand Up @@ -108,4 +109,104 @@ describe('State node IDs', () => {
}
});
});

it('should work with keys that have escaped periods', () => {
const machine = createMachine({
initial: 'start',
states: {
start: {
on: {
escaped: 'foo\\.bar',
unescaped: 'foo.bar'
}
},
'foo.bar': {},
foo: {
initial: 'bar',
states: {
bar: {}
}
}
}
});

const initialState = getInitialSnapshot(machine);
const escapedState = getNextSnapshot(machine, initialState, {
type: 'escaped'
});

expect(escapedState.value).toEqual('foo.bar');

const unescapedState = getNextSnapshot(machine, initialState, {
type: 'unescaped'
});
expect(unescapedState.value).toEqual({ foo: 'bar' });
});

it('should work with IDs that have escaped periods', () => {
const machine = createMachine({
initial: 'start',
states: {
start: {
on: {
escaped: '#foo\\.bar',
unescaped: '#foo.bar'
}
},
stateWithDot: {
id: 'foo.bar'
},
foo: {
id: 'foo',
initial: 'bar',
states: {
bar: {}
}
}
}
});

const initialState = getInitialSnapshot(machine);
const escapedState = getNextSnapshot(machine, initialState, {
type: 'escaped'
});

expect(escapedState.value).toEqual('stateWithDot');

const unescapedState = getNextSnapshot(machine, initialState, {
type: 'unescaped'
});
expect(unescapedState.value).toEqual({ foo: 'bar' });
});

it("should not treat escaped backslash as period's escape", () => {
const machine = createMachine({
initial: 'start',
states: {
start: {
on: {
EV: '#some\\\\.thing'
}
},
foo: {
id: 'some\\.thing'
},
bar: {
id: 'some\\',
initial: 'baz',
states: {
baz: {},
thing: {}
}
}
}
});

const initialState = getInitialSnapshot(machine);
const escapedState = getNextSnapshot(machine, initialState, {
type: 'EV'
});

expect(escapedState.value).toEqual({ bar: 'thing' });
});
});

0 comments on commit e43eab1

Please sign in to comment.