Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Always avoid source reentrancy with reenter: false (the default) #4234

Merged
merged 8 commits into from
Oct 23, 2023
5 changes: 5 additions & 0 deletions .changeset/hip-bobcats-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'xstate': major
---

Atomic and parallel states should no longer be reentered when the transition target doesn't escape them. You can get the reentering behavior by configuring `reenter: true` for the transition.
100 changes: 69 additions & 31 deletions packages/core/src/stateUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,14 @@ function getChildren<TContext extends MachineContext, TE extends EventObject>(

function getProperAncestors(
stateNode: AnyStateNode,
toStateNode: AnyStateNode | null
toStateNode: AnyStateNode | undefined
): Array<typeof stateNode> {
const ancestors: Array<typeof stateNode> = [];

if (toStateNode === stateNode) {
return ancestors;
}

// add all ancestors
let m = stateNode.parent;
while (m && m !== toStateNode) {
Expand Down Expand Up @@ -519,19 +523,21 @@ export function resolveTarget(
});
}

function resolveHistoryTarget<
function resolveHistoryDefaultTransition<
TContext extends MachineContext,
TEvent extends EventObject
>(stateNode: AnyStateNode & { type: 'history' }): ReadonlyArray<AnyStateNode> {
>(stateNode: AnyStateNode & { type: 'history' }) {
const normalizedTarget = normalizeTarget<TContext, TEvent>(
stateNode.config.target
);
if (!normalizedTarget) {
return stateNode.parent!.initial.target;
return stateNode.parent!.initial;
}
return normalizedTarget.map((t) =>
typeof t === 'string' ? getStateNodeByPath(stateNode.parent!, t) : t
);
return {
target: normalizedTarget.map((t) =>
typeof t === 'string' ? getStateNodeByPath(stateNode.parent!, t) : t
)
};
}

function isHistoryNode(
Expand Down Expand Up @@ -877,7 +883,7 @@ function findLCCA(stateNodes: Array<AnyStateNode>): AnyStateNode {
}

function getEffectiveTargetStates(
transition: AnyTransitionDefinition,
transition: Pick<AnyTransitionDefinition, 'target'>,
historyValue: AnyHistoryValue
): Array<AnyStateNode> {
if (!transition.target) {
Expand All @@ -894,9 +900,7 @@ function getEffectiveTargetStates(
}
} else {
for (const node of getEffectiveTargetStates(
{
target: resolveHistoryTarget(targetNode)
} as AnyTransitionDefinition,
resolveHistoryDefaultTransition(targetNode),
historyValue
)) {
targets.add(node);
Expand All @@ -913,18 +917,18 @@ function getEffectiveTargetStates(
function getTransitionDomain(
transition: AnyTransitionDefinition,
historyValue: AnyHistoryValue
): AnyStateNode | null {
): AnyStateNode | undefined {
const targetStates = getEffectiveTargetStates(transition, historyValue);

if (!targetStates) {
return null;
return;
}

if (
!transition.reenter &&
transition.source.type !== 'parallel' &&
targetStates.every((targetStateNode) =>
isDescendant(targetStateNode, transition.source)
targetStates.every(
(target) =>
target === transition.source || isDescendant(target, transition.source)
)
) {
return transition.source;
Expand All @@ -946,6 +950,10 @@ function computeExitSet(
if (t.target?.length) {
const domain = getTransitionDomain(t, historyValue);

if (t.reenter && t.source === domain) {
statesToExit.add(domain);
}

for (const stateNode of configuration) {
if (isDescendant(stateNode, domain!)) {
statesToExit.add(stateNode);
Expand Down Expand Up @@ -1122,6 +1130,9 @@ function enterStates(
) {
let nextState = currentState;
const statesToEnter = new Set<AnyStateNode>();
// those are states that were directly targeted or indirectly targeted by the explicit target
// in other words, those are states for which initial actions should be executed
// when we target `#deep_child` initial actions of its ancestors shouldn't be executed
const statesForDefaultEntry = new Set<AnyStateNode>();
computeEntrySet(
filteredTransitions,
Expand Down Expand Up @@ -1227,20 +1238,34 @@ function computeEntrySet(
statesToEnter: Set<AnyStateNode>
) {
for (const t of transitions) {
const domain = getTransitionDomain(t, historyValue);

for (const s of t.target || []) {
if (
!isHistoryNode(s) &&
// if the target is different than the source then it will *definitely* be entered
(t.source !== s ||
// we know that the domain can't lie within the source
// if it's different than the source then it's outside of it and it means that the target has to be entered as well
t.source !== domain ||
// reentering transitions always enter the target, even if it's the source itself
t.reenter)
) {
statesToEnter.add(s);
statesForDefaultEntry.add(s);
}
addDescendantStatesToEnter(
s,
historyValue,
statesForDefaultEntry,
statesToEnter
);
}
const ancestor = getTransitionDomain(t, historyValue);
const targetStates = getEffectiveTargetStates(t, historyValue);
for (const s of targetStates) {
addAncestorStatesToEnter(
s,
ancestor,
domain,
statesToEnter,
historyValue,
statesForDefaultEntry
Expand All @@ -1262,6 +1287,8 @@ function addDescendantStatesToEnter<
if (historyValue[stateNode.id]) {
const historyStateNodes = historyValue[stateNode.id];
for (const s of historyStateNodes) {
statesToEnter.add(s);

addDescendantStatesToEnter(
s,
historyValue,
Expand All @@ -1277,39 +1304,45 @@ function addDescendantStatesToEnter<
historyValue,
statesForDefaultEntry
);
for (const stateForDefaultEntry of statesForDefaultEntry) {
statesForDefaultEntry.add(stateForDefaultEntry);
}
}
} else {
const targets = resolveHistoryTarget<TContext, TEvent>(stateNode);
for (const s of targets) {
const historyDefaultTransition = resolveHistoryDefaultTransition<
TContext,
TEvent
>(stateNode);
for (const s of historyDefaultTransition.target) {
statesToEnter.add(s);

if (historyDefaultTransition === stateNode.parent?.initial) {
statesForDefaultEntry.add(stateNode.parent);
}

addDescendantStatesToEnter(
s,
historyValue,
statesForDefaultEntry,
statesToEnter
);
}
for (const s of targets) {

for (const s of historyDefaultTransition.target) {
addAncestorStatesToEnter(
s,
stateNode,
statesToEnter,
historyValue,
statesForDefaultEntry
);
for (const stateForDefaultEntry of statesForDefaultEntry) {
statesForDefaultEntry.add(stateForDefaultEntry);
}
}
}
} else {
statesToEnter.add(stateNode);
if (stateNode.type === 'compound') {
statesForDefaultEntry.add(stateNode);
const [initialState] = stateNode.initial.target;

if (!isHistoryNode(initialState)) {
statesToEnter.add(initialState);
statesForDefaultEntry.add(initialState);
}
addDescendantStatesToEnter(
initialState,
historyValue,
Expand All @@ -1330,6 +1363,10 @@ function addDescendantStatesToEnter<
(sn) => !isHistoryNode(sn)
)) {
if (![...statesToEnter].some((s) => isDescendant(s, child))) {
if (!isHistoryNode(child)) {
statesToEnter.add(child);
statesForDefaultEntry.add(child);
}
addDescendantStatesToEnter(
child,
historyValue,
Expand All @@ -1345,7 +1382,7 @@ function addDescendantStatesToEnter<

function addAncestorStatesToEnter(
stateNode: AnyStateNode,
toStateNode: AnyStateNode | null,
toStateNode: AnyStateNode | undefined,
statesToEnter: Set<AnyStateNode>,
historyValue: HistoryValue<any, any>,
statesForDefaultEntry: Set<AnyStateNode>
Expand All @@ -1356,6 +1393,7 @@ function addAncestorStatesToEnter(
if (anc.type === 'parallel') {
for (const child of getChildren(anc).filter((sn) => !isHistoryNode(sn))) {
if (![...statesToEnter].some((s) => isDescendant(s, child))) {
statesToEnter.add(child);
addDescendantStatesToEnter(
child,
historyValue,
Expand Down Expand Up @@ -1673,7 +1711,7 @@ function selectEventlessTransitions(

for (const stateNode of atomicStates) {
loop: for (const s of [stateNode].concat(
getProperAncestors(stateNode, null)
getProperAncestors(stateNode, undefined)
)) {
if (!s.always) {
continue;
Expand Down
78 changes: 75 additions & 3 deletions packages/core/test/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -837,7 +837,10 @@ describe('entry/exit actions', () => {
states: {
green: {
on: {
RESTART: 'green'
RESTART: {
target: 'green',
reenter: true
}
}
}
}
Expand Down Expand Up @@ -1009,7 +1012,7 @@ describe('entry/exit actions', () => {
]);
});

it('should exit deep descendant during a self-transition', () => {
it('should exit deep descendant during a default self-transition', () => {
const m = createMachine({
initial: 'a',
states: {
Expand Down Expand Up @@ -1037,6 +1040,45 @@ describe('entry/exit actions', () => {
flushTracked();
service.send({ type: 'EV' });

expect(flushTracked()).toEqual([
'exit: a.a1.a11',
'exit: a.a1',
'enter: a.a1',
'enter: a.a1.a11'
]);
});

it('should exit deep descendant during a reentering self-transition', () => {
const m = createMachine({
initial: 'a',
states: {
a: {
on: {
EV: {
target: 'a',
reenter: true
}
},
initial: 'a1',
states: {
a1: {
initial: 'a11',
states: {
a11: {}
}
}
}
}
}
});

const flushTracked = trackEntries(m);

const service = createActor(m).start();

flushTracked();
service.send({ type: 'EV' });

expect(flushTracked()).toEqual([
'exit: a.a1.a11',
'exit: a.a1',
Expand All @@ -1047,7 +1089,7 @@ describe('entry/exit actions', () => {
]);
});

it('should reenter leaf state during its self-transition', () => {
it('should not reenter leaf state during its default self-transition', () => {
const m = createMachine({
initial: 'a',
states: {
Expand All @@ -1071,6 +1113,36 @@ describe('entry/exit actions', () => {
flushTracked();
service.send({ type: 'EV' });

expect(flushTracked()).toEqual([]);
});

it('should reenter leaf state during its reentering self-transition', () => {
const m = createMachine({
initial: 'a',
states: {
a: {
initial: 'a1',
states: {
a1: {
on: {
EV: {
target: 'a1',
reenter: true
}
}
}
}
}
}
});

const flushTracked = trackEntries(m);

const service = createActor(m).start();

flushTracked();
service.send({ type: 'EV' });

expect(flushTracked()).toEqual(['exit: a.a1', 'enter: a.a1']);
});

Expand Down
Loading