-
Notifications
You must be signed in to change notification settings - Fork 709
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix: Inserting Slots within Slots from the Marketplace causes infinit…
…e loop and crash (#4113) ## Description closes #4111 - [x] - Fixes client side - [x] - Fixes server side https://github.com/user-attachments/assets/8bb6ffdc-2e78-45eb-b98b-e697daee822a ## Steps for reproduction Now seems like impossible to save broken project. Tested locally on broken project. ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## Before requesting a review - [ ] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [ ] tested locally and on preview environment (preview dev login: 5de6) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file
- Loading branch information
Showing
7 changed files
with
299 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
export * from "./types"; | ||
export * from "./shared/pages-utils"; | ||
export * from "./shared/marketplace"; | ||
export * from "./shared/graph-utils"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
import { test, expect, describe } from "@jest/globals"; | ||
import { findCycles, breakCyclesMutable } from "./graph-utils"; | ||
import type { Instance } from "@webstudio-is/sdk"; | ||
|
||
const typeId = "id" as const; | ||
|
||
describe("findCycles", () => { | ||
test("should return an empty array for an empty graph", () => { | ||
const graph: Instance[] = []; | ||
const result = findCycles(graph); | ||
expect(result).toEqual([]); | ||
}); | ||
|
||
test("should return an empty array for a graph with no cycles", () => { | ||
const graph = [ | ||
{ id: "1", children: [{ type: typeId, value: "2" }] }, | ||
{ id: "2", children: [{ type: typeId, value: "3" }] }, | ||
{ id: "3", children: [] }, | ||
]; | ||
const result = findCycles(graph); | ||
expect(result).toEqual([]); | ||
}); | ||
|
||
test("should return a single cycle for a graph with one cycle", () => { | ||
const graph = [ | ||
{ id: "1", children: [{ type: typeId, value: "2" }] }, | ||
{ id: "2", children: [{ type: typeId, value: "3" }] }, | ||
{ id: "3", children: [{ type: typeId, value: "1" }] }, | ||
]; | ||
const result = findCycles(graph); | ||
expect(result).toEqual([["1", "2", "3", "1"]]); | ||
}); | ||
|
||
test("should return multiple cycles for a graph with multiple cycles", () => { | ||
const graph = [ | ||
{ id: "1", children: [{ type: typeId, value: "2" }] }, | ||
{ | ||
id: "2", | ||
children: [ | ||
{ type: typeId, value: "3" }, | ||
{ type: typeId, value: "4" }, | ||
], | ||
}, | ||
{ id: "3", children: [{ type: typeId, value: "1" }] }, | ||
{ id: "4", children: [{ type: typeId, value: "2" }] }, | ||
]; | ||
const result = findCycles(graph); | ||
expect(result).toEqual([ | ||
["1", "2", "3", "1"], | ||
["2", "4", "2"], | ||
]); | ||
}); | ||
|
||
test("should return multiple cycles for a graph with multiple inline cycles", () => { | ||
const graph = [ | ||
{ id: "1", children: [{ type: typeId, value: "2" }] }, | ||
{ | ||
id: "2", | ||
children: [{ type: typeId, value: "3" }], | ||
}, | ||
{ | ||
id: "3", | ||
children: [ | ||
{ type: typeId, value: "4" }, | ||
{ type: typeId, value: "2" }, | ||
], | ||
}, | ||
{ id: "4", children: [{ type: typeId, value: "1" }] }, | ||
]; | ||
|
||
const result = findCycles(graph); | ||
|
||
expect(result).toEqual([ | ||
["1", "2", "3", "4", "1"], | ||
["2", "3", "2"], | ||
]); | ||
}); | ||
}); | ||
|
||
describe("breakCyclesMutable", () => { | ||
test("should return the same instances for an empty graph", () => { | ||
const result = breakCyclesMutable([], () => false); | ||
|
||
expect(result).toEqual([]); | ||
}); | ||
|
||
test("should return the same instances for a graph with no cycles", () => { | ||
const instances = [ | ||
{ id: "1", component: "Slot", children: [{ type: typeId, value: "2" }] }, | ||
{ id: "2", children: [{ type: typeId, value: "3" }] }, | ||
{ id: "3", children: [] }, | ||
]; | ||
const result = breakCyclesMutable( | ||
instances, | ||
(node) => node?.component === "Slot" | ||
); | ||
expect(result).toEqual(instances); | ||
}); | ||
|
||
test("should break a single cycle in the graph", () => { | ||
const instances = [ | ||
{ id: "1", children: [{ type: typeId, value: "2" }] }, | ||
{ id: "2", children: [{ type: typeId, value: "3" }] }, | ||
{ id: "3", component: "Slot", children: [{ type: typeId, value: "1" }] }, | ||
]; | ||
|
||
const result = breakCyclesMutable( | ||
instances, | ||
(node) => node?.component === "Slot" | ||
); | ||
|
||
expect(result).toEqual([ | ||
{ id: "1", children: [{ type: typeId, value: "2" }] }, | ||
{ id: "2", children: [] }, | ||
{ id: "3", component: "Slot", children: [{ type: typeId, value: "1" }] }, | ||
]); | ||
}); | ||
|
||
test("should break multiple cycles in the graph", () => { | ||
const instances = [ | ||
{ id: "1", children: [{ type: typeId, value: "2" }] }, | ||
{ | ||
id: "2", | ||
children: [ | ||
{ type: typeId, value: "3" }, | ||
{ type: typeId, value: "4" }, | ||
], | ||
}, | ||
{ id: "3", component: "Slot", children: [{ type: typeId, value: "1" }] }, | ||
{ id: "4", component: "Slot", children: [{ type: typeId, value: "2" }] }, | ||
]; | ||
|
||
const result = breakCyclesMutable( | ||
instances, | ||
(node) => node?.component === "Slot" | ||
); | ||
expect(result).toEqual([ | ||
{ id: "1", children: [{ type: typeId, value: "2" }] }, | ||
{ | ||
id: "2", | ||
children: [], | ||
}, | ||
{ id: "3", component: "Slot", children: [{ type: typeId, value: "1" }] }, | ||
{ id: "4", component: "Slot", children: [{ type: typeId, value: "2" }] }, | ||
]); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
import type { Instance } from "@webstudio-is/sdk"; | ||
|
||
type InstanceId = Instance["id"]; | ||
|
||
// Depth-First Search (DFS) algorithm to find cycles in a directed graph | ||
export const findCycles = ( | ||
graph: Iterable<Pick<Instance, "id" | "children">> | ||
): InstanceId[][] => { | ||
const adjacencyList: Record<InstanceId, InstanceId[]> = {}; | ||
|
||
// Build adjacency list | ||
for (const node of graph) { | ||
adjacencyList[node.id] = node.children | ||
.filter((child) => child.type === "id") | ||
.map((child) => child.value); | ||
} | ||
|
||
const visited = new Set<string>(); | ||
const path: InstanceId[] = []; | ||
const cycles: InstanceId[][] = []; | ||
|
||
const dfs = (nodeId: string): void => { | ||
if (path.includes(nodeId)) { | ||
const cycleStart = path.indexOf(nodeId); | ||
cycles.push(path.slice(cycleStart).concat(nodeId)); | ||
return; | ||
} | ||
|
||
if (visited.has(nodeId)) { | ||
return; | ||
} | ||
|
||
visited.add(nodeId); | ||
path.push(nodeId); | ||
|
||
for (const childId of adjacencyList[nodeId] || []) { | ||
dfs(childId); | ||
} | ||
|
||
path.pop(); | ||
}; | ||
|
||
// Start DFS from each node | ||
for (const node of graph) { | ||
if (!visited.has(node.id)) { | ||
dfs(node.id); | ||
} | ||
} | ||
|
||
return cycles; | ||
}; | ||
|
||
export const breakCyclesMutable = <T extends Pick<Instance, "id" | "children">>( | ||
instances: Iterable<T>, | ||
breakOn: (node: T) => boolean | ||
) => { | ||
const cycles = findCycles(instances); | ||
if (cycles.length === 0) { | ||
return instances; | ||
} | ||
|
||
const cycleInstances = new Map<T["id"], T>(); | ||
const cycleInstanceIdSet = new Set<T["id"]>(cycles.flat()); | ||
|
||
// Pick all instances that are part of the cycle | ||
for (const instance of instances) { | ||
if (cycleInstanceIdSet.has(instance.id)) { | ||
cycleInstances.set(instance.id, instance); | ||
} | ||
} | ||
|
||
for (const cycle of cycles) { | ||
// Find slot or take last instance | ||
const slotId = | ||
cycle.find((id) => breakOn(cycleInstances.get(id)!)) ?? | ||
cycle[cycle.length - 1]; | ||
|
||
// Remove slot from children of all instances in the cycle | ||
for (const id of cycle) { | ||
const instance = cycleInstances.get(id); | ||
if (instance === undefined) { | ||
continue; | ||
} | ||
|
||
if (instance.children.find((child) => child.value === slotId)) { | ||
instance.children = instance.children.filter( | ||
(child) => child.value !== slotId | ||
); | ||
} | ||
} | ||
} | ||
return instances; | ||
}; |