Skip to content

Commit 6f260bc

Browse files
bloveclaude
andcommitted
feat(react): add the CSV delivery path
Slice 2 — Blob, anchor, filename. `<a download>` rather than showSaveFilePicker, and the reason is not taste: the anchor has NO user-activation requirement (the HTML spec's only guard is the sandboxed-downloads flag; Chromium's DownloadRequestLimiter is a rate limit), so it survives an await. showSaveFilePicker is transient-activation-gated and throws SecurityError afterwards — Chrome's own docs say to get the file handle BEFORE processing the data, which inverts the flow and makes the user name a file before knowing the export worked. It is also ~27% of users, desktop Chromium only, and untestable, since Playwright cannot drive a native dialog. buildExportFileName is pure and sanitizes for all three platforms at once, because everything the browser would otherwise do is lossy and silent: Chromium replaces `:` with `_` on EVERY OS (so a raw toISOString survives nowhere), strips leading dots, prefixes Windows device names, and turns trailing dots into underscores on Windows while removing them on POSIX. The incompleteness marker lives in the filename, not the file. RFC 4180 has no comment syntax, so a marker row would be a data row that pandas reads as a record with one populated column and NaN elsewhere. MIME is text/csv;charset=utf-8, kept consistent with the .csv extension — the HTML spec tells the UA to "fix" a mismatch by appending an extension, which is the documented Safari .txt complaint. Explicitly not application/octet-stream: Chromium maps it to NO extension, so the instinct to "force a download" yields an extensionless file. AG Grid ships text/plain here. No top-level browser access anywhere in the module: `"use client"` would not help (a client module still executes during SSR) and `client-only` would not either (it guards the RSC graph and is inert during SSR). 17 tests, mutation-proven four ways: keeping colons in the timestamp, dropping the PARTIAL marker, switching to application/octet-stream, and revoking the object URL synchronously each turn the suite red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 4868c16 commit 6f260bc

5 files changed

Lines changed: 243 additions & 0 deletions

File tree

‎.changeset/csv-save-file.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@pretable/react": minor
3+
---
4+
5+
Add `defaultSaveFile`, `toCsvBlob` and `buildExportFileName` — the delivery half
6+
of CSV export.
7+
8+
Blob + `<a download>`, chosen over `showSaveFilePicker` for one decisive reason:
9+
`<a download>` has no user-activation requirement, so it still works after an
10+
`await`, while the picker is transient-activation-gated and throws
11+
`SecurityError` once any async work has happened. Chrome's own guidance is to
12+
open the picker _before_ doing the work, which would make the user name a file
13+
before knowing whether the export succeeded.
14+
15+
`buildExportFileName` is pure and sanitizes for the union of all three
16+
platforms, because everything the browser would otherwise do to a name is lossy,
17+
silent, and differs by OS — Chromium replaces `:` with `_` on _every_ platform,
18+
strips leading dots, and diverges between Windows and POSIX on trailing dots.
19+
20+
An incomplete export is marked in the **filename** (`-PARTIAL`). The signal
21+
cannot go in the file: RFC 4180 has no comment syntax, so a marker row is a data
22+
row. A filename travels with the artifact when it is emailed onward and costs
23+
the bytes nothing.

‎packages/react/react.api.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ export interface AutosizeOptions {
2222
minWidthPx?: number;
2323
}
2424

25+
// @public
26+
export function buildExportFileName(input: BuildExportFileNameArgs): string;
27+
28+
// @public
29+
export interface BuildExportFileNameArgs {
30+
complete?: boolean;
31+
date: Date;
32+
// (undocumented)
33+
extension?: string;
34+
name: string;
35+
}
36+
2537
// @public (undocumented)
2638
export type ColumnAlign = "start" | "center" | "end";
2739

@@ -146,6 +158,9 @@ export type CreateLocalRowModelWithDefaultIdOptions<TColumns> = (TColumns extend
146158
// @public
147159
export function defaultCoerceForCopy(value: unknown): string;
148160

161+
// @public
162+
export function defaultSaveFile(file: PretableCsvFile, options?: SaveFileOptions): void;
163+
149164
// @public
150165
export interface DensityHeights {
151166
// (undocumented)
@@ -2375,6 +2390,12 @@ export interface RowSelectionColumnConfig {
23752390
width?: number;
23762391
}
23772392

2393+
// @public
2394+
export interface SaveFileOptions {
2395+
fileName?: string;
2396+
now?: Date;
2397+
}
2398+
23782399
// @public
23792400
export function serializeCsv<TRow extends PretableRow, TRowId extends PretableRowId, TColumns>(args: SerializeCsvArgs<TRow, TRowId, TColumns>): PretableCsvFile | null;
23802401

@@ -2420,6 +2441,9 @@ export interface SerializeRangesArgs<TRow extends PretableRow, TRowId extends Pr
24202441
scope?: "all" | "loaded";
24212442
}
24222443

2444+
// @public
2445+
export function toCsvBlob(file: PretableCsvFile): Blob;
2446+
24232447
// @public
24242448
export function useLocalRowModel<const TColumns extends readonly [unknown, ...(readonly unknown[])]>(options: UseLocalRowModelWithDefaultIdOptions<TColumns>): PretableRowModel<PretableRowForColumns<TColumns>, PretableConventionalRowId<PretableRowForColumns<TColumns>>, TColumns>;
24252449

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
3+
import type { PretableCsvFile } from "../csv";
4+
import {
5+
buildExportFileName,
6+
defaultSaveFile,
7+
exportTimestamp,
8+
toCsvBlob,
9+
} from "../save-file";
10+
11+
const AT = new Date(Date.UTC(2026, 7, 13, 14, 5, 30));
12+
13+
function file(overrides: Partial<PretableCsvFile> = {}): PretableCsvFile {
14+
return {
15+
text: "A,B\r\n1,2",
16+
rowCount: 1,
17+
scope: "all",
18+
complete: true,
19+
...overrides,
20+
};
21+
}
22+
23+
describe("exportTimestamp", () => {
24+
it("is ISO 8601 basic format in UTC, with no colons", () => {
25+
// Colons are not merely a Windows problem: Chromium replaces `:` with `_`
26+
// on every OS, so a raw toISOString() does not survive as written anywhere.
27+
expect(exportTimestamp(AT)).toBe("20260813T140530Z");
28+
expect(exportTimestamp(AT)).not.toContain(":");
29+
});
30+
31+
it("zero-pads so byte order equals time order", () => {
32+
expect(exportTimestamp(new Date(Date.UTC(2026, 0, 2, 3, 4, 5)))).toBe(
33+
"20260102T030405Z",
34+
);
35+
});
36+
});
37+
38+
describe("buildExportFileName", () => {
39+
it("stamps the name and appends the extension", () => {
40+
expect(buildExportFileName({ name: "report", date: AT })).toBe(
41+
"report-20260813T140530Z.csv",
42+
);
43+
});
44+
45+
it("replaces characters that are illegal on any platform", () => {
46+
expect(
47+
buildExportFileName({ name: 'a/b\\c:d*e?f"g<h>i|j', date: AT }),
48+
).toBe("a-b-c-d-e-f-g-h-i-j-20260813T140530Z.csv");
49+
});
50+
51+
it("strips a leading dot so the file is never hidden", () => {
52+
expect(buildExportFileName({ name: ".hidden", date: AT })).toBe(
53+
"hidden-20260813T140530Z.csv",
54+
);
55+
});
56+
57+
it("strips trailing dots and spaces", () => {
58+
// Windows silently drops them, so a name kept as-is would not be the name
59+
// on disk. POSIX keeps them, which is the divergence being closed.
60+
expect(buildExportFileName({ name: "report. ", date: AT })).toBe(
61+
"report-20260813T140530Z.csv",
62+
);
63+
});
64+
65+
it("prefixes a Windows reserved device name", () => {
66+
// CON.csv is still CON.
67+
expect(buildExportFileName({ name: "CON", date: AT })).toBe(
68+
"_CON-20260813T140530Z.csv",
69+
);
70+
expect(buildExportFileName({ name: "lpt9", date: AT })).toBe(
71+
"_lpt9-20260813T140530Z.csv",
72+
);
73+
});
74+
75+
it("falls back to a deterministic stem when the name sanitizes to nothing", () => {
76+
expect(buildExportFileName({ name: "...", date: AT })).toBe(
77+
"export-20260813T140530Z.csv",
78+
);
79+
});
80+
81+
it("does not double the extension", () => {
82+
expect(buildExportFileName({ name: "report", date: AT })).toMatch(/\.csv$/);
83+
expect(
84+
buildExportFileName({
85+
name: "report",
86+
date: AT,
87+
extension: ".csv",
88+
}).match(/\.csv/g),
89+
).toHaveLength(1);
90+
});
91+
92+
it("caps the stem so the whole name stays under the 255-byte limit", () => {
93+
const name = buildExportFileName({ name: "x".repeat(500), date: AT });
94+
expect(name.length).toBeLessThan(255);
95+
});
96+
97+
it("marks an INCOMPLETE export in the filename", () => {
98+
// The signal cannot live in the file: RFC 4180 has no comment syntax, so a
99+
// marker row is a data row. A filename travels with the artifact when it is
100+
// emailed onward and costs the bytes nothing.
101+
expect(
102+
buildExportFileName({ name: "report", date: AT, complete: false }),
103+
).toBe("report-20260813T140530Z-PARTIAL.csv");
104+
});
105+
106+
it("does not mark a complete export", () => {
107+
expect(
108+
buildExportFileName({ name: "report", date: AT, complete: true }),
109+
).not.toContain("PARTIAL");
110+
});
111+
});
112+
113+
describe("toCsvBlob", () => {
114+
it("carries the CSV bytes", async () => {
115+
// Asserted on the Blob we hold — NEVER on anything recovered from an object
116+
// URL. In jsdom, createObjectURL exists and appears to work, but jsdom's
117+
// Blob is not Node's, so the store holds the string "undefined" while the
118+
// MIME type survives. A round-trip assertion there can pass against
119+
// garbage; an assertion that survives the bytes becoming "undefined" is not
120+
// an assertion.
121+
const blob = toCsvBlob(file({ text: 'a,"b,c"\r\n1,2' }));
122+
await expect(blob.text()).resolves.toBe('a,"b,c"\r\n1,2');
123+
});
124+
125+
it("declares text/csv with a charset, matching the .csv extension", () => {
126+
// A mismatch is something the HTML spec tells the UA to "fix" by appending
127+
// an extension — the documented Safari .txt complaint. Never
128+
// application/octet-stream: Chromium maps it to NO extension.
129+
expect(toCsvBlob(file()).type).toBe("text/csv;charset=utf-8");
130+
});
131+
});
132+
133+
describe("defaultSaveFile", () => {
134+
const created: string[] = [];
135+
const revoked: string[] = [];
136+
137+
afterEach(() => {
138+
vi.restoreAllMocks();
139+
created.length = 0;
140+
revoked.length = 0;
141+
});
142+
143+
function stubObjectUrl() {
144+
vi.stubGlobal("URL", {
145+
...URL,
146+
createObjectURL: (b: Blob) => {
147+
created.push(b.type);
148+
return "blob:stub";
149+
},
150+
revokeObjectURL: (u: string) => revoked.push(u),
151+
});
152+
}
153+
154+
it("clicks a download anchor and cleans it up", () => {
155+
stubObjectUrl();
156+
const click = vi.spyOn(HTMLAnchorElement.prototype, "click");
157+
158+
defaultSaveFile(file(), { fileName: "x.csv", now: AT });
159+
160+
expect(click).toHaveBeenCalledOnce();
161+
// Removed again: the anchor is an implementation detail, not a mutation.
162+
expect(document.querySelector("a[download]")).toBeNull();
163+
});
164+
165+
it("names the file from the export's completeness", () => {
166+
stubObjectUrl();
167+
let downloadName = "";
168+
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
169+
this: HTMLAnchorElement,
170+
) {
171+
downloadName = this.download;
172+
});
173+
174+
defaultSaveFile(file({ complete: false, scope: "loaded" }), { now: AT });
175+
176+
expect(downloadName).toBe("export-20260813T140530Z-PARTIAL.csv");
177+
});
178+
179+
it("defers revoking the object URL", () => {
180+
vi.useFakeTimers();
181+
stubObjectUrl();
182+
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
183+
184+
defaultSaveFile(file(), { fileName: "x.csv", now: AT });
185+
186+
// Revoking synchronously after click() broke downloads in Firefox for long
187+
// enough that the ecosystem stopped trusting it. The cost of waiting is a
188+
// timer; the cost of being wrong is a silently missing file.
189+
expect(revoked).toEqual([]);
190+
vi.advanceTimersByTime(60_000);
191+
expect(revoked).toEqual(["blob:stub"]);
192+
vi.useRealTimers();
193+
});
194+
});

‎packages/react/src/public_api.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ export type {
134134
PretableFormulaEscapePredicate,
135135
SerializeCsvArgs,
136136
} from "./csv";
137+
export { buildExportFileName, defaultSaveFile, toCsvBlob } from "./save-file";
138+
export type { BuildExportFileNameArgs, SaveFileOptions } from "./save-file";
137139

138140
// Paste (mapPasteToTargets stays internal)
139141
export { parseTsv } from "./paste";

‎packages/react/src/save-file.ts‎

8.21 KB
Binary file not shown.

0 commit comments

Comments
 (0)