xstate icon indicating copy to clipboard operation
xstate copied to clipboard

XState Inspector can be slow

Open VanTanev opened this issue 4 years ago • 12 comments

Description XState inspector is slow with large context objects A trace is attached.

The active machines on the page have contexts that JSON.stringify() to the following lengths:

{
    "main": 12482,
    "actors": {
        "0": 19786,
        "1": 136995,
        "2": 6564
    }
}

Actors and the main machine send about 10 events in quick succession, and that makes the inspector lag.

Additional context Attached a trace: Profile-20210329T172918.zip

VanTanev avatar Mar 30 '21 12:03 VanTanev

The workaround in https://github.com/davidkpiano/xstate/issues/2127 can be used to modify large context objects, and truncate the data that is not relevant to the inspector.

VanTanev avatar Apr 30 '21 08:04 VanTanev

That workaround may work for Map and Set but other objects, when even moderately complex, cause the stringify to be very slow and cause the inspector to crash.

Here is an example:

image

That shows over 800ms to call stringify for the inspector which caused the UI to block on send(event, ...)

engineersamuel avatar May 10 '21 12:05 engineersamuel

@engineersamuel

function stringifyToWarning<T extends Record<string, any>>(obj: T): T {
    if (process.env.NODE_ENV === 'development' && typeof obj === 'object') {
        ;(obj as any)['toJSON'] = function () {
            return 'Context too large for inspector'
        }
    }
    return obj
}

const machine = createMachine({
  context: stringifyToWarning({
      // huge context
   })
})

Here is a "big hammer" workaround for now :)

VanTanev avatar May 10 '21 13:05 VanTanev

Hahaha, yes that would work! But the inspector is so amazing I wouldn't want to give it up, but technically I guess I wouldn't be giving it up, I could still see the state, just not the context here, so point taken. Likely part of the problem is the stringifying of more complex objects, likely I need to write a toJSON that ignores anything but primitive types and basic objects.

engineersamuel avatar May 10 '21 14:05 engineersamuel

Yeah, that was my point in my previous comment - to do a toJSON() implementation that is specific to a given context, and simplifies it to only the most important parts you want to view inside the inspector.

VanTanev avatar May 10 '21 14:05 VanTanev

So that may work on the initial context, but I think assign may be overriding it. What we really need is the built in ability to override the toJSON without hacking it like this. xstate should provide a function that can be overridden.

engineersamuel avatar May 10 '21 14:05 engineersamuel

@engineersamuel Assign doesn't override this. I've tested it.

I don't think XState should provide a function here, given that it is perfectly solvable in userland. What XState might choose to do is, if it sees an object stringifies to more than 500 characters, it stops trying to stringify it.

VanTanev avatar May 10 '21 14:05 VanTanev

@VanTanev I see, I dug deeper, and there is an issue still here. The toJSON is not called when invoking send. When invoking send the following code is invoked:

service.send = function inspectSend(event, payload) {
            inspectService.send({
                type: 'service.event',
                event: stringify(toSCXMLEvent(toEventObject(event, payload))),
                sessionId: service.sessionId
            });
            return originalSend(event, payload);
        };

So you'll see the stringify is called there which does not respect the toJSON. I think the inspector likely respects it, but sending it to the inspector performs a full stringify:

utils.js:

function stringify(value) {
    try {
        return JSON.stringify(value);
    }
    catch (e) {
        return safeStringify(value);
    }
}

image

engineersamuel avatar May 10 '21 14:05 engineersamuel

you can apply a similar fix to send event objects:

function shortStringify<T extends EventObject>(obj: T): T {
    if (process.env.NODE_ENV === 'development' && typeof obj === 'object') {
        ;(obj as any)['toJSON'] = function () {
            const keys = Object.keys(this)
            const res = { ...this }
            for (let key of keys) {
                const value = this[key]
                if (key === 'type') {
                  res[key] = value
                } else {
                  res[key] = '...'
                }
            }
            return res
        }
    }
    return obj
}

send(shortStringify({ type: 'MY_EVENT', data /* HUGE */ }))

VanTanev avatar May 10 '21 15:05 VanTanev

Awesome, that works, I made a slight change, will record here for others as well.

  export function shortStringify<T>(obj: T): T {
    if (process.env.NODE_ENV === 'development' && typeof obj === 'object') {
      ;(obj as any)['toJSON'] = function () {
        const keys = Object.keys(this)
        const res = { ...this }
        for (let key of keys) {
            const value = this[key]
            if (key === 'type') {
              res[key] = value
            } else {
              res[key] = '...'
            }
        }
        return res
      }
    }
    return obj
  }

Then call send with:

send(MintEvent.SOME_EVENT, { value: shortStringify(largeObject) });

engineersamuel avatar May 10 '21 16:05 engineersamuel

I'm fascinated by XState's visualization, and I'm currently developing all logic in the tree structure of the machine and the machine that is invoked. And I faced this problem.

The current problem is the initialization of my service, which calls up large objects through 11 asynchronous logic during the initialization process. To reuse asynchronous logic, enter parameters to retrieve other data using a wrapper machine with a promise machine at the bottom. I want the initialization to proceed automatically, so I use invoke.

So when I start the app, the root machine invokes the machine to delegate the work, and it repeats itself in a tree structure. As a result, the machine is created as a multiple of the operation and the inspector does not work well(The inspector cannot display the screen according to the amount of data in the context.). I am running XState in Node.js environment and the machine is working normally in Node.js, but the inspector is stopping.

Currently I do not send events directly, so in my use case, I think I need an option to override toJSON or omit the context within machine configure. If you have any ideas in this situation, can I get your advice?

fronterior avatar Aug 05 '22 16:08 fronterior

The solution I used is as follows.

// ignore.ts
function ignore(obj: Record<string, any>, name?: string) {
  return Object.assign(obj, { toJSON: () => `[Ignored object]${name ? ': ' + name : ''}`});
}
// assign.ts
import { assign as _assign, AssignAction, Assigner, EventObject, PropertyAssigner } from 'xstate';
import { ignore } from './ignore';

function resetIgnore(obj: any) {
  delete obj['toJSON'];
}

export function assign<TContext, TEvent extends EventObject = EventObject>(params: Assigner<TContext, TEvent> | PropertyAssigner<TContext, TEvent>, options: Partial<{ ignore: boolean }> = {}) {
  const result = _assign(params);

  const transformIgnore = options.ignore ? ignore : resetIgnore;

  if (typeof result.assignment === 'function') {
    return {
      ...result,
      assignment: (...params: any) => transformIgnore((result.assignment as any)(...params)),
    };
  } else {
    const clonedAssignment = {} as any;
    for (const key in result.assignment) {
      clonedAssignment[key] = (...params: any) => transformIgnore((result.assignment as any)[key](...params));
    }

    return {
      ...result,
      assignment: clonedAssignment,
    };
  }
}
// machine.ts
... // withConfig parameters
  services: {
    awaitPromise: (ctx) => ctx.createPromise().then(data => ignore(data)),
  },
  actions: {
    assignValue: assign({
      value: (ctx, event) => event.newValue,
    }, { ignore: true }),
  },
...

Here, ignore function is acting like a function you created. Assign function overwrites toJSON both when ignore is true and false, because data is always hidden when the toJSON of the received data is overwritten. I also worked on the Object.defineProperty, but I couldn't use it for primitive values, so I gave up and used to overwrite toJSON key.

My concerns are as follows. The toJSON field is included in the all data I want to ignore. This should be considered when using JSON.stringify in logic. Also, if it is an object, care should be taken when using a for in loop.

If I added toJSON so that I can't see all the data in the inspector, the inspector worked normally. I checked that the data was delivered as a reference instead of being duplicated, but it became slow when the inspector stringify the data. The 100 KB of data was enough to stop the inspector.... So if there's no way, I'd like it to go into the function of XState.

fronterior avatar Aug 05 '22 18:08 fronterior