statelyai / xstate-viz

Visualizer for XState machines
https://stately.ai/viz
MIT License
434 stars 102 forks source link

ELK Layout: extend retry heuristic to disable wrapping #387

Closed zgotsch closed 2 years ago

zgotsch commented 2 years ago

I have a state machine which refuses to render in the visualizer, but would render when unrelated changes were made -- usually changes that would make other parts of the visualization smaller. I did some investigation and found that the ELK wrapping was to blame.

Following the pattern where compaction is disabled after a failed attempt, I made it so that wrapping is disabled after a second attempt. The graph that results isn't pretty, but it's strictly better than not being able to use the visualizer.

Failing Machine Input (I would have made it more minimal but small changes cause the rendering to succeed) ```typescript import { createMachine, assign, StateFrom, spawn, actions, ActorRefFrom, ActionObject, AnyEventObject, } from "xstate"; const pure = actions.pure; createMachine( { context: { id: null, players: new Map(), avMeetingId: null, gameUrl: null, primerUrl: null, queueId: null, }, predictableActionArguments: true, invoke: { src: "avLoader", id: "av_loader", onDone: [ { actions: "assignAvId", }, ], }, id: "cart", initial: "preparing", on: { ADD_PLAYER: { actions: "addPlayer", }, PLAYER_CONNECTED: { actions: "playerConnected", }, PLAYER_DISCONNECTED: { actions: "playerDisconnected", }, PLAYER_EXITED: { actions: "playerExited", }, TIMEOUT: { target: "#dead", }, "*": { actions: "removePlayer", cond: (_ctx, event) => event.type.startsWith("done.invoke.player."), }, }, states: { playing: { exit: "notifyPlayersGameEnded", on: { GAME_ENDED_PLACEHOLDER: { target: "postgame", }, }, }, postgame: { entry: "startAllowedToLeave", exit: "endAllowedToLeave", initial: "reflecting", on: { NEXT_LOOP_PLACEHOLDER: { target: "preparing", }, }, states: { reflecting: { on: { REFLECTION_TIMER_EXPIRED: "deciding", }, }, deciding: { on: { DECIDING_COMPLETE: "showing_decision", }, }, showing_decision: { after: { 5000: "awaiting_backfill", }, }, awaiting_backfill: { always: { target: "#cart.preparing", cond: "backfillComplete", }, }, }, }, preparing: { entry: "resetLoop", type: "parallel", states: { pregame: { initial: "loading_primer", states: { loading_primer: { invoke: { src: "primerLoader", id: "primer_loader", onDone: [ { actions: "assignPrimerUrl", target: "primer_ready_check", }, ], }, on: { TIMEOUT: { target: "#dead", }, }, }, primer_ready_check: { always: { cond: "primerReady", target: "priming", }, }, priming: { on: { PRIMER_ENDED: [ { cond: "gameReady", target: "#cart.playing", }, { target: "priming", internal: false, }, ], }, }, }, }, loading_game: { invoke: { src: "gameLoader", id: "game_loader", onDone: [ { actions: "assignGameUrl", }, ], }, on: { TIMEOUT: { target: "#dead", }, }, }, }, }, dead: { id: "dead", type: "final", }, }, }, { guards: { gameReady: (ctx) => ctx.gameUrl != null, // TODO(zgotsch): Primer readiness also depends on teams as well as backfill completion primerReady: (ctx) => ctx.primerUrl != null && ctx.avMeetingId != null, }, actions: { resetLoop: assign((_ctx) => ({ gameUrl: null, primerUrl: null })), assignGameUrl: assign({ gameUrl: (_ctx, event) => event.data ?? null, }), assignPrimerUrl: assign({ primerUrl: (_ctx, event) => event.data ?? null, }), assignAvId: assign({ avMeetingId: (_ctx, event) => event.data ?? null, }), addPlayer: assign({ players: (ctx, event) => { const newPlayers = new Map(ctx.players); newPlayers.set( event.playerId, spawn(playerMachine, { name: `player.${event.playerId}`, sync: true, }) ); return newPlayers; }, }), removePlayer: pure((ctx, event) => { const playerId = (event as AnyEventObject).type.split(".").pop() as | UserId | undefined; invariant( playerId != null, "Trying to remove a player without knowing the id. This should never happen due to the cond" ); const newPlayers = new Map(ctx.players); newPlayers.delete(playerId); return [ assign({ players: newPlayers }), send({ type: "PLAYER_REMOVED", playerId: playerId }), ] as Array>; }), playerConnected: pure((ctx, event) => { const playerRef = ctx.players.get(event.playerId); if (!playerRef) { return undefined; } return send("CONNECTED", { to: playerRef }); }), playerDisconnected: pure((ctx, event) => { const playerRef = ctx.players.get(event.playerId); if (!playerRef) { return undefined; } return send("DISCONNECTED", { to: playerRef }); }), playerExited: pure((ctx, event) => { const playerRef = ctx.players.get(event.playerId); if (!playerRef) { return undefined; } return send("EXITED", { to: playerRef }); }), startAllowedToLeave: pure((ctx) => [...ctx.players.values()].map((playerRef) => send("START_ALLOWED_TO_LEAVE", { to: playerRef }) ) ), endAllowedToLeave: pure((ctx) => [...ctx.players.values()].map((playerRef) => send("END_ALLOWED_TO_LEAVE", { to: playerRef }) ) ), notifyPlayersGameEnded: pure((ctx) => [...ctx.players.values()].map((playerRef) => send("GAME_ENDED", { to: playerRef }) ) ), // logCartDeath: (_ctx, event) => { // console.log("Cart died", event) // }, }, } ); ```

changeset-bot[bot] commented 2 years ago

🦋 Changeset detected

Latest commit: 77b2a13a76da0dbd9b2c7003c1872daca90c5760

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package | Name | Type | | -------------- | ----- | | xstate-viz-app | Patch |

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

vercel[bot] commented 2 years ago

@zgotsch is attempting to deploy a commit to the Stately Team on Vercel.

A member of the Team first needs to authorize it.

vercel[bot] commented 2 years ago

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Updated
xstate-viz ✅ Ready (Inspect) Visit Preview Oct 14, 2022 at 1:32PM (UTC)
plantvsbirds commented 2 years ago

My error looks similar to what yall are experiencing, too, hope we can ship this fast

image