authorgravatar for lucenz@proton.meSimone <lucenz@proton.me> 2026-09-24 05:36:37+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 00:36:37-03:00
logf1add18ae1560f509405a5eece07f3cfb1cc850f
tree1c125133b75f8bab45be4196873ec0562a4c3c5b
parent21e2b7de0062a3c1dca7209ca48b736dd9a36c84
signature Signed by PGP key B5690EEEBB952194

fix(shared): preserve final quoted empty CSV records (#11425)

Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com>

2 files changed, 33 insertions(+), 2 deletions(-)

packages/shared/src/delimitedPreview.test.ts+29
......@@ -21,6 +21,35 @@ describe("delimited file previews", () => {
2121 ["", "three"],
2222 ]);
2323 });
24 describe.each([",", "\t"] as const)("with delimiter %j", (delimiter) => {
25 it("preserves a final quoted empty record without requiring a line ending", () => {
26 for (const ending of ["", "\n", "\r\n"]) {
27 expect(parseDelimitedPreview(`name\r\nAlice\r\n""${ending}`, delimiter)).toEqual({
28 rows: [["name"], ["Alice"], [""]],
29 truncated: false,
30 });
31 }
32 expect(parseDelimitedPreview('""', delimiter)).toEqual({
33 rows: [[""]],
34 truncated: false,
35 });
36 });
37 it("does not invent records for empty input or a trailing line ending", () => {
38 for (const prefix of ["", "\ufeff"]) {
39 expect(parseDelimitedPreview(prefix, delimiter).rows).toEqual([]);
40 expect(parseDelimitedPreview(`${prefix}name\r\n`, delimiter).rows).toEqual([["name"]]);
41 expect(parseDelimitedPreview(`${prefix}""`, delimiter).rows).toEqual([[""]]);
42 }
43 });
44 it("keeps a quoted empty record at the row limit", () => {
45 const text = `${"name\n".repeat(99)}""`;
46 const preview = parseDelimitedPreview(text, delimiter);
47 expect(preview.rows).toHaveLength(100);
48 expect(preview.rows.at(-1)).toEqual([""]);
49 expect(preview.truncated).toBe(false);
50 expect(parseDelimitedPreview(`${text}\nextra`, delimiter).truncated).toBe(true);
51 });
52 });
2453 it("bounds rows, columns and cell length and reports partial content", () => {
2554 const table = parseDelimitedPreview(
2655 Array.from({ length: 101 }, () =>
packages/shared/src/delimitedPreview.ts+4-2
......@@ -16,12 +16,13 @@ export function parseDelimitedPreview(text: string, delimiter: "," | "\t") {
1616 let cell = "";
1717 let quoted = false;
1818 let truncated = false;
19 let rowStart = text.charCodeAt(0) === 0xfeff ? 1 : 0;
1920 const endCell = () => {
2021 if (row.length < 30) row.push(cell);
2122 else truncated = true;
2223 cell = "";
2324 };
24 for (let index = text.charCodeAt(0) === 0xfeff ? 1 : 0; index < text.length; index++) {
25 for (let index = rowStart; index < text.length; index++) {
2526 const char = text[index];
2627 if (char === '"') {
2728 if (quoted && text[index + 1] === '"') {
......@@ -41,12 +42,13 @@ export function parseDelimitedPreview(text: string, delimiter: "," | "\t") {
4142 rows.push(row);
4243 row = [];
4344 if (char === "\r" && text[index + 1] === "\n") index++;
45 rowStart = index + 1;
4446 if (rows.length === 100) return { rows, truncated: truncated || index < text.length - 1 };
4547 }
4648 } else if (cell.length < 2000) cell += char;
4749 else truncated = true;
4850 }
49 if (cell.length || row.length) {
51 if (rowStart < text.length) {
5052 endCell();
5153 rows.push(row);
5254 }