statelyai / xstate

Actor-based state management & orchestration for complex app logic.
https://stately.ai/docs
MIT License
26.51k stars 1.22k forks source link

Bug: `undefined` guard always resolves #4940

Closed unsay closed 2 weeks ago

unsay commented 3 weeks ago

XState version

XState version 5

Description

Given a an undefined function, it seems like the more correct behavior would for the final state to be falsy.

Expected result

Should there be safeguard check? Or is this by design? It does seem as if the guard property is set, it should throw an error as an incomplete guard.

Actual result

The guard resolves to undefined and is never evaluated, so the first always transition occurs.

next: idle
next: truthy
complete: truthy

Reproduction

https://stately.ai/registry/editor/170740c1-7b72-4a23-8066-05571dc51456?machineId=63f824ae-4218-440a-a251-fe25875622e9&mode=design

Additional context

No response

unsay commented 3 weeks ago

Apologies, the Stately link does some sugar and cleans up the machine. To reproduce, see below.

Interestingly, guard: 'functionDoesNotExist' does what I expect:

Error: Guard 'functionDoesNotExist' is not implemented.'.

While below as a prop on the guards object resolves truthy.

import { createMachine, createActor } from 'xstate'

const guards = {}

const machine = createMachine({
  id: 'testMachine',
  initial: 'idle',
  states: {
    idle: {
      on: {
        TEST: { target: 'testing' }
      }
    },
    testing: {
      always: [
        {
          target: 'truthy',
          guard: guards.functionDoesNotExist
        },
        { target: 'falsy' }
      ]
    },
    truthy: {
      type: 'final'
    },
    falsy: {
      type: 'final'
    }
  }
})

const actor = createActor(machine)
actor.subscribe({
  next: (snapshot) => {
    console.log('next:', snapshot.value)
  },
  complete: () => {
    const snapshot = actor.getSnapshot()
    console.log('complete:', snapshot.value)
  }
})

actor.start()
actor.send({ type: 'TEST' })
davidkpiano commented 2 weeks ago

The revised code example is working as expected. If a guard is undefined, then the transition will just be taken. So this:

{
  target: 'truthy',
  guard: guards.functionDoesNotExist
}

Is essentially the same as this:

{
  target: 'truthy'
}