react-konva
react-konva copied to clipboard
Question: Is SSR supported?
Would it be possible to use konva-node with react on the server?
Reading the source I dont think it is. Closing the issue.
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.
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!
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.
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-ignore
s 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
Wow this is awesome thanks for the write up!!
@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} />;
});
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.
I will reopen the issue to continue the discussion.
@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?
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 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);
@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.
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 Do you know if / how this would support custom fonts. I have tried canvas.registerFonts() and continually hit errors within the registerFonts function.
@mattmagin what error? Make a small demo.
@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);
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 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
I tried on online service. But I am certain that must be libs to do it programmatically easily.
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"
npm install canvas
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');
@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!
What react version do you use?
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"
@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...