Skip to content

Commit 97e0864

Browse files
committed
Fix page patch denormalization
1 parent 40e133e commit 97e0864

5 files changed

Lines changed: 205 additions & 27 deletions

File tree

apps/builder/app/shared/sync/sync-stores.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { useEffect } from "react";
44
import { batched } from "nanostores";
55
import { nanoid } from "nanoid";
66
import { $project } from "./data-stores";
7+
import {
8+
normalizePagesPatch,
9+
denormalizePagesPatch,
10+
} from "@webstudio-is/project/pages-patch-normalizer";
711
import {
812
$selectedPageHash,
913
$selectedInstanceSizes,

packages/project/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
"./build-patch-core.server": {
4141
"webstudio": "./src/db/build-patch-core.ts",
4242
"import": "./src/db/build-patch-core.ts"
43+
},
44+
"./pages-patch-normalizer": {
45+
"webstudio": "./src/shared/pages-patch-normalizer.ts",
46+
"import": "./src/shared/pages-patch-normalizer.ts"
4347
}
4448
},
4549
"license": "AGPL-3.0-or-later",

packages/project/src/db/build-patch-core.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
serializeData,
3434
} from "@webstudio-is/project-build";
3535
import type { Database } from "@webstudio-is/postgrest/index.server";
36+
import { denormalizePagesPatch } from "../shared/pages-patch-normalizer";
3637

3738
enableMapSet();
3839
enablePatches();
@@ -66,6 +67,12 @@ export const singlePlayerVersionMismatchResult = {
6667
errors: singlePlayerVersionMismatchError,
6768
} as const satisfies BuildPatchUpdateResult;
6869

70+
const denormalizePagesPatches = (patches: Patch[], pages: Pages): Patch[] => {
71+
return denormalizePagesPatch([{ namespace: "pages", patches }], pages, {
72+
onMissing: "throw",
73+
})[0].patches;
74+
};
75+
6976
export const createBuildPatchUpdate = async ({
7077
build,
7178
clientVersion,
@@ -120,10 +127,13 @@ export const createBuildPatchUpdate = async ({
120127
if (namespace === "pages") {
121128
const pages = buildData.pages ?? parsePages(build.pages);
122129
const currentSocialImageAssetId =
123-
getHomePage(pages).meta.socialImageAssetId;
124-
buildData.pages = applyPatches(pages, patches);
125-
const newSocialImageAssetId = getHomePage(buildData.pages).meta
126-
.socialImageAssetId;
130+
pages.homePage.meta.socialImageAssetId;
131+
buildData.pages = applyPatches(
132+
pages,
133+
denormalizePagesPatches(patches, pages)
134+
);
135+
const newSocialImageAssetId =
136+
buildData.pages.homePage.meta.socialImageAssetId;
127137
if (currentSocialImageAssetId !== newSocialImageAssetId) {
128138
previewImageAssetId = newSocialImageAssetId || null;
129139
}

packages/project/src/db/build-patch.test.ts

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -135,18 +135,8 @@ describe("patchBuild", () => {
135135
{
136136
...buildRow,
137137
pages: JSON.stringify({
138-
meta: {},
139-
homePageId: "page-1",
140-
rootFolderId: "root",
138+
...JSON.parse(buildRow.pages),
141139
pages: [
142-
{
143-
id: "page-1",
144-
name: "Home",
145-
path: "",
146-
title: "Home",
147-
meta: {},
148-
rootInstanceId: "body-1",
149-
},
150140
{
151141
id: "page-2",
152142
name: "About",
@@ -156,14 +146,6 @@ describe("patchBuild", () => {
156146
rootInstanceId: "body-1",
157147
},
158148
],
159-
folders: [
160-
{
161-
id: "root",
162-
name: "Root",
163-
slug: "",
164-
children: ["page-1", "page-2"],
165-
},
166-
],
167149
}),
168150
},
169151
])
@@ -187,7 +169,7 @@ describe("patchBuild", () => {
187169
patches: [
188170
{
189171
op: "replace",
190-
path: ["pages", "page-2", "path"],
172+
path: ["pages", "@page-2", "path"],
191173
value: "/company",
192174
},
193175
],
@@ -201,9 +183,7 @@ describe("patchBuild", () => {
201183

202184
expect(result).toEqual({ status: "ok", version: 4 });
203185
expect(
204-
JSON.parse((updatedBuild as { pages: string }).pages).pages.find(
205-
(page: { id: string }) => page.id === "page-2"
206-
)?.path
186+
JSON.parse((updatedBuild as { pages: string }).pages).pages[0].path
207187
).toBe("/company");
208188
});
209189

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import type { Patch } from "immer";
2+
import type { Folder, Page, Pages } from "@webstudio-is/sdk";
3+
4+
export type PagesPatchChange = {
5+
namespace: string;
6+
patches: Patch[];
7+
revisePatches?: Patch[];
8+
};
9+
10+
const ID_PREFIX = "@";
11+
12+
const encodeId = (id: string) => `${ID_PREFIX}${id}`;
13+
14+
const isIdSegment = (segment: string | number): segment is string => {
15+
return typeof segment === "string" && segment.startsWith(ID_PREFIX);
16+
};
17+
18+
const normalizeOnePatch = (
19+
patch: Patch,
20+
revisePatches: Patch[],
21+
pages: Pages
22+
): Patch => {
23+
const [key, indexOrId, ...rest] = patch.path;
24+
if (typeof indexOrId !== "number") {
25+
return patch;
26+
}
27+
if (key !== "pages" && key !== "folders") {
28+
return patch;
29+
}
30+
31+
const items: Array<Page | Folder> =
32+
key === "pages" ? pages.pages : pages.folders;
33+
34+
if (patch.op === "add") {
35+
const id = (patch.value as { id?: string } | undefined)?.id;
36+
if (!id) {
37+
return patch;
38+
}
39+
return { ...patch, path: [key, encodeId(id), ...rest] };
40+
}
41+
42+
if (patch.op === "remove") {
43+
const revisePatch = revisePatches.find(
44+
(rp) => rp.op === "add" && rp.path[0] === key && rp.path[1] === indexOrId
45+
);
46+
const id = (revisePatch?.value as { id?: string } | undefined)?.id;
47+
if (!id) {
48+
return patch;
49+
}
50+
return { ...patch, path: [key, encodeId(id), ...rest] };
51+
}
52+
53+
const id = (items[indexOrId] as { id?: string } | undefined)?.id;
54+
if (!id) {
55+
return patch;
56+
}
57+
return { ...patch, path: [key, encodeId(id), ...rest] };
58+
};
59+
60+
const normalizeRevisePatch = (
61+
revisePatch: Patch,
62+
patches: Patch[],
63+
pages: Pages
64+
): Patch => {
65+
const [key, indexOrId, ...rest] = revisePatch.path;
66+
if (typeof indexOrId !== "number") {
67+
return revisePatch;
68+
}
69+
if (key !== "pages" && key !== "folders") {
70+
return revisePatch;
71+
}
72+
73+
const items: Array<Page | Folder> =
74+
key === "pages" ? pages.pages : pages.folders;
75+
76+
if (revisePatch.op === "add") {
77+
const id = (revisePatch.value as { id?: string } | undefined)?.id;
78+
if (!id) {
79+
return revisePatch;
80+
}
81+
return { ...revisePatch, path: [key, encodeId(id), ...rest] };
82+
}
83+
84+
if (revisePatch.op === "remove") {
85+
const patch = patches.find(
86+
(p) => p.op === "add" && p.path[0] === key && p.path[1] === indexOrId
87+
);
88+
const id = (patch?.value as { id?: string } | undefined)?.id;
89+
if (!id) {
90+
return revisePatch;
91+
}
92+
return { ...revisePatch, path: [key, encodeId(id), ...rest] };
93+
}
94+
95+
const id = (items[indexOrId] as { id?: string } | undefined)?.id;
96+
if (!id) {
97+
return revisePatch;
98+
}
99+
return { ...revisePatch, path: [key, encodeId(id), ...rest] };
100+
};
101+
102+
const denormalizeOnePatch = (
103+
patch: Patch,
104+
pages: Pages,
105+
{ onMissing = "keep" }: { onMissing?: "keep" | "throw" } = {}
106+
): Patch => {
107+
const [key, indexOrId, ...rest] = patch.path;
108+
if (key !== "pages" && key !== "folders") {
109+
return patch;
110+
}
111+
if (isIdSegment(indexOrId as string | number) === false) {
112+
return patch;
113+
}
114+
115+
const id = (indexOrId as string).slice(ID_PREFIX.length);
116+
const items: Array<Page | Folder> =
117+
key === "pages" ? pages.pages : pages.folders;
118+
119+
if (patch.op === "add") {
120+
return { ...patch, path: [key, items.length, ...rest] };
121+
}
122+
123+
const index = items.findIndex((item) => item.id === id);
124+
if (index === -1) {
125+
if (onMissing === "throw") {
126+
throw new Error(
127+
`Unable to apply pages patch. Item "${id}" was not found.`
128+
);
129+
}
130+
return patch;
131+
}
132+
133+
return { ...patch, path: [key, index, ...rest] };
134+
};
135+
136+
export const normalizePagesPatch = <ChangeType extends PagesPatchChange>(
137+
changes: ChangeType[],
138+
pages: Pages
139+
): ChangeType[] =>
140+
changes.map((change) => {
141+
if (change.namespace !== "pages") {
142+
return change;
143+
}
144+
const revisePatches = change.revisePatches ?? [];
145+
return {
146+
...change,
147+
patches: change.patches.map((patch) =>
148+
normalizeOnePatch(patch, revisePatches, pages)
149+
),
150+
revisePatches: revisePatches.map((revisePatch) =>
151+
normalizeRevisePatch(revisePatch, change.patches, pages)
152+
),
153+
};
154+
});
155+
156+
export const denormalizePagesPatch = <ChangeType extends PagesPatchChange>(
157+
changes: ChangeType[],
158+
pages: Pages,
159+
options?: { onMissing?: "keep" | "throw" }
160+
): ChangeType[] =>
161+
changes.map((change) => {
162+
if (change.namespace !== "pages") {
163+
return change;
164+
}
165+
const denormalizedChange = {
166+
...change,
167+
patches: change.patches.map((patch) =>
168+
denormalizeOnePatch(patch, pages, options)
169+
),
170+
};
171+
if (change.revisePatches === undefined) {
172+
return denormalizedChange;
173+
}
174+
return {
175+
...denormalizedChange,
176+
revisePatches: change.revisePatches.map((revisePatch) =>
177+
denormalizeOnePatch(revisePatch, pages, options)
178+
),
179+
};
180+
});

0 commit comments

Comments
 (0)