From e03dbecb3bfc3dfbb70d4cb4a44ed25c241e444e Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Thu, 19 Jan 2023 13:17:02 +0300 Subject: [PATCH] Sync patches with server in designer (#813) For security reasons it's important to prevent canvas from having write access to db. Here I moved old sync logic to designer folder and got rid of one more event in pubsub, used atom directly. --- apps/designer/app/canvas/canvas.tsx | 14 +--- apps/designer/app/canvas/shared/sync/index.ts | 1 - apps/designer/app/canvas/shared/sync/queue.ts | 46 ----------- .../app/canvas/shared/sync/use-sync.ts | 36 --------- apps/designer/app/designer/designer.tsx | 13 ++- .../designer/features/topbar/sync-status.tsx | 7 +- .../app/designer/shared/nano-states/index.ts | 4 - .../app/designer/shared/sync-server.ts | 79 +++++++++++++++++++ .../app/routes/designer/$projectId.tsx | 8 +- apps/designer/app/shared/sync/index.ts | 1 - apps/designer/app/shared/sync/sync-status.ts | 1 - 11 files changed, 99 insertions(+), 111 deletions(-) delete mode 100644 apps/designer/app/canvas/shared/sync/index.ts delete mode 100644 apps/designer/app/canvas/shared/sync/queue.ts delete mode 100644 apps/designer/app/canvas/shared/sync/use-sync.ts create mode 100644 apps/designer/app/designer/shared/sync-server.ts delete mode 100644 apps/designer/app/shared/sync/sync-status.ts diff --git a/apps/designer/app/canvas/canvas.tsx b/apps/designer/app/canvas/canvas.tsx index 60e4be6e36..cbfb0d6ea9 100644 --- a/apps/designer/app/canvas/canvas.tsx +++ b/apps/designer/app/canvas/canvas.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState } from "react"; import store from "immerhin"; -import type { Build, CanvasData } from "@webstudio-is/project"; +import type { CanvasData } from "@webstudio-is/project"; import { createElementsTree, useAllUserProps, @@ -9,7 +9,6 @@ import { customComponentsMeta, setParams, type OnChangeChildren, - type Tree, } from "@webstudio-is/react-sdk"; import { publish, useSubscribe } from "~/shared/pubsub"; import { registerContainers, useCanvasStore } from "~/shared/sync"; @@ -26,7 +25,6 @@ import { import { useManageDesignModeStyles, GlobalStyles } from "./shared/styles"; import { useTrackSelectedElement } from "./shared/use-track-selected-element"; import { WrapperComponentDev } from "./features/wrapper-component"; -import { useSync } from "./shared/sync"; import { rootInstanceContainer, useBreakpoints, @@ -125,19 +123,13 @@ const useCopyPaste = () => { }); }; -type DesignModeProps = { - treeId: Tree["id"]; - buildId: Build["id"]; -}; - -const DesignMode = ({ treeId, buildId }: DesignModeProps) => { +const DesignMode = () => { useManageDesignModeStyles(); usePublishSelectedInstanceData(); useInsertInstance(); useReparentInstance(); useDeleteInstance(); useTrackSelectedElement(); - useSync({ buildId, treeId }); useUpdateSelectedInstance(); useUnselectInstance(); usePublishScrollState(); @@ -195,7 +187,7 @@ export const Canvas = ({ data }: CanvasProps): JSX.Element | null => { return ( <> - + {elements} ); diff --git a/apps/designer/app/canvas/shared/sync/index.ts b/apps/designer/app/canvas/shared/sync/index.ts deleted file mode 100644 index 375f89800c..0000000000 --- a/apps/designer/app/canvas/shared/sync/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./use-sync"; diff --git a/apps/designer/app/canvas/shared/sync/queue.ts b/apps/designer/app/canvas/shared/sync/queue.ts deleted file mode 100644 index 18e0f4a3ec..0000000000 --- a/apps/designer/app/canvas/shared/sync/queue.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { type SyncStatus } from "~/shared/sync"; -import { publish } from "~/shared/pubsub"; - -declare module "~/shared/pubsub" { - export interface PubsubMap { - syncStatus: SyncStatus; - } -} - -// @todo because of limitation with nested models and saving tree as a Json type -// we load that entire Json to manipulate and if a second request happens before the first one has written -// the result into the db, the second request will have outdated tree and will then write the outdated tree back -// to the database -// For now we will just have to queue the changes for all tree mutations. - -type Job = () => Promise; - -const queue: Array = []; - -export const enqueue = (job: Job) => { - queue.push(job); - if (isInProgress === false) { - dequeue(); - } -}; - -let isInProgress = false; - -const dequeue = () => { - const job = queue.shift(); - if (job) { - isInProgress = true; - publish({ - type: "syncStatus", - payload: "syncing", - }); - job().finally(() => { - isInProgress = false; - publish({ - type: "syncStatus", - payload: "idle", - }); - dequeue(); - }); - } -}; diff --git a/apps/designer/app/canvas/shared/sync/use-sync.ts b/apps/designer/app/canvas/shared/sync/use-sync.ts deleted file mode 100644 index 4ee346f2bd..0000000000 --- a/apps/designer/app/canvas/shared/sync/use-sync.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useInterval } from "react-use"; -import { sync } from "immerhin"; -import { enqueue } from "./queue"; -import type { Build } from "@webstudio-is/project"; -import { Tree } from "@webstudio-is/react-sdk"; -import { restPatchPath } from "~/shared/router-utils"; - -export const useSync = ({ - treeId, - buildId, -}: { - buildId: Build["id"]; - treeId: Tree["id"]; -}) => { - useInterval(() => { - const entries = sync(); - if (entries.length === 0) { - return; - } - - // @todo this entire queueing logic needs to be gone, it's a workaround, - // because prisma can't do atomic updates yet with sandbox documents - // and backend fetches and updates big objects, so if we send quickly, - // we end up overwriting things - enqueue(() => - fetch(restPatchPath(), { - method: "post", - body: JSON.stringify({ - transactions: entries, - treeId: treeId, - buildId: buildId, - }), - }) - ); - }, 1000); -}; diff --git a/apps/designer/app/designer/designer.tsx b/apps/designer/app/designer/designer.tsx index ecbb925eec..f4eadedca6 100644 --- a/apps/designer/app/designer/designer.tsx +++ b/apps/designer/app/designer/designer.tsx @@ -7,6 +7,7 @@ import { } from "@webstudio-is/project"; import { Box, type CSS, Flex, Grid } from "@webstudio-is/design-system"; import { registerContainers, useDesignerStore } from "~/shared/sync"; +import { useSyncServer } from "./shared/sync-server"; import interStyles from "~/shared/font-faces/inter.css"; import { SidebarLeft } from "./features/sidebar-left"; import { Inspector } from "./features/inspector"; @@ -16,7 +17,6 @@ import { useProject, useCurrentPageId, useSelectedInstanceData, - useSyncStatus, } from "./shared/nano-states"; import { Topbar } from "./features/topbar"; import designerStyles from "./designer.css"; @@ -64,11 +64,6 @@ const useSubscribeHoveredInstanceData = () => { useSubscribe("hoverInstance", setValue); }; -const useSubscribeSyncStatus = () => { - const [, setValue] = useSyncStatus(); - useSubscribe("syncStatus", setValue); -}; - const useSetProject = (project: Project) => { const [, setProject] = useProject(); useEffect(() => { @@ -286,6 +281,8 @@ export type DesignerProps = { project: Project; pages: Pages; pageId: string; + treeId: string; + buildId: string; buildOrigin: string; }; @@ -293,9 +290,10 @@ export const Designer = ({ project, pages, pageId, + treeId, + buildId, buildOrigin, }: DesignerProps) => { - useSubscribeSyncStatus(); useSubscribeSelectedInstanceData(); useSubscribeHoveredInstanceData(); useSubscribeBreakpoints(); @@ -304,6 +302,7 @@ export const Designer = ({ useSetCurrentPageId(pageId); const [publish, publishRef] = usePublish(); useDesignerStore(publish); + useSyncServer({ buildId, treeId }); usePublishAssets(publish); const [isPreviewMode] = useIsPreviewMode(); usePublishShortcuts(publish); diff --git a/apps/designer/app/designer/features/topbar/sync-status.tsx b/apps/designer/app/designer/features/topbar/sync-status.tsx index 1512d9818a..d570539b40 100644 --- a/apps/designer/app/designer/features/topbar/sync-status.tsx +++ b/apps/designer/app/designer/features/topbar/sync-status.tsx @@ -1,4 +1,6 @@ +import { useStore } from "@nanostores/react"; import { + theme, Flex, keyframes, AccessibleIcon, @@ -6,8 +8,7 @@ import { Box, } from "@webstudio-is/design-system"; import { CheckIcon, DotsHorizontalIcon } from "@webstudio-is/icons"; -import { useSyncStatus } from "../../shared/nano-states"; -import { theme } from "@webstudio-is/design-system"; +import { syncStatus } from "~/designer/shared/sync-server"; const iconSize = 15; @@ -39,7 +40,7 @@ const AnimatedDotsIcon = () => { }; export const SyncStatus = () => { - const [status] = useSyncStatus(); + const status = useStore(syncStatus); return ( diff --git a/apps/designer/app/designer/shared/nano-states/index.ts b/apps/designer/app/designer/shared/nano-states/index.ts index b604f3d7a7..418fa14aff 100644 --- a/apps/designer/app/designer/shared/nano-states/index.ts +++ b/apps/designer/app/designer/shared/nano-states/index.ts @@ -5,7 +5,6 @@ import { type SelectedInstanceData, type HoveredInstanceData, } from "@webstudio-is/project"; -import { type SyncStatus } from "~/shared/sync"; import { type Pages, type Project } from "@webstudio-is/project"; import type { AssetContainer, DeletingAssetContainer } from "../assets"; @@ -42,9 +41,6 @@ export const useCanvasWidth = () => useValue(canvasWidthContainer); const canvasRectContainer = atom(); export const useCanvasRect = () => useValue(canvasRectContainer); -const syncStatusContainer = atom("idle"); -export const useSyncStatus = () => useValue(syncStatusContainer); - const assetsContainer = atom>( [] ); diff --git a/apps/designer/app/designer/shared/sync-server.ts b/apps/designer/app/designer/shared/sync-server.ts new file mode 100644 index 0000000000..f30bfc962e --- /dev/null +++ b/apps/designer/app/designer/shared/sync-server.ts @@ -0,0 +1,79 @@ +import { atom } from "nanostores"; +import { sync } from "immerhin"; +import type { Build } from "@webstudio-is/project"; +import type { Tree } from "@webstudio-is/react-sdk"; +import { restPatchPath } from "~/shared/router-utils"; +import { useEffect } from "react"; + +export type SyncStatus = "syncing" | "idle"; + +export const syncStatus = atom("idle"); + +// @todo because of limitation with nested models and saving tree as a Json type +// we load that entire Json to manipulate and if a second request happens before the first one has written +// the result into the db, the second request will have outdated tree and will then write the outdated tree back +// to the database +// For now we will just have to queue the changes for all tree mutations. + +type Job = () => Promise; + +const queue: Array = []; + +export const enqueue = (job: Job) => { + queue.push(job); + if (isInProgress === false) { + dequeue(); + } +}; + +let isInProgress = false; + +const dequeue = () => { + const job = queue.shift(); + if (job) { + isInProgress = true; + syncStatus.set("syncing"); + job().finally(() => { + isInProgress = false; + syncStatus.set("idle"); + dequeue(); + }); + } +}; + +export const useSyncServer = ({ + treeId, + buildId, +}: { + buildId: Build["id"]; + treeId: Tree["id"]; +}) => { + useEffect(() => { + const intervalId = setInterval(() => { + // @todo prevent clearing queue in case request is failed + const entries = sync(); + if (entries.length === 0) { + return; + } + + // @todo this entire queueing logic needs to be gone, it's a workaround, + // because prisma can't do atomic updates yet with sandbox documents + // and backend fetches and updates big objects, so if we send quickly, + // we end up overwriting things + enqueue(() => + // @todo start next round only when the request is completed + fetch(restPatchPath(), { + method: "post", + body: JSON.stringify({ + transactions: entries, + treeId: treeId, + buildId: buildId, + }), + }) + ); + }, 1000); + return () => { + clearInterval(intervalId); + }; + }, [treeId, buildId]); +}; diff --git a/apps/designer/app/routes/designer/$projectId.tsx b/apps/designer/app/routes/designer/$projectId.tsx index 40de33a232..f15279e8ad 100644 --- a/apps/designer/app/routes/designer/$projectId.tsx +++ b/apps/designer/app/routes/designer/$projectId.tsx @@ -27,10 +27,16 @@ export const loader = async ({ const devBuild = await db.build.loadByProjectId(project.id, "dev"); + const pages = devBuild.pages; + const page = + pages.pages.find((page) => page.id === pageIdParam) ?? pages.homePage; + return { project, - pages: devBuild.pages, + pages, pageId: pageIdParam || devBuild.pages.homePage.id, + treeId: page.treeId, + buildId: devBuild.id, buildOrigin: getBuildOrigin(request), }; }; diff --git a/apps/designer/app/shared/sync/index.ts b/apps/designer/app/shared/sync/index.ts index e2f02329d0..9e9c46e6bd 100644 --- a/apps/designer/app/shared/sync/index.ts +++ b/apps/designer/app/shared/sync/index.ts @@ -1,2 +1 @@ -export * from "./sync-status"; export * from "./sync-stores"; diff --git a/apps/designer/app/shared/sync/sync-status.ts b/apps/designer/app/shared/sync/sync-status.ts deleted file mode 100644 index 06c827be78..0000000000 --- a/apps/designer/app/shared/sync/sync-status.ts +++ /dev/null @@ -1 +0,0 @@ -export type SyncStatus = "syncing" | "idle";