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

Question: Is SSR supported?

Open benadam11 opened this issue 3 years ago • 13 comments

Would it be possible to use konva-node with react on the server?

benadam11 avatar Apr 09 '21 17:04 benadam11

Reading the source I dont think it is. Closing the issue.

benadam11 avatar Apr 09 '21 17:04 benadam11

I was thinking a lot about such use cases. But I didn't find the time yet to make it. Probably I can bootstrap it faster if there are any other similar solutions in React ecosystem for server rendering with hooks. But I don't know about them.

I think it is possible to do it.

lavrton avatar Apr 09 '21 17:04 lavrton

Yeah your project looks really awesome though! (My use case is I want to write a service that can run on the client or server to dynamically generate graphics) - thanks for taking the time to respond!

benadam11 avatar Apr 09 '21 17:04 benadam11

As the workaround, you can use puppeteer. Right now I am using such a solution for https://github.com/lavrton/polotno-node

For sure it is CPU consuming, but it produces 100% the same output and you can use the frontend code.

lavrton avatar Apr 09 '21 17:04 lavrton

I got it working using this as my Stage instead:

import {forwardRef, useEffect, useLayoutEffect, useRef} from "react";
import ReactFiberReconciler from 'react-reconciler';
import {useIsomorphicLayoutEffect} from "react-use";
import canvas from 'canvas';
import Konva from 'konva/lib/Core';
import * as HostConfig from 'react-konva/lib/ReactKonvaHostConfig';
import { applyNodeProps, toggleStrictMode } from 'react-konva/lib/makeUpdates';

const KonvaRenderer = ReactFiberReconciler(HostConfig);

const makeKonvaServer = (konvaRef) => {
  // mock window
  konvaRef.window = {
    Image: canvas.Image,
    devicePixelRatio: 1,
  };
  // mock document
  konvaRef.document = {
    createElement: function () {},
    documentElement: {
      addEventListener: function () {},
    },
  };

  // make some global injections
  global.requestAnimationFrame = (cb) => {
    setImmediate(cb);
  };

  // create canvas in Node env
  konvaRef.Util.createCanvasElement = () => {
    const node = new canvas.Canvas();
    node.style = {};
    return node;
  };

  // create image in Node env
  konvaRef.Util.createImageElement = () => {
    const node = new canvas.Image();
    node.style = {};
    return node;
  };

  // _checkVisibility use dom element, in node we can skip it
  konvaRef.Stage.prototype._checkVisibility = () => {};
}

function usePrevious(value) {
  const ref = useRef();
  useIsomorphicLayoutEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

const createStage = ({ width, height, container }) => {
  if (!Konva.isBrowser) {
    makeKonvaServer(Konva); 
  }

  return new Konva.Stage({
    width,
    height,
    container: Konva.isBrowser ? container : undefined
  })
};

export const StageWrap = (props) => {
  const container = useRef(null);
  const stage = useRef();
  const fiberRef = useRef();
  const oldProps = usePrevious(props);

  const _setRef = (stage) => {
    const { forwardedRef } = props;
    if (!forwardedRef) {
      return;
    }
    if (typeof forwardedRef === 'function') {
      forwardedRef(stage);
    } else {
      forwardedRef.current = stage;
    }
  };

  useIsomorphicLayoutEffect(() => {
    stage.current = createStage({
      width: props.width,
      height: props.height,
      container: container.current
    })

    _setRef(stage.current);

    fiberRef.current = KonvaRenderer.createContainer(stage.current);
    KonvaRenderer.updateContainer(props.children, fiberRef.current);

    return () => {
      _setRef(null);
      KonvaRenderer.updateContainer(null, fiberRef.current, null);
      stage.current?.destroy();
    };
  }, []);

  useIsomorphicLayoutEffect(() => {
    _setRef(stage.current);
    applyNodeProps(stage.current, props, oldProps);
    KonvaRenderer.updateContainer(props.children, fiberRef.current, null);
  });

  if (!Konva.isBrowser) {
    stage.current = createStage({
      width: props.width,
      height: props.height,
      container: container.current
    })

    _setRef(stage.current);
    fiberRef.current = KonvaRenderer.createContainer(stage.current);
    KonvaRenderer.updateContainer(props.children, fiberRef.current);

    const url = stage.current.toDataURL();

    return <img src={url} />
  }

  return (
    <div
      ref={container}
      accessKey={props.accessKey}
      className={props.className}
      role={props.role}
      style={props.style}
      tabIndex={props.tabIndex}
      title={props.title}
    />
  );
};

export const Stage = forwardRef((props, ref) => {
  return <StageWrap {...props} forwardedRef={ref} />;
});

I got the src from konva-node adapted into the makeKonvaServer because importing and using konva-node was mutating the Konva library on the client-side, not only due to the global.requestAnimationFrame mock but that was the main thing popping in my face and blocking me. Moving it here into a function and only mutating on the server made the trick.

The second thing is that you don't have effects nor layout effects on the server. So I replaced the useLayoutEffects with useIsomorphicLayoutEffect from the react-use library (which is pretty simple and could be in this snippet as well)

The third thing: because we don't have effects when it is a server-rendered environment, you want to execute the code from the effect at least once, so that is done in that small if case before returning the render. A quick trick that worked well.

Fourth, this needs to be converted to typescript, but so many @ts-ignores due to the lack of types just made it simpler to make it in js.

A lot can be improved here though, but it does the trick! 😄

Here is an usage example:

import React, { useState } from "react";
import Konva from 'konva';
import { Layer, Rect, Text } from 'react-konva';
import Layout from '../components/Layout'
import { Stage } from '../components/Stage';

const ColoredRect: React.FC = () => {
  const [color, setColor] = useState('green');

  return (
    <Rect
      x={20}
      y={20}
      width={50}
      height={50}
      fill={color}
      shadowBlur={5}
      onClick={() => {
        setColor(Konva.Util.getRandomColor());
      }}
    />
  );
}

const CanvasPage = () => (
  <Layout title="Canvas">
    <Stage width={600} height={600}>
      <Layer>
        <Text text="Try click on rect" />
        <ColoredRect />
      </Layer>
    </Stage>
  </Layout>
);

export default CanvasPage

armand1m avatar Apr 18 '21 22:04 armand1m

Wow this is awesome thanks for the write up!!

benadam11 avatar Apr 19 '21 02:04 benadam11

@benadam11 you're welcome! glad to help :smile:

Here is a cleaner version of the component above, with less duplication and suppressing the mismatch message that gets triggered on the browser when the server returns a <img> tag but then the client hydrates it as a <div>

import { forwardRef, useCallback, useEffect, useRef } from "react";
import ReactReconciler from 'react-reconciler';
import { useIsomorphicLayoutEffect, usePrevious } from "react-use";
import canvas from 'canvas';
import Konva from 'konva/lib/Core';
import * as HostConfig from 'react-konva/lib/ReactKonvaHostConfig';
import { applyNodeProps, toggleStrictMode } from 'react-konva/lib/makeUpdates';

const KonvaRenderer = ReactReconciler(HostConfig);

const makeKonvaServer = (konvaRef) => {
  // mock window
  konvaRef.window = {
    Image: canvas.Image,
    devicePixelRatio: 1,
  };

  // mock document
  konvaRef.document = {
    createElement: function () {},
    documentElement: {
      addEventListener: function () {},
    },
  };

  // make some global injections
  global.requestAnimationFrame = (cb) => {
    setImmediate(cb);
  };

  // create canvas in Node env
  konvaRef.Util.createCanvasElement = () => {
    const node = new canvas.Canvas();
    node.style = {};
    return node;
  };

  // create image in Node env
  konvaRef.Util.createImageElement = () => {
    const node = new canvas.Image();
    node.style = {};
    return node;
  };

  // _checkVisibility use dom element, in node we can skip it
  konvaRef.Stage.prototype._checkVisibility = () => {};
}

const createIsomorphicStage = ({ width, height, container }) => {
  if (!Konva.isBrowser) {
    makeKonvaServer(Konva); 
  }

  return new Konva.Stage({
    width,
    height,
    container,
  });
};

const useIsomorphicInitialSetup = (callback) => {
  if (!Konva.isBrowser) {
    /** Just run it */
    callback();
  }

  useIsomorphicLayoutEffect(callback, []);
}

export const StageWrap = (props) => {
  const container = useRef();
  const stage = useRef();
  const fiberRef = useRef();
  const oldProps = usePrevious(props);
  const {
    forwardedRef,
    width,
    height,
    children,
    accessKey,
    className,
    role,
    style,
    tabIndex,
    title,
  } = props;

  const setForwardedRef = useCallback((stage) => {
    if (!forwardedRef) {
      return;
    }

    if (typeof forwardedRef === 'function') {
      return forwardedRef(stage);
    } 

    forwardedRef.current = stage;
  }, [stage, forwardedRef]);

  const createUpdatedContainer = useCallback(() => {
    setForwardedRef(stage.current);
    fiberRef.current = KonvaRenderer.createContainer(stage.current);
    KonvaRenderer.updateContainer(children, fiberRef.current);
  }, [stage.current, children]);

  const updateContainer = useCallback(() => {
    setForwardedRef(stage.current);
    applyNodeProps(stage.current, props, oldProps);
    KonvaRenderer.updateContainer(props.children, fiberRef.current, null);
  }, [stage.current, fiberRef.current, props, oldProps]);

  const destroyContainer = useCallback(() => {
    setForwardedRef(null);
    KonvaRenderer.updateContainer(null, fiberRef.current, null);
    stage.current?.destroy();
  }, [fiberRef.current, stage.current]);

  useIsomorphicInitialSetup(() => {
    stage.current = createIsomorphicStage({
      width,
      height,
      container: container.current
    });
    createUpdatedContainer();
    return destroyContainer;
  });

  useIsomorphicLayoutEffect(updateContainer);

  if (!Konva.isBrowser) {
    const url = stage.current.toDataURL();

    return (
      <div>
        <img
          ref={container}
          accessKey={accessKey}
          className={className}
          role={role}
          style={style}
          tabIndex={tabIndex}
          title={title}
          src={url}
        />
      </div>
    );
  }

  return (
    <div
      suppressHydrationWarning
      ref={container}
      accessKey={accessKey}
      className={className}
      role={role}
      style={style}
      tabIndex={tabIndex}
      title={title}
    />
  );
};

export const Stage = forwardRef((props, ref) => {
  return <StageWrap {...props} forwardedRef={ref} />;
});

armand1m avatar Apr 19 '21 11:04 armand1m

That is a very good example, @armand1m! Thanks for sharing.

I am thinking about how to move it inside the official part of react-konva family. But I believe it will be a lot more useful if we can do any support for hooks or async rendering.

The current approach will draw only shapes. But most of the apps also have images. I am not sure if it is possible to add them too.

lavrton avatar Apr 19 '21 13:04 lavrton

I will reopen the issue to continue the discussion.

lavrton avatar Apr 19 '21 13:04 lavrton

@lavrton Indeed I haven't tested images but is a need I'll have as well, I'll give it a try.

I'm not sure what do you mean by support for "hooks or async rendering" in this case?

armand1m avatar Apr 19 '21 13:04 armand1m

The default way to use images in react-konva is use-image hook. And it is using useEffect API: https://github.com/konvajs/use-image/blob/master/index.js#L12. Also, image loading as an async operation. So we need to wait for it.

Probably React suspense can help here...

lavrton avatar Apr 19 '21 14:04 lavrton

@lavrton got it, thanks for explaining. Often CSS in JS libraries offer a function to collect stylesheets so you can inject in the style provider later. This is often done by rendering the component tree first, collecting the stylesheets and then rendering it again for the server response with the styles injected.

I've seen this same approach being applied for async operations, it might be an option here to offer an API similar to the following:

const CanvasProvider = ReactKonva.CanvasProvider;
const canvas = new ReactKonva.ServerCanvas();
await canvas.collect(<App {...props} />);

const html = ReactDOMServer.renderToString(
  <CanvasProvider state={canvas.state}>
    <App {...props} />
  </CanvasProvider>
);

res.send(html);

armand1m avatar Apr 19 '21 14:04 armand1m

@lavrton indeed the useImage hook breaks on the server, not only because of the useEffect but also because it depends on the document global. I'm going to make a custom one to see what I can achieve with it.

armand1m avatar Apr 21 '21 08:04 armand1m

I am closing the issue for now.

I don't think it is useful to render just shapes in a sync way. The real apps will need more complex integrations and loading. Like images, caching, filters, etc.

At the current moment, you can fully mount react-konva in Node.js. I just tried this and it works:

const React = require('react');
const { createRoot } = require('react-dom/client');
const { JSDOM } = require('jsdom');
const { Stage, Layer, Rect, Circle } = require('react-konva');

// Define the React component
const MyComponent = () => {
  return React.createElement(
    Stage,
    { width: 500, height: 500 },
    React.createElement(
      Layer,
      null,
      React.createElement(Rect, {
        x: 50,
        y: 50,
        width: 100,
        height: 100,
        fill: 'red',
      }),
      React.createElement(Circle, { x: 250, y: 250, radius: 50, fill: 'blue' })
    )
  );
};

// Mount the component like on the client
const dom = new JSDOM(`<!DOCTYPE html><html><body></body></html>`);
global.window = dom.window;
global.document = dom.window.document;

const div = document.createElement('div');
document.body.appendChild(div);

const root = createRoot(div);
root.render(React.createElement(MyComponent));

// wait till component is mounted and rendered
setTimeout(() => {
  console.log(global.Konva.stages[0].toDataURL());
}, [10]);

It can be used for rendering on server.

lavrton avatar Apr 14 '23 17:04 lavrton

@lavrton Do you know if / how this would support custom fonts. I have tried canvas.registerFonts() and continually hit errors within the registerFonts function.

mattmagin avatar Jun 23 '23 00:06 mattmagin

@mattmagin what error? Make a small demo.

lavrton avatar Jun 23 '23 00:06 lavrton

@lavrton Just a basic node.js app without Konva. We wanted to make sure that it wasn't anything we were doing on our end.

const fs = require("fs");
const { registerFont, createCanvas } = require("canvas");

registerFont("oswald.woff2", {
  family: "Oswald",
});

const canvas = createCanvas(500, 500);
const ctx = canvas.getContext("2d");

ctx.font = '20px "Oswald"';
ctx.fillText("The quick brown fox jumps over the lazy dog", 10, 100);

const buffer = canvas.toBuffer("image/png");
fs.writeFileSync("./image.png", buffer);

test-register-canvas.zip

mattmagin avatar Jun 23 '23 00:06 mattmagin

With your demo I saw:

Error: Could not parse font file

I converted the font to ttf format and it worked just fine. I don't know why canvas library can't parse woff2 format.

lavrton avatar Jun 23 '23 01:06 lavrton

@lavrton Hmmm, okay - what did you use to convert the file? Cause we are dealing with people uploading their own fonts - so we should need to do it programatically

mattmagin avatar Jun 26 '23 23:06 mattmagin

I tried on online service. But I am certain that must be libs to do it programmatically easily.

lavrton avatar Jun 27 '23 16:06 lavrton

I am closing the issue for now.

I don't think it is useful to render just shapes in a sync way. The real apps will need more complex integrations and loading. Like images, caching, filters, etc.

At the current moment, you can fully mount react-konva in Node.js. I just tried this and it works:

const React = require('react');
const { createRoot } = require('react-dom/client');
const { JSDOM } = require('jsdom');
const { Stage, Layer, Rect, Circle } = require('react-konva');

// Define the React component
const MyComponent = () => {
  return React.createElement(
    Stage,
    { width: 500, height: 500 },
    React.createElement(
      Layer,
      null,
      React.createElement(Rect, {
        x: 50,
        y: 50,
        width: 100,
        height: 100,
        fill: 'red',
      }),
      React.createElement(Circle, { x: 250, y: 250, radius: 50, fill: 'blue' })
    )
  );
};

// Mount the component like on the client
const dom = new JSDOM(`<!DOCTYPE html><html><body></body></html>`);
global.window = dom.window;
global.document = dom.window.document;

const div = document.createElement('div');
document.body.appendChild(div);

const root = createRoot(div);
root.render(React.createElement(MyComponent));

// wait till component is mounted and rendered
setTimeout(() => {
  console.log(global.Konva.stages[0].toDataURL());
}, [10]);

It can be used for rendering on server.

With this code I'm getting error Can't resolve 'canvas' I'm using this code with ssr, in NextJS route callback

"konva": "^9.2.2", "react-konva": "^18.2.10"

dendrofen avatar Oct 27 '23 21:10 dendrofen

npm install canvas

lavrton avatar Oct 30 '23 17:10 lavrton

closer, but still issue - new one

error TypeError: m.createRoot is not a function

This is probably because of const { createRoot } = require('react-dom/client');

dendrofen avatar Oct 31 '23 23:10 dendrofen

@lavrton I'm trying to create demo and place link to this topic, or append to docs.. so any help with issue solving would be great. Appreciate that!

dendrofen avatar Oct 31 '23 23:10 dendrofen

What react version do you use?

lavrton avatar Nov 01 '23 01:11 lavrton

Main use is Next.js "next": "^13.4.8",

about your question: "react": "18.2.0", "react-dom": "18.2.0", "react-konva": "^18.2.9", "konva": "^9.2.0"

dendrofen avatar Nov 02 '23 02:11 dendrofen

@lavrton I have success in implementing Konva SSR using Konva.Node with Stage create from Json. This solution do not need react-dom, but don't expect this to be great solution, because such algorithm doesn't provide full Konva functional and also need client functional for generating export json. So continue searching...

dendrofen avatar Nov 02 '23 02:11 dendrofen