Skip to content

Commit ebd7759

Browse files
committed
handle infinite recursion
1 parent 930f1e2 commit ebd7759

File tree

2 files changed

+120
-25
lines changed

2 files changed

+120
-25
lines changed

src/js/packages/event-to-object/src/index.ts

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ export default function convert(
77
classObject: { [key: string]: any },
88
maxDepth: number = 10,
99
): object {
10+
const visited = new WeakSet<any>();
11+
visited.add(classObject);
12+
1013
// Begin conversion
1114
const convertedObj: { [key: string]: any } = {};
1215
for (const key in classObject) {
@@ -16,8 +19,10 @@ export default function convert(
1619
}
1720
// Handle objects (potentially cyclical)
1821
else if (typeof classObject[key] === "object") {
19-
const result = deepCloneClass(classObject[key], maxDepth);
20-
convertedObj[key] = result;
22+
const result = deepCloneClass(classObject[key], maxDepth, visited);
23+
if (result !== maxDepthSignal) {
24+
convertedObj[key] = result;
25+
}
2126
}
2227
// Handle simple types (non-cyclical)
2328
else {
@@ -31,7 +36,7 @@ export default function convert(
3136
window.Event &&
3237
classObject instanceof window.Event
3338
) {
34-
convertedObj["selection"] = serializeSelection(maxDepth);
39+
convertedObj["selection"] = serializeSelection(maxDepth, visited);
3540
}
3641

3742
return convertedObj;
@@ -40,7 +45,10 @@ export default function convert(
4045
/**
4146
* Serialize the current window selection.
4247
*/
43-
function serializeSelection(maxDepth: number): object | null {
48+
function serializeSelection(
49+
maxDepth: number,
50+
visited: WeakSet<any>,
51+
): object | null {
4452
if (typeof window === "undefined" || !window.getSelection) {
4553
return null;
4654
}
@@ -51,11 +59,11 @@ function serializeSelection(maxDepth: number): object | null {
5159
return {
5260
type: selection.type,
5361
anchorNode: selection.anchorNode
54-
? deepCloneClass(selection.anchorNode, maxDepth)
62+
? deepCloneClass(selection.anchorNode, maxDepth, visited)
5563
: null,
5664
anchorOffset: selection.anchorOffset,
5765
focusNode: selection.focusNode
58-
? deepCloneClass(selection.focusNode, maxDepth)
66+
? deepCloneClass(selection.focusNode, maxDepth, visited)
5967
: null,
6068
focusOffset: selection.focusOffset,
6169
isCollapsed: selection.isCollapsed,
@@ -67,33 +75,50 @@ function serializeSelection(maxDepth: number): object | null {
6775
/**
6876
* Recursively convert a class-based object to a plain object.
6977
*/
70-
function deepCloneClass(x: any, _maxDepth: number): object {
78+
function deepCloneClass(
79+
x: any,
80+
_maxDepth: number,
81+
visited: WeakSet<any>,
82+
): object {
7183
const maxDepth = _maxDepth - 1;
7284

7385
// Return an indicator if maxDepth is reached
7486
if (maxDepth <= 0 && typeof x === "object") {
7587
return maxDepthSignal;
7688
}
7789

78-
// Convert array-like class (e.g., NodeList, ClassList, HTMLCollection)
79-
if (
80-
Array.isArray(x) ||
81-
(typeof x?.length === "number" &&
82-
typeof x[Symbol.iterator] === "function" &&
83-
!Object.prototype.toString.call(x).includes("Map") &&
84-
!(x instanceof CSSStyleDeclaration))
85-
) {
86-
return classToArray(x, maxDepth);
90+
if (visited.has(x)) {
91+
return maxDepthSignal;
8792
}
93+
visited.add(x);
8894

89-
// Convert mapping-like class (e.g., Node, Map, Set)
90-
return classToObject(x, maxDepth);
95+
try {
96+
// Convert array-like class (e.g., NodeList, ClassList, HTMLCollection)
97+
if (
98+
Array.isArray(x) ||
99+
(typeof x?.length === "number" &&
100+
typeof x[Symbol.iterator] === "function" &&
101+
!Object.prototype.toString.call(x).includes("Map") &&
102+
!(x instanceof CSSStyleDeclaration))
103+
) {
104+
return classToArray(x, maxDepth, visited);
105+
}
106+
107+
// Convert mapping-like class (e.g., Node, Map, Set)
108+
return classToObject(x, maxDepth, visited);
109+
} finally {
110+
visited.delete(x);
111+
}
91112
}
92113

93114
/**
94115
* Convert an array-like class to a plain array.
95116
*/
96-
function classToArray(x: any, maxDepth: number): Array<any> {
117+
function classToArray(
118+
x: any,
119+
maxDepth: number,
120+
visited: WeakSet<any>,
121+
): Array<any> {
97122
const result: Array<any> = [];
98123
for (let i = 0; i < x.length; i++) {
99124
// Skip anything that should not be converted
@@ -102,7 +127,7 @@ function classToArray(x: any, maxDepth: number): Array<any> {
102127
}
103128
// Only push objects as if we haven't reached max depth
104129
else if (typeof x[i] === "object") {
105-
const converted = deepCloneClass(x[i], maxDepth);
130+
const converted = deepCloneClass(x[i], maxDepth, visited);
106131
if (converted !== maxDepthSignal) {
107132
result.push(converted);
108133
}
@@ -120,7 +145,11 @@ function classToArray(x: any, maxDepth: number): Array<any> {
120145
* We must iterate through it with a for-loop in order to gain
121146
* access to properties from all parent classes.
122147
*/
123-
function classToObject(x: any, maxDepth: number): object {
148+
function classToObject(
149+
x: any,
150+
maxDepth: number,
151+
visited: WeakSet<any>,
152+
): object {
124153
const result: { [key: string]: any } = {};
125154
for (const key in x) {
126155
// Skip anything that should not be converted
@@ -129,7 +158,7 @@ function classToObject(x: any, maxDepth: number): object {
129158
}
130159
// Add objects as a property if we haven't reached max depth
131160
else if (typeof x[key] === "object") {
132-
const converted = deepCloneClass(x[key], maxDepth);
161+
const converted = deepCloneClass(x[key], maxDepth, visited);
133162
if (converted !== maxDepthSignal) {
134163
result[key] = converted;
135164
}
@@ -149,7 +178,7 @@ function classToObject(x: any, maxDepth: number): object {
149178
) {
150179
const dataset = x["dataset"];
151180
if (!shouldIgnoreValue(dataset, "dataset", x)) {
152-
const converted = deepCloneClass(dataset, maxDepth);
181+
const converted = deepCloneClass(dataset, maxDepth, visited);
153182
if (converted !== maxDepthSignal) {
154183
result["dataset"] = converted;
155184
}
@@ -170,7 +199,7 @@ function classToObject(x: any, maxDepth: number): object {
170199
if (typeof val === "object") {
171200
// Ensure files have enough depth to be serialized
172201
const propDepth = prop === "files" ? Math.max(maxDepth, 3) : maxDepth;
173-
const converted = deepCloneClass(val, propDepth);
202+
const converted = deepCloneClass(val, propDepth, visited);
174203
if (converted !== maxDepthSignal) {
175204
result[prop] = converted;
176205
}
@@ -199,7 +228,7 @@ function classToObject(x: any, maxDepth: number): object {
199228
!shouldIgnoreValue(element, element.name, x)
200229
) {
201230
if (typeof element === "object") {
202-
const converted = deepCloneClass(element, maxDepth);
231+
const converted = deepCloneClass(element, maxDepth, visited);
203232
if (converted !== maxDepthSignal) {
204233
result[element.name] = converted;
205234
}

src/js/packages/event-to-object/tests/event-to-object.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,3 +604,69 @@ test("converts form submission with file input", () => {
604604
expect(converted.target.myFile.files.length).toBe(1);
605605
expect(converted.target.myFile.files[0].name).toBe("test.txt");
606606
});
607+
608+
test("handles recursive structures", () => {
609+
// Direct recursion
610+
const recursive: any = { a: 1 };
611+
recursive.self = recursive;
612+
613+
const converted: any = convert(recursive);
614+
expect(converted.a).toBe(1);
615+
expect(converted.self).toBeUndefined();
616+
617+
// Indirect recursion
618+
const indirect: any = { name: "root" };
619+
const child: any = { name: "child" };
620+
indirect.child = child;
621+
child.parent = indirect;
622+
623+
const convertedIndirect: any = convert(indirect);
624+
expect(convertedIndirect.name).toBe("root");
625+
expect(convertedIndirect.child.name).toBe("child");
626+
expect(convertedIndirect.child.parent).toBeUndefined();
627+
});
628+
629+
test("handles shared references without stopping", () => {
630+
const shared = { name: "shared" };
631+
const root = {
632+
left: { item: shared },
633+
right: { item: shared },
634+
};
635+
636+
const converted: any = convert(root);
637+
expect(converted.left.item.name).toBe("shared");
638+
expect(converted.right.item.name).toBe("shared");
639+
expect(converted.left.item).not.toEqual({ __stop__: true });
640+
expect(converted.right.item).not.toEqual({ __stop__: true });
641+
});
642+
643+
test("handles recursive HTML node structures", () => {
644+
const parent = window.document.createElement("div");
645+
const child = window.document.createElement("span");
646+
parent.appendChild(child);
647+
648+
// Add explicit circular references to ensure we test recursion
649+
// even if standard DOM properties are not enumerable in this environment.
650+
(parent as any).circular = parent;
651+
(child as any).parentLink = parent;
652+
(parent as any).childLink = child;
653+
654+
const converted: any = convert(parent);
655+
656+
// Verify explicit cycle is handled
657+
expect(converted.circular).toBeUndefined();
658+
659+
// Verify child link is handled
660+
if (converted.childLink) {
661+
expect(converted.childLink.parentLink).toBeUndefined();
662+
}
663+
664+
// If the DOM implementation enumerates parentNode, it should be handled gracefully
665+
if (
666+
converted.children &&
667+
converted.children.length > 0 &&
668+
converted.children[0].parentNode
669+
) {
670+
expect(converted.children[0].parentNode).toBeUndefined();
671+
}
672+
});

0 commit comments

Comments
 (0)