Split item state from order to minimize render

This commit is contained in:
Jeremie Pardou-Piquemal 2020-07-30 23:41:12 +02:00 committed by Jérémie Pardou-Piquemal
parent 93e681e32e
commit 25b75645f3
14 changed files with 246 additions and 203 deletions

View file

@ -1,40 +1,49 @@
import React from "react";
import { useRecoilValue } from "recoil";
import { useRecoilCallback } from "recoil";
import useGameStorage from "./Board/game/useGameStorage";
import { AvailableItemListAtom, BoardConfigAtom, ItemListAtom } from "./Board/";
import throttle from "lodash.throttle";
import {
AvailableItemListAtom,
BoardConfigAtom,
AllItemsSelector,
} from "./Board/";
export const AutoSave = () => {
const availableItemList = useRecoilValue(AvailableItemListAtom);
const boardConfig = useRecoilValue(BoardConfigAtom);
const itemList = useRecoilValue(ItemListAtom);
const [, setGameLocalSave] = useGameStorage();
// eslint-disable-next-line react-hooks/exhaustive-deps
const updateAutoSave = React.useCallback(
throttle(
(game) => {
if (game.items.length) {
setGameLocalSave(game);
}
},
5000,
{ trailing: true }
),
[]
const updateAutoSave = useRecoilCallback(
({ snapshot }) => async () => {
const availableItemList = await snapshot.getPromise(
AvailableItemListAtom
);
const boardConfig = await snapshot.getPromise(BoardConfigAtom);
const itemList = await snapshot.getPromise(AllItemsSelector);
const game = {
items: itemList,
board: boardConfig,
availableItems: availableItemList,
};
if (game.items.length) {
setGameLocalSave(game);
}
},
[setGameLocalSave]
);
React.useEffect(() => {
updateAutoSave({
items: itemList,
board: boardConfig,
availableItems: availableItemList,
});
}, [itemList, boardConfig, availableItemList, updateAutoSave]);
let mounted = true;
const cancel = setInterval(() => {
if (!mounted) return;
updateAutoSave();
}, 5000);
return () => {
mounted = false;
clearInterval(cancel);
};
}, [updateAutoSave]);
return null;
};

View file

@ -1,6 +1,7 @@
import React, { memo } from "react";
import { useRecoilValue } from "recoil";
import { selectedItemsAtom } from "../../Selector";
import { ItemsFamily } from "../../";
import debounce from "lodash.debounce";
import styled, { css } from "styled-components";
@ -67,7 +68,11 @@ const ItemWrapper = styled.div.attrs(({ rotation, loaded, locked }) => {
`}
`;
const Item = ({ setState, state, isSelected }) => {
const Item = ({
setState,
state: { type, x, y, rotation = 0, id, locked, layer, ...rest },
isSelected,
}) => {
const itemRef = React.useRef(null);
const sizeRef = React.useRef({});
const [unlock, setUnlock] = React.useState(false);
@ -94,13 +99,11 @@ const Item = ({ setState, state, isSelected }) => {
};
}, []);
const Component = getComponent(state.type);
const rotation = state.rotation || 0;
const Component = getComponent(type);
const updateState = React.useCallback(
(callbackOrItem, sync = true) => setState(state.id, callbackOrItem, sync),
[setState, state.id]
(callbackOrItem, sync = true) => setState(id, callbackOrItem, sync),
[setState, id]
);
// Update actual dimension. Usefull when image with own dimensions.
@ -154,7 +157,7 @@ const Item = ({ setState, state, isSelected }) => {
return (
<div
style={{
transform: `translate(${state.x}px, ${state.y}px)`,
transform: `translate(${x}px, ${y}px)`,
display: "inline-block",
position: "absolute",
top: 0,
@ -163,14 +166,14 @@ const Item = ({ setState, state, isSelected }) => {
>
<ItemWrapper
rotation={rotation}
locked={state.locked && !unlock}
locked={locked && !unlock}
selected={isSelected}
ref={itemRef}
layer={state.layer}
layer={layer}
loaded={loaded}
id={state.id}
id={id}
>
<Component {...state} x={0} y={0} setState={updateState} />
<Component {...rest} x={0} y={0} setState={updateState} />
</ItemWrapper>
</div>
);
@ -192,9 +195,10 @@ const MemoizedItem = memo(
const BaseItem = ({ setState, state }) => {
const selectedItems = useRecoilValue(selectedItemsAtom);
const realState = useRecoilValue(ItemsFamily(state.id));
return (
<MemoizedItem
state={state}
state={realState}
setState={setState}
isSelected={selectedItems.includes(state.id)}
/>

View file

@ -7,7 +7,7 @@ import { useRecoilValue } from "recoil";
import { Form, Field } from "react-final-form";
import AutoSave from "../../Form/AutoSave";
import { ItemListAtom } from "../../";
import { ItemsFamily } from "../../";
import Label from "../../Form/Label";
@ -18,9 +18,8 @@ import "rc-slider/assets/index.css";
const ItemFormFactory = ({ itemId, onSubmitHandler }) => {
const { t } = useTranslation();
const itemList = useRecoilValue(ItemListAtom);
const item = itemList.find(({ id }) => id === itemId);
const item = useRecoilValue(ItemsFamily(itemId));
if (!item) return null;
const FieldsComponent = getFormFieldComponent(item.type);

View file

@ -9,7 +9,7 @@ const ItemList = () => {
const itemList = useRecoilValue(ItemListAtom);
return itemList.map((item) => (
<Item key={item.id} state={item} setState={updateItem} />
<Item key={item.id} state={{ id: item.id }} setState={updateItem} />
));
};

View file

@ -1,6 +1,9 @@
import React from "react";
import { useC2C } from "../../../hooks/useC2C";
import useItems from "./useItems";
import { useRecoilCallback } from "recoil";
import { ItemsFamily } from "../";
export const SubcribeItemEvents = () => {
const [c2c] = useC2C();
@ -8,24 +11,25 @@ export const SubcribeItemEvents = () => {
const {
updateItemOrder,
moveItems,
setItemList,
removeItems,
insertItemBefore,
} = useItems();
const batchUpdate = useRecoilCallback(
({ set }) => (updatedItems) => {
for (const [id, newItem] of Object.entries(updatedItems)) {
set(ItemsFamily(id), (item) => ({ ...item, ...newItem }));
}
},
[]
);
React.useEffect(() => {
const unsub = c2c.subscribe(`batchItemsUpdate`, (updatedItems) => {
setItemList((prevList) => {
return prevList.map((item) => {
if (item.id in updatedItems) {
return { ...item, ...updatedItems[item.id] };
}
return item;
});
});
batchUpdate(updatedItems);
});
return unsub;
}, [c2c, setItemList]);
}, [c2c, batchUpdate]);
React.useEffect(() => {
const unsub = c2c.subscribe(

View file

@ -7,7 +7,7 @@ import { selectedItemsAtom } from "../Selector";
import { useUsers } from "../../users";
import intersection from "lodash.intersection";
import { ItemListAtom } from "../";
import { ItemsFamily } from "../";
import { getDefaultActionsFromItem } from "./Item/allItems";
import { useTranslation } from "react-i18next";
@ -48,10 +48,11 @@ export const useItemActions = () => {
const [availableActions, setAvailableActions] = React.useState([]);
const isMountedRef = React.useRef(false);
const getSelectedItemList = React.useCallback(
async (snapshot) => {
const itemList = await snapshot.getPromise(ItemListAtom);
return itemList.filter(({ id }) => selectedItems.includes(id));
const getSelectedItemList = useRecoilCallback(
({ snapshot }) => async () => {
return await Promise.all(
selectedItems.map((id) => snapshot.getPromise(ItemsFamily(id)))
);
},
[selectedItems]
);

View file

@ -1,8 +1,8 @@
import React from "react";
import { useC2C } from "../../../hooks/useC2C";
import { useSetRecoilState } from "recoil";
import { useSetRecoilState, useRecoilCallback } from "recoil";
import { ItemListAtom, selectedItemsAtom } from "../";
import { ItemListAtom, selectedItemsAtom, ItemsFamily } from "../";
const useItems = () => {
const [c2c] = useC2C();
@ -10,32 +10,42 @@ const useItems = () => {
const setItemList = useSetRecoilState(ItemListAtom);
const setSelectItems = useSetRecoilState(selectedItemsAtom);
const batchUpdateItems = React.useCallback(
(ids, callbackOrItem, sync = true) => {
const batchUpdateItems = useRecoilCallback(
({ set }) => (itemIds, callbackOrItem, sync = true) => {
let callback = callbackOrItem;
if (typeof callbackOrItem === "object") {
callback = () => callbackOrItem;
}
setItemList((prevList) => {
const updatedItems = {};
const updatedList = prevList.map((item) => {
if (ids.includes(item.id)) {
const newItem = {
...callback(item),
id: item.id,
};
updatedItems[newItem.id] = newItem;
return newItem;
}
return item;
const updatedItems = {};
itemIds.forEach((id) => {
set(ItemsFamily(id), (item) => {
const newItem = {
...callback(item),
id: item.id,
};
updatedItems[item.id] = newItem;
return newItem;
});
if (sync) {
c2c.publish(`batchItemsUpdate`, updatedItems);
}
return updatedList;
});
if (sync) {
c2c.publish(`batchItemsUpdate`, updatedItems);
}
},
[c2c]
);
const setItemListFull = useRecoilCallback(
({ set }) => (items) => {
setItemList(
items.map(({ id }) => ({
id,
}))
);
items.forEach((item) => {
set(ItemsFamily(item.id), item);
});
},
[c2c, setItemList]
[setItemList]
);
const updateItem = React.useCallback(
@ -45,28 +55,23 @@ const useItems = () => {
[batchUpdateItems]
);
const moveItems = React.useCallback(
(itemIds, posDelta, sync = true) => {
setItemList((prevList) => {
const newItemList = prevList.map((item) => {
if (itemIds.includes(item.id)) {
const x = item.x + posDelta.x;
const y = item.y + posDelta.y;
const newItem = { ...item, x, y };
return newItem;
}
return item;
});
if (sync) {
c2c.publish(`selectedItemsMove`, {
itemIds,
posDelta,
});
}
return newItemList;
const moveItems = useRecoilCallback(
({ set }) => async (itemIds, posDelta, sync = true) => {
itemIds.forEach((id) => {
set(ItemsFamily(id), (item) => ({
...item,
x: item.x + posDelta.x,
y: item.y + posDelta.y,
}));
});
if (sync) {
c2c.publish(`selectedItemsMove`, {
itemIds,
posDelta,
});
}
},
[setItemList, c2c]
[c2c]
);
const updateItemOrder = React.useCallback(
@ -135,80 +140,97 @@ const useItems = () => {
[setItemList, c2c]
);
const swapItems = React.useCallback(
(fromIds, toIds) => {
setItemList((prevItemList) => {
const swappedItems = toIds.map((toId) =>
prevItemList.find(({ id }) => id === toId)
);
const swapItems = useRecoilCallback(
({ snapshot, set }) => async (fromIds, toIds) => {
const fromItems = await Promise.all(
fromIds.map((id) => snapshot.getPromise(ItemsFamily(id)))
);
const toItems = await Promise.all(
toIds.map((id) => snapshot.getPromise(ItemsFamily(id)))
);
const updatedItems = {};
const replaceMapItems = toIds.reduce((theMap, id) => {
theMap[id] = fromItems.shift();
return theMap;
}, {});
const updatedItems = toItems.reduce((prev, toItem) => {
const replaceBy = replaceMapItems[toItem.id];
const newItem = {
...toItem,
x: replaceBy.x,
y: replaceBy.y,
};
set(ItemsFamily(toItem.id), newItem);
prev[toItem.id] = newItem;
return prev;
}, {});
c2c.publish(`batchItemsUpdate`, updatedItems);
const replaceMap = fromIds.reduce((theMap, id) => {
theMap[id] = toIds.shift();
return theMap;
}, {});
setItemList((prevItemList) => {
const result = prevItemList.map((item) => {
if (fromIds.includes(item.id)) {
const replaceBy = swappedItems.shift();
const newItem = {
...replaceBy,
x: item.x,
y: item.y,
return {
id: replaceMap[item.id],
};
updatedItems[replaceBy.id] = {
x: item.x,
y: item.y,
};
return newItem;
}
return item;
});
c2c.publish(`batchItemsUpdate`, updatedItems);
c2c.publish(
`updateItemListOrder`,
result.map(({ id }) => id)
);
return result;
});
},
[c2c, setItemList]
}
);
const insertItemBefore = React.useCallback(
(newItem, beforeId, sync = true) => {
const insertItemBefore = useRecoilCallback(
({ set }) => (newItem, beforeId, sync = true) => {
set(ItemsFamily(newItem.id), newItem);
setItemList((prevItemList) => {
if (sync) {
c2c.publish(`insertItemBefore`, [newItem, beforeId]);
}
if (beforeId) {
const insertAt = prevItemList.findIndex(({ id }) => id === beforeId);
const newItemList = [...prevItemList];
newItemList.splice(insertAt, 0, {
...newItem,
id: newItem.id,
});
return newItemList;
} else {
return [
...prevItemList,
{
...newItem,
id: newItem.id,
},
];
}
});
if (sync) {
c2c.publish(`insertItemBefore`, [newItem, beforeId]);
}
},
[c2c, setItemList]
);
const removeItems = React.useCallback(
(itemsIdToRemove, sync = true) => {
const removeItems = useRecoilCallback(
({ set }) => (itemsIdToRemove, sync = true) => {
setItemList((prevItemList) => {
if (sync) {
c2c.publish(`removeItems`, itemsIdToRemove);
}
return prevItemList.filter(
(item) => !itemsIdToRemove.includes(item.id)
);
});
itemsIdToRemove.forEach((id) => set(ItemsFamily(id), undefined));
if (sync) {
c2c.publish(`removeItems`, itemsIdToRemove);
}
setSelectItems((prevList) => {
return prevList.filter((id) => !itemsIdToRemove.includes(id));
});
@ -224,7 +246,7 @@ const useItems = () => {
updateItem,
swapItems,
reverseItemsOrder,
setItemList,
setItemList: setItemListFull,
pushItem: insertItemBefore,
removeItems,
insertItemBefore,

View file

@ -8,7 +8,7 @@ import {
} from "recoil";
import styled from "styled-components";
import { PanZoomRotateAtom, ItemListAtom, BoardConfigAtom } from "./";
import { PanZoomRotateAtom, BoardConfigAtom, AllItemsSelector } from "./";
import { insideClass, isPointInsideRect } from "../../utils";
export const selectedItemsAtom = atom({
@ -86,7 +86,7 @@ const Selector = ({ children }) => {
const throttledSetSelected = useRecoilCallback(
({ snapshot }) => async (selector) => {
if (stateRef.current.moving) {
const itemList = await snapshot.getPromise(ItemListAtom);
const itemList = await snapshot.getPromise(AllItemsSelector);
const selected = findSelected(itemList, selector).map(({ id }) => id);
setSelected((prevSelected) => {
@ -141,7 +141,7 @@ const Selector = ({ children }) => {
const onMouseUp = useRecoilCallback(
({ snapshot }) => async () => {
if (stateRef.current.moving) {
const itemList = await snapshot.getPromise(ItemListAtom);
const itemList = await snapshot.getPromise(AllItemsSelector);
const selected = findSelected(itemList, stateRef.current).map(
({ id }) => id
);

View file

@ -1,4 +1,4 @@
import { atom } from "recoil";
import { atom, atomFamily, selector } from "recoil";
export const AvailableItemListAtom = atom({
key: "availableItemList",
@ -15,4 +15,20 @@ export const ItemListAtom = atom({
default: [],
});
export default { ItemListAtom, BoardConfigAtom, AvailableItemListAtom };
export const ItemsFamily = atomFamily({
key: "Items",
default: () => {},
});
export const AllItemsSelector = selector({
key: "AllItemsSelector",
get: ({ get }) => get(ItemListAtom).map(({ id }) => get(ItemsFamily(id))),
});
export default {
ItemListAtom,
BoardConfigAtom,
AvailableItemListAtom,
ItemsFamily,
AllItemsSelector,
};

View file

@ -1,6 +1,10 @@
import { useRecoilValue } from "recoil";
import { AvailableItemListAtom, BoardConfigAtom, ItemListAtom } from "./atoms";
import {
AvailableItemListAtom,
BoardConfigAtom,
AllItemsSelector,
} from "./atoms";
import useLocalStorage from "../../../hooks/useLocalStorage";
//import useLocalStorage from 'react-use-localstorage';
@ -8,7 +12,7 @@ import useLocalStorage from "../../../hooks/useLocalStorage";
export const useGameStorage = () => {
const availableItemList = useRecoilValue(AvailableItemListAtom);
const boardConfig = useRecoilValue(BoardConfigAtom);
const itemList = useRecoilValue(ItemListAtom);
const itemList = useRecoilValue(AllItemsSelector);
const [gameLocalSave, setGameLocalSave] = useLocalStorage("savedGame", {
items: itemList,

View file

@ -5,4 +5,6 @@ export {
AvailableItemListAtom,
BoardConfigAtom,
ItemListAtom,
ItemsFamily,
AllItemsSelector,
} from "./game/atoms";

View file

@ -1,12 +1,14 @@
import React from "react";
import { useRecoilValue } from "recoil";
import { useRecoilCallback } from "recoil";
import { useTranslation } from "react-i18next";
import useGameStorage from "./Board/game/useGameStorage";
import { AvailableItemListAtom, BoardConfigAtom, ItemListAtom } from "./Board/";
import throttle from "lodash.throttle";
import {
AvailableItemListAtom,
BoardConfigAtom,
AllItemsSelector,
} from "./Board/";
const generateDownloadURI = (data) => {
return (
@ -15,10 +17,6 @@ const generateDownloadURI = (data) => {
};
export const DownloadGameLink = () => {
const availableItemList = useRecoilValue(AvailableItemListAtom);
const boardConfig = useRecoilValue(BoardConfigAtom);
const itemList = useRecoilValue(ItemListAtom);
const { t } = useTranslation();
const [downloadURI, setDownloadURI] = React.useState({});
@ -26,29 +24,40 @@ export const DownloadGameLink = () => {
const [, setGameLocalSave] = useGameStorage();
// eslint-disable-next-line react-hooks/exhaustive-deps
const updateSaveLink = React.useCallback(
throttle(
(game) => {
if (game.items.length) {
setDownloadURI(generateDownloadURI(game));
setDate(Date.now());
setGameLocalSave(game);
}
},
5000,
{ trailing: true }
),
[]
const updateSaveLink = useRecoilCallback(
({ snapshot }) => async () => {
const availableItemList = await snapshot.getPromise(
AvailableItemListAtom
);
const boardConfig = await snapshot.getPromise(BoardConfigAtom);
const itemList = await snapshot.getPromise(AllItemsSelector);
const game = {
items: itemList,
board: boardConfig,
availableItems: availableItemList,
};
if (game.items.length) {
setDownloadURI(generateDownloadURI(game));
setDate(Date.now());
setGameLocalSave(game);
}
},
[setGameLocalSave]
);
React.useEffect(() => {
updateSaveLink({
items: itemList,
board: boardConfig,
availableItems: availableItemList,
});
}, [itemList, boardConfig, availableItemList, updateSaveLink]);
let mounted = true;
const cancel = setInterval(() => {
if (!mounted) return;
updateSaveLink();
}, 5000);
return () => {
mounted = false;
clearInterval(cancel);
};
}, [updateSaveLink]);
return (
<a

View file

@ -1,10 +1,10 @@
import React from "react";
import { useRecoilState, useRecoilValue, useRecoilCallback } from "recoil";
import { useRecoilState, useRecoilValue } from "recoil";
import { useC2C } from "../hooks/useC2C";
import { useItems } from "../components/Board/Items";
import { AvailableItemListAtom, ItemListAtom } from "./Board";
import { AvailableItemListAtom, AllItemsSelector } from "./Board";
import useBoardConfig from "./useBoardConfig";
import { nanoid } from "nanoid";
@ -17,7 +17,7 @@ const fetchGame = async (url) => {
export const SubscribeGameEvents = () => {
const [c2c, joined, isMaster] = useC2C();
const { setItemList } = useItems();
const itemList = useRecoilValue(ItemListAtom);
const itemList = useRecoilValue(AllItemsSelector);
const [availableItemList, setAvailableItemList] = useRecoilState(
AvailableItemListAtom
);
@ -54,8 +54,8 @@ export const SubscribeGameEvents = () => {
};
}, [c2c, isMaster, joined]);
/*const loadGame = useRecoilCallback(
async (snapshot, game) => {
const loadGame = React.useCallback(
(game) => {
if (game.board.url) {
fetchGame(game.board.url).then((result) => {
setAvailableItemList(
@ -68,31 +68,17 @@ export const SubscribeGameEvents = () => {
);
}
setItemList(game.items);
game.items.forEach((item)=>{
const setItemPosition = await snapshot.getPromise();
});
setBoardConfig(game.board);
},
[setAvailableItemList, setBoardConfig, setItemList]
);*/
);
React.useEffect(() => {
const unsub = [];
unsub.push(
c2c.subscribe("loadGame", (game) => {
if (game.board.url) {
fetchGame(game.board.url).then((result) => {
setAvailableItemList(
result.availableItems.map((item) => ({ id: nanoid(), ...item }))
);
});
} else {
setAvailableItemList(
game.availableItems.map((item) => ({ id: nanoid(), ...item }))
);
}
setItemList(game.items);
setBoardConfig(game.board);
loadGame(game);
})
);
unsub.push(
@ -103,7 +89,7 @@ export const SubscribeGameEvents = () => {
return () => {
unsub.forEach((u) => u());
};
}, [c2c, setAvailableItemList, setItemList, setBoardConfig]);
}, [c2c, setBoardConfig, loadGame]);
// Load game from master if any
React.useEffect(() => {

View file

@ -47,16 +47,3 @@ export const shuffle = (a) => {
}
return a;
};
export const shuffleSelectedItems = (itemList, selectedItemIds) => {
const shuffledSelectedItems = shuffle(
itemList.filter(({ id }) => selectedItemIds.includes(id))
);
return itemList.map((item) => {
if (selectedItemIds.includes(item.id)) {
return { ...shuffledSelectedItems.pop(), x: item.x, y: item.y };
}
return item;
});
};