How to send event from Promise Actor to parent machine

190 Views Asked by At

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?

2

There are 2 best solutions below

0
bflemi3 On BEST ANSWER

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 })
    }

    ...
  }
)
4
NReilingh On

Was having the same problem today. I'm an xstate noob but I'm pretty sure I understand what's happening here, so this answer is just as much to get my thinking straight as it is to help you out.

First, and your main problem: the sendTo() and sendParent() functions are not imperative side-effects that you can use to communicate directly from a "promise actor" (really an Actor based on PromiseActorLogic) to a parent actor. These functions return a SendToAction type which can be used to configure an action in a state machine configuration. Since your PromiseActorLogic is not a StateMachine, it doesn't apply here.

As I understand it, fromPromise() is intended to be used when you just have a straightforward bit of promise logic and only need to define transitions for when the promise resolves or rejects -- using the onDone and onError events and transitions that you have already found. For sending back arbitrary events, the Callback Actor may be a better choice, since it provides you with a sendBack() function, which does exactly what you want -- sends an event back to the parent actor.

Callback Actors don't mean you are unable to use async/await -- an async function returns a promise, and promises are compatible with callbacks, so you can write your async function and call it without await -- it will return a promise which you can .then() and .catch() as needed.

Despite Promise Actors' intention to only return onDone and onError events, you can get around this and send arbitrary events to the parent by including a reference to the parent in the actor's input.

const machine = createMachine({
  states: {
    uploading: {
      invoke: {
        src: 'upload',
        input: ({ self }) => ({ parent: self }),
...

const upload = fromPromise(async ({ input }) => {
  input.parent.send({ type: 'PROGRESS' });
});

Note that you MUST capture the state machine's self as parent in input, since the self passed to fromPromise() is the child actor (the promise actor's self).