xstate

How to send event from Promise Actor to parent machine


I'm working on a state machine to handle uploading a file. During uploading, I'd like to show the user the progress. Here's what I have so far:

// machine.tsx

import upload from './actors/upload'

const machine = createMachine({
  id: 'Document Upload Machine',
  context: ({ input: { file } }) => ({
    created: Date.now(),
    file,
    name: getDocumentTitle(file.name)
  }),
  initial: 'init',
  states: {
    init: {},
    uploading: {
      entry: 'resetProgress',
      invoke: {
        src: 'upload',
        input: ({ context: { file, upload } }) => ({ file, urls: upload.urls }),
        onDone: {
          target: 'startProcessing',
          actions: assign({ upload: ({ context, event }) => ({ ...context.upload, parts: event.output }) }),
        },
        onError: {
          target: 'failed',
          actions: assign({ error: 'upload' }),
        },
      },
      on: {
        PROGRESS: {
          actions: {
            type: 'setProgress',
            params: ({ event }) => ({ value: event.value }) 
          }
        }
      }
    },
    ...
  }
}, {
  actions: {
    resetProgress: assign({ progress: 0 }),
    setProgress: assign({ progress: (_, { value }) => Math.round(value) }),
  },
  actors: {
    upload
  }
})
// actors/upload.tsx

interface Input {
  file: File
  urls: string[]
}

const upload = fromPromise<IMultipartPart[], Input>(async ({ input: { file, urls }, self }) => {
  const partCount = urls.length
  const total = file.size
  const overallProgress = Array.from({ length: partCount }).map(() => 0)

  const _onUploadProgress = (part: number) => (event: ProgressEvent) => {
    overallProgress[part - 1] = event.loaded
    const loaded = overallProgress.reduce((loaded, part) => loaded + part, 0)

    console.log('Sending progress to parent', (loaded * 100) / total)

    sendParent({ type: 'PROGRESS', value: (loaded * 100) / total })
  }

  const parts = await Promise.all(
    urls.map(async (url, index) => {
      const partNumber = index + 1

      return uploadPart(
        url,
        { file, partCount, partNumber },
        { onUploadProgress: _onUploadProgress(partNumber) }
      )
    })
  )

  return parts
})

While I'm seeing Sending progress to parent in the console, a breakpoint in the PROGRESS event for the uploading state is not hit, showing that the event is not being picked up by the parent machine, so the progress never gets updated. I've scoured the v5 documentation, the jsdocs site for the API, the xstate GitHub issues and discussions and pretty much exhausted Google searching for an answer on how to properly send events from an Actor to Parent.

To my understanding, in xstate v5, a Promise Actor can send events to its parent via sendParent. What am I doing wrong or misunderstanding?


Solution

  • I posted this question in the GitHub Discussions for xstate. They responded with two approaches... the first matching what @NRielingh is saying. The second, and this is their suggested approach, is to use the new Actor System to send messages between Actors.

    For my use case, here's how that looks...

    Background

    There's a parent machine responsible for spawning Document Upload Machines for each file being uploaded.

    1. Spawn the Document Upload Machine

    // Parent machine
    const parent = createMachine(
      {
        ...
      },
      {
        actions: {
          addMachine: assign(({ context, spawn }, { file }) => {
            // This is used as both the `id` and the `systemId`
            const id = createId()
            return {
              machines: context.machines.concat(
                spawn(documentUploadMachine, { id, input: { file }, systemId: id })
              )
            }
          })
        }
      }
    )
    

    2. Pass Document Upload Machine systemId to actor

    // Document Upload Machine
    const documentUploadMachine = createMachine({
      ...
      states: {
        ...
        uploading: {
          invoke: {
            src: 'upload',
            input: ({ context, self }) => ({ 
              file: context.file, 
              // self.id is the same as the systemId assigned during spawn
              parentId: self.id, 
              urls: context.uploads.urls 
            })
            ...
          }
        }
        ...
      }
    })
    

    3. Use parent.send to send to parent actor

    // ./actors/upload.ts
    interface Input {
      file: File
      parentId: string
      urls: string[]
    }
    
    const upload = fromPromise<IMultipartPart[], Input>(
      async ({ input: { file, parentId, urls }, self, system }) => {
        const parent = system.get(parentId)
    
        ...
    
        const _onUploadProgress = (part: number) => (event: ProgressEvent) => {
          overallProgress[part - 1] = event.loaded
          const loaded = overallProgress.reduce((loaded, part) => loaded + part, 0)
    
          parent.send({ type: 'PROGRESS', value: (loaded * 100) / total })
        }
    
        ...
      }
    )