Skip to content

Commit

Permalink
Correctly handle multiple and circular references.
Browse files Browse the repository at this point in the history
Fixes #14 .

It still needs to be confirmed that it’s ok according to web standards to iterate `FileList` instances with `for…of`, see w3c/FileAPI#94 (comment) .
  • Loading branch information
jaydenseric committed Jun 10, 2021
1 parent 530f74c commit 85fbe26
Show file tree
Hide file tree
Showing 3 changed files with 175 additions and 65 deletions.
6 changes: 6 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@

## Next

### Major

- The function `extractFiles` now deeply clones an input value containing multiple references of an object or array with a mirrored reference structure instead of creating multiple objects or arrays. This change shouldn’t affect typical `JSON.stringify` use with cloned values.
- The function `extractFiles` now uses `for…of` to iterate `FileList` instances.

### Patch

- Updated dev dependencies.
- Reverted the more specific package `main` field path.
- The function `extractFiles` now correctly handles circular references within the input value by recreating the circular references in the returned clone instead of infinitely recursing to the point of a `Maximum call stack size exceeded` error, fixing [#14](https://github.com/jaydenseric/extract-files/issues/14).
- Renamed imports in the test index module.
- Refactored `extractFiles` tests to use `Object.freeze` with input objects and arrays to ensure input isn’t mutated.
- Updated a code example to use a deep import.
Expand Down
109 changes: 70 additions & 39 deletions public/extractFiles.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,53 +70,84 @@ module.exports = function extractFiles(
path = '',
isExtractableFile = defaultIsExtractableFile
) {
let clone;
// Map of extracted files and their object paths within the input value.
const files = new Map();

// Map of arrays and objects recursed within the input value and their clones,
// for reusing clones of values that are referenced multiple times within the
// input value.
const clones = new Map();

/**
* Adds a file to the extracted files map.
* Recursively clones the value, extracting files.
* @kind function
* @name extractFiles~addFile
* @param {Array<ObjectPath>} paths File object paths.
* @param {ExtractableFile} file Extracted file.
* @name extractFiles~recurse
* @param {*} value Value to extract files from.
* @param {ObjectPath} path Prefix for object paths for extracted files.
* @param {Set} recursed Recursed arrays and objects for avoiding infinite recursion of circular references within the input value.
* @returns {*} Clone of the value with files replaced with `null`.
* @ignore
*/
function addFile(paths, file) {
const storedPaths = files.get(file);
if (storedPaths) storedPaths.push(...paths);
else files.set(file, paths);
}
function recurse(value, path, recursed) {
let clone = value;

if (isExtractableFile(value)) {
clone = null;

const filePaths = files.get(value);

filePaths ? filePaths.push(path) : files.set(value, [path]);
} else {
const isList =
Array.isArray(value) ||
(typeof FileList !== 'undefined' && value instanceof FileList);
const isObject = value && value.constructor === Object;

if (isList || isObject) {
const hasClone = clones.has(value);

if (hasClone) clone = clones.get(value);
else {
clone = isList ? [] : {};

if (isExtractableFile(value)) {
clone = null;
addFile([path], value);
} else {
const prefix = path ? `${path}.` : '';

if (typeof FileList !== 'undefined' && value instanceof FileList)
clone = Array.prototype.map.call(value, (file, i) => {
addFile([`${prefix}${i}`], file);
return null;
});
else if (Array.isArray(value))
clone = value.map((child, i) => {
const result = extractFiles(child, `${prefix}${i}`, isExtractableFile);
result.files.forEach(addFile);
return result.clone;
});
else if (value && value.constructor === Object) {
clone = {};
for (const i in value) {
const result = extractFiles(
value[i],
`${prefix}${i}`,
isExtractableFile
);
result.files.forEach(addFile);
clone[i] = result.clone;
clones.set(value, clone);
}

if (!recursed.has(value)) {
const pathPrefix = path ? `${path}.` : '';
const recursedDeeper = new Set(recursed).add(value);

if (isList) {
let index = 0;

for (const item of value) {
const itemClone = recurse(
item,
pathPrefix + index++,
recursedDeeper
);

if (!hasClone) clone.push(itemClone);
}
} else
for (const key in value) {
const propertyClone = recurse(
value[key],
pathPrefix + key,
recursedDeeper
);

if (!hasClone) clone[key] = propertyClone;
}
}
}
} else clone = value;
}

return clone;
}

return { clone, files };
return {
clone: recurse(value, path, new Set()),
files,
};
};
125 changes: 99 additions & 26 deletions test/public/extractFiles.test.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { deepStrictEqual } from 'assert';
import { deepStrictEqual, strictEqual } from 'assert';
import revertableGlobals from 'revertable-globals';
import ReactNativeFile from '../../public/ReactNativeFile.js';
import extractFiles from '../../public/extractFiles.js';
Expand Down Expand Up @@ -42,8 +42,20 @@ export default (tests) => {
files.forEach((file, i) => {
this[i] = file;
});

this.length = files.length;
}

[Symbol.iterator]() {
let index = 0;

return {
next: () =>
index < this.length
? { done: false, value: this[index++] }
: { done: true },
};
}
}

const revertGlobals = revertableGlobals({ File, FileList });
Expand Down Expand Up @@ -108,18 +120,6 @@ export default (tests) => {
});
});

tests.add(
'`extractFiles` with an object containing multiple references of a file.',
() => {
const file = new ReactNativeFile({ uri: '', name: '', type: '' });

deepStrictEqual(extractFiles(Object.freeze({ a: file, b: file })), {
clone: { a: null, b: null },
files: new Map([[file, ['a', 'b']]]),
});
}
);

tests.add('`extractFiles` with an object containing multiple files.', () => {
const fileA = new ReactNativeFile({ uri: '', name: '', type: '' });
const fileB = new ReactNativeFile({ uri: '', name: '', type: '' });
Expand All @@ -133,6 +133,19 @@ export default (tests) => {
});
});

tests.add('`extractFiles` with an array containing multiple files.', () => {
const file0 = new ReactNativeFile({ uri: '', name: '', type: '' });
const file1 = new ReactNativeFile({ uri: '', name: '', type: '' });

deepStrictEqual(extractFiles(Object.freeze([file0, file1])), {
clone: [null, null],
files: new Map([
[file0, ['0']],
[file1, ['1']],
]),
});
});

tests.add('`extractFiles` with a nested object containing a file.', () => {
const file = new ReactNativeFile({ uri: '', name: '', type: '' });

Expand All @@ -145,6 +158,27 @@ export default (tests) => {
);
});

tests.add('`extractFiles` with a nested array containing a file.', () => {
const file = new ReactNativeFile({ uri: '', name: '', type: '' });

deepStrictEqual(extractFiles(Object.freeze([Object.freeze([file])])), {
clone: [[null]],
files: new Map([[file, ['0.0']]]),
});
});

tests.add(
'`extractFiles` with an object containing multiple references of a file.',
() => {
const file = new ReactNativeFile({ uri: '', name: '', type: '' });

deepStrictEqual(extractFiles(Object.freeze({ a: file, b: file })), {
clone: { a: null, b: null },
files: new Map([[file, ['a', 'b']]]),
});
}
);

tests.add(
'`extractFiles` with an array containing multiple references of a file.',
() => {
Expand All @@ -157,25 +191,64 @@ export default (tests) => {
}
);

tests.add('`extractFiles` with an array containing multiple files.', () => {
const file0 = new ReactNativeFile({ uri: '', name: '', type: '' });
const file1 = new ReactNativeFile({ uri: '', name: '', type: '' });
tests.add('`extractFiles` with an object referenced multiple times.', () => {
const file = new ReactNativeFile({ uri: '', name: '', type: '' });
const inputA = Object.freeze({ a: file });
const result = extractFiles(Object.freeze({ a: inputA, b: inputA }));
const expectedRepeatedObject = { a: null };

deepStrictEqual(extractFiles(Object.freeze([file0, file1])), {
clone: [null, null],
files: new Map([
[file0, ['0']],
[file1, ['1']],
]),
deepStrictEqual(result, {
clone: { a: expectedRepeatedObject, b: expectedRepeatedObject },
files: new Map([[file, ['a.a', 'b.a']]]),
});
strictEqual(result.a, result.b);
});

tests.add('`extractFiles` with a nested array containing a file.', () => {
tests.add('`extractFiles` with an array referenced multiple times.', () => {
const file = new ReactNativeFile({ uri: '', name: '', type: '' });
const array = Object.freeze([file]);
const result = extractFiles(Object.freeze({ a: array, b: array }));
const expectedRepeatedArray = [null];

deepStrictEqual(result, {
clone: {
a: expectedRepeatedArray,
b: expectedRepeatedArray,
},
files: new Map([[file, ['a.0', 'b.0']]]),
});
strictEqual(result.clone.a, result.clone.b);
});

tests.add('`extractFiles` with an object with circular references.', () => {
const file = new ReactNativeFile({ uri: '', name: '', type: '' });
const input = { a: file };
input.b = input;

deepStrictEqual(extractFiles(Object.freeze([Object.freeze([file])])), {
clone: [[null]],
files: new Map([[file, ['0.0']]]),
Object.freeze(input);

const clone = { a: null };
clone.b = clone;

deepStrictEqual(extractFiles(input), {
clone,
files: new Map([[file, ['a']]]),
});
});

tests.add('`extractFiles` with an array with circular references.', () => {
const file = new ReactNativeFile({ uri: '', name: '', type: '' });
const input = [file];
input[1] = input;

Object.freeze(input);

const clone = [null];
clone[1] = clone;

deepStrictEqual(extractFiles(input), {
clone,
files: new Map([[file, ['0']]]),
});
});

Expand Down

0 comments on commit 85fbe26

Please sign in to comment.