react-diagrams icon indicating copy to clipboard operation
react-diagrams copied to clipboard

Broken Rendering with Redux + Model Serialization/De-Serialization

Open jamal-ahmad opened this issue 5 years ago • 14 comments

So i'm trying to setup a simple app with redux as the global store. The graph is stored in a slice as a simple serialized model object. On every change to the DiagramModel entities (nodes, links), the DiagramModel is serialized and written back into the store.

import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import createEngine, { DiagramModel } from "@projectstorm/react-diagrams";
import { CanvasWidget } from "@projectstorm/react-canvas-core";
import { setGraph, fetchGraph } from "../features/graph/graphSlice";
import { RootState } from "./rootReducer";
import { SimpleSerializedGraph } from "../features/graph/types/simple";
import "./App.css";

const App: React.FC = () => {
    console.log("I got called");
    const [engine, setEngine] = useState(createEngine());

    const dispatch = useDispatch();
    const graph: SimpleSerializedGraph = useSelector(
        (state: RootState) => state.graph
    );

    // emulate graph data being loaded from the server
    useEffect(() => {
        console.log("loading graph");
        dispatch(fetchGraph());
    }, []);

    let model = new DiagramModel();
    let obj: ReturnType<DiagramModel["serialize"]> = JSON.parse(
        JSON.stringify(graph)
    );
    model.deserializeModel(obj, engine);
    console.log(model.serialize());
    model.getModels().forEach((item) =>
        item.registerListener({
            eventDidFire: () => {
                console.log("WOOSH");
                dispatch(setGraph(engine.getModel().serialize()));
            },
        })
    );
    engine.setModel(model);

    return (
        <React.Fragment>
            <CanvasWidget className="diagram-container" engine={engine} />
        </React.Fragment>
    );
};

export default App;

However this is resulting in very broken rendering. (see pic below). I've got a couple of questions:

  • Is there something that i'm doing wrong here?
  • if a state management solution is used, how is that global state supposed to be updated?
  • Should I store something else in the store e.g. the ModelEngine object itself? not sure how feasible that would be with redux
  • is redux not a good choice for react-diagrams? maybe something like mobx or react context? I hope this isn't the case because redux tooling is very nice and it would be shame if the react-diagrams and redux were at odds.
Screen Shot 2020-09-18 at 8 19 07 PM

the dummy data being used is as follows:

export const initialData: SimpleSerializedGraph = {
    "id": "initialData",
    "offsetX": 0,
    "offsetY": 0,
    "zoom": 100,
    "gridSize": 0,
    "layers": [],
}

export const dummyData: SimpleSerializedGraph = {
    "id": "dummyData",
    "offsetX": 0,
    "offsetY": 0,
    "zoom": 100,
    "gridSize": 0,
    "layers": [
        {
            "id": "28",
            "type": "diagram-links",
            "isSvg": true,
            "transformed": true,
            "models": {
                "36": {
                    "id": "36",
                    "type": "default",
                    "source": "32",
                    "sourcePort": "33",
                    "target": "34",
                    "targetPort": "35",
                    "points": [
                        {
                            "id": "37",
                            "type": "point",
                            "x": 147.234375,
                            "y": 133.5
                        },
                        {
                            "id": "38",
                            "type": "point",
                            "x": 409.5,
                            "y": 133.5
                        }
                    ],
                    "labels": [],
                    "width": 3,
                    "color": "gray",
                    "curvyness": 50,
                    "selectedColor": "rgb(0,192,255)",
                }
            }
        },
        {
            "id": "30",
            "type": "diagram-nodes",
            "isSvg": false,
            "transformed": true,
            "models": {
                "32": {
                    "id": "32",
                    "type": "default",
                    "x": 100,
                    "y": 100,
                    "ports": [
                        {
                            "id": "33",
                            "type": "default",
                            "x": 139.734375,
                            "y": 126,
                            "name": "Out",
                            "alignment": "right",
                            "parentNode": "32",
                            "links": [
                                "36"
                            ],
                            "in": false,
                            "label": "Out"
                        }
                    ],
                    "name": "Node 1",
                    "color": "rgb(0,192,255)",
                    "portsInOrder": [],
                    "portsOutOrder": [
                        "33"
                    ]
                },
                "34": {
                    "id": "34",
                    "type": "default",
                    "x": 400,
                    "y": 100,
                    "ports": [
                        {
                            "id": "35",
                            "type": "default",
                            "x": 402,
                            "y": 126,
                            "name": "In",
                            "alignment": "left",
                            "parentNode": "34",
                            "links": [
                                "36"
                            ],
                            "in": true,
                            "label": "In"
                        }
                    ],
                    "name": "Node 2",
                    "color": "rgb(192,255,0)",
                    "portsInOrder": [
                        "35"
                    ],
                    "portsOutOrder": []
                }
            }
        }
    ]
}

and the types being used are (i deduced them from the lib source code):

export interface SimpleSerializedBaseModel {
    type: string;
    selected?: boolean;
    extras?: any;
    id: string;
    locked?: boolean;
};

// this will allow abitrary proeprties to be added to the model
// as along as the base properties are present
export interface SimpleSerializedModel extends SimpleSerializedBaseModel {
    [prop: string]: any;
};

export interface SimpleSerializedLayer {
    isSvg: boolean;
    transformed: boolean;
    models: { [x: string]: SimpleSerializedModel };
    type: string;
    selected?: boolean;
    extras?: any;
    id: string;
    locked?: boolean;
}

export interface SimpleSerializedGraph {
    offsetX: number;
    offsetY: number;
    zoom: number;
    gridSize: number;
    layers: SimpleSerializedLayer[];
    id: string;
    locked?: boolean;
};

jamal-ahmad avatar Sep 19 '20 00:09 jamal-ahmad

Additional info would be that this issue only occurs when i try to write back to the store:

model.getModels().forEach((item) =>
    item.registerListener({
        eventDidFire: () => {
            console.log("WOOSH");
            dispatch(setGraph(engine.getModel().serialize()));
        },
    })
);

As a workaround, i'm updating the state every second right now instead of on every change:

    // every sec save the current state to the redux slice
    useEffect(() => {
        const saveDisposer = setInterval(() => {
            let newState = engine?.getModel()?.serialize();
            // inorder to avoid state history pollution
            // only update when changed
            if(!deepGraphEqual(graph, newState)) { 
                dispatch(setGraph(newState));
            }
        }, 1000);
        return () => clearInterval(saveDisposer);
        // eslint-disable-next-line react-hooks/exhaustive-deps
    });

jamal-ahmad avatar Sep 19 '20 00:09 jamal-ahmad

Using react-diagrams with redux is not a good idea, AFAIK.

I'm not sure why you need to serialize the diagram state to the redux so often, but this may end up causing perfomance issues. Are you trying to implement undo/redo features or something like that?

renato-bohler avatar Sep 19 '20 15:09 renato-bohler

Yeah, i'm implementing a drag and drop editor for a state machine framework. Undo/Redo would be features i want to have in there. I guess saving the state on some interval (every few seconds) and only saving when there's a diff reduces the number of times the model is repopulated into the engine. Following is how i've gotten it to render correctly:

The store slice:

import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import deepEqual from 'deep-equal';
import { AppThunk, AppDispatch } from '../../app/store';
import { initialData, dummyData } from './data';
import { SimpleSerializedGraph } from './types/simple';

const initialState = initialData;
const graphSlice = createSlice({
    name: 'graph',
    initialState,
    reducers: {
        setGraph(state, action: PayloadAction<SimpleSerializedGraph | undefined>) {
            console.log("setting graph")
            return (action.payload) ? action.payload : state;
        }
    }
});

export const { setGraph } = graphSlice.actions;

export const deepGraphEqual = (
    stateA: SimpleSerializedGraph | undefined, 
    stateB: SimpleSerializedGraph | undefined
): boolean => {
    return deepEqual(stateA, stateB);
}

export const fetchGraph = (): AppThunk => async (dispatch: AppDispatch) => {
    // simulate fetching from server
    setTimeout(() => {
        let fetchedGraph = dummyData;
        dispatch(graphSlice.actions.setGraph(fetchedGraph));
    }, 1000);
}

export default graphSlice.reducer;  

the rendering component:

import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import createEngine, { DiagramEngine, DiagramModel } from "@projectstorm/react-diagrams";
import { CanvasWidget } from "@projectstorm/react-canvas-core";
import { setGraph, fetchGraph, deepGraphEqual } from "../features/graph/graphSlice";
import { RootState } from "../app/rootReducer";
import { SimpleSerializedGraph } from "../features/graph/types/simple";
import { BbsmStateNodeFactory } from "./bbsm-state-node/BbsmStateNodeFactory";
import "./Graph.css";

const deSerializeModel = (graph: SimpleSerializedGraph, engine: DiagramEngine): DiagramModel => {
    let model = new DiagramModel();
    let obj: ReturnType<DiagramModel["serialize"]> = JSON.parse(JSON.stringify(graph));
    model.deserializeModel(obj, engine);
    return model;
};

export const Graph: React.FC = () => {
    const dispatch = useDispatch();
    const graph: SimpleSerializedGraph = useSelector((state: RootState) => state.graph);

    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const [engine, setEngine] = useState(createEngine());
    engine.getNodeFactories().registerFactory(new BbsmStateNodeFactory());

    useEffect(() => {
        dispatch(fetchGraph());
    }, [dispatch]);

    useEffect(() => {
        const saveDisposer = setInterval(() => {
            let newState = engine?.getModel()?.serialize();
            if (!deepGraphEqual(graph, newState)) {
                dispatch(setGraph(newState));
            }
        }, 1000);
        return () => clearInterval(saveDisposer);
    });

    engine.setModel(deSerializeModel(graph, engine));

    return (
        <div className="GraphContainer">
            <CanvasWidget className="CanvasWidgetContainer" engine={engine} />
        </div>
    );
};

However, it would be nice if there were some examples/demos on external store practices for react-diagrams. Something along the lines of, "here's how you use react-diagrams with redux/mobx/context"

jamal-ahmad avatar Sep 19 '20 20:09 jamal-ahmad

For the undo/redo, I would recommend using the command pattern. There's this great comment explaining what this is and how to use it: https://github.com/projectstorm/react-diagrams/issues/391#issuecomment-567390715.

I've implemented it on my project. If you want to try it out (example / source).

Again, using react-diagrams and store every change (or save based on a small time interval) to a redux store may be a bad approach. It may be easier to implement, but it will probably suffer on performance, as serializing and deserializing large diagrams could be time consuming.

renato-bohler avatar Sep 20 '20 02:09 renato-bohler

For the undo/redo, I would recommend using the command pattern. There's this great comment explaining what this is and how to use it: #391 (comment).

+1 for the command pattern.

I think i was approaching the problem from data first perspective (bad habit from previous mobx usage) but react-diagrams seems to follow more of a MVC like design.

I never thought of extending the DiagramModel class itself and i really like the approach because now i can bake the external features (loading and saving state-machines via backend service) right into the react-diagrams framework.

Also thanks for sharing your source code! it's very helpful to look at your design :)

A side note: i think it would be nice for react-diagrams to have a product showcase for open-source tools like yours. Not only would it provide reference implementations/patterns/designs but would also allow people to gauge the capability of the library without diving into too much details or POCing (or as in my case doing a bad pattern based POC)

jamal-ahmad avatar Sep 20 '20 18:09 jamal-ahmad

@renato-bohler can I start a showcase part of the Readme and put your project there?

dylanvorster avatar Sep 23 '20 10:09 dylanvorster

Sure @dylanvorster :smile:

renato-bohler avatar Sep 23 '20 13:09 renato-bohler

Using react-diagrams with redux is not a good idea, AFAIK.

Hey @renato-bohler what other way can I use the react-diagrams engine and model and access it from different components, do you know? Thanks for any help

sinanyaman95 avatar Jan 11 '21 08:01 sinanyaman95

Hey @renato-bohler what other way can I use the react-diagrams engine and model and access it from different components, do you know? Thanks for any help

Well, the problem is not using react-diagrams with redux itself... the problem is serializing every change on the diagram. If you need to access diagram informations on other components, you could listen to some key changes and dispatch actions accordingly, no problem.

Other thing that you could probably do (never tested it, though) is using a React Context to access the engine or the model elsewhere, so you could serialize it and get whatever information you need whenever you need them.

The thing is: don't serialize/deserialize every change, as this is not performatic. Do it only when strictly necessary.

renato-bohler avatar Jan 11 '21 12:01 renato-bohler

Hi @renato-bohler The example link mentioned on previous comment is not working. Could you share another one or confirm it is only possible to check in source code?

givvemee avatar Mar 31 '25 03:03 givvemee

Hi @renato-bohler The example link mentioned on previous comment is not working. Could you share another one or confirm it is only possible to check in source code?

Oh, it has been a while 😅 unsure if I'll be able to help, but what exactly is not working?

renato-bohler avatar Mar 31 '25 13:03 renato-bohler

@renato-bohler I hope i'm not bothering you. The example link redirects me to Untitled circuit. Is it still valid link? 😅

givvemee avatar Apr 01 '25 00:04 givvemee

@renato-bohler I hope i'm not bothering you. The example link redirects me to Untitled circuit. Is it still valid link? 😅

Not bothering 😄

Oh, I see. This link is still valid, it's just that you're seeing an empty circuit. Try this other example with a pre-built circuit. Try moving components around, adding components, deleting components and hitting CTRL + Z and CTRL + SHIFT + Z (if you're looking for the undo/redo functionality).

renato-bohler avatar Apr 01 '25 12:04 renato-bohler

@renato-bohler Thank you for sharing new link and it works now. Yes, I was looking for an example that could by implemented by undo/redo command pattern. Thank you so much, have a great one.

givvemee avatar Apr 02 '25 01:04 givvemee