Skip to content

Feat table export - #10

Merged
Marvinkwame merged 11 commits into
mainfrom
feat-table-export
Sep 2, 2026
Merged

Marvinkwame merged 11 commits into
mainfrom
feat-table-export

Conversation

@Marvinkwame

Copy link
Copy Markdown
Owner

Summary

Adds useTableExport, a hook that exports any tablecraft table to CSV or JSON.
It returns the data (toRows, toCSV, toJSON) plus a download() helper for
the common case, and ships from the root entry because it adds no
dependency — CSV and JSON are pure string generation.

const { toCSV, download } = useTableExport(table)

<button onClick={() => download('csv', 'users.csv')}>Export</button>

Exports respect the active filters, sort order, and column visibility.
rows: 'selected' narrows to the selected rows.

Why

Export is the most common thing consumers build by hand on top of a table, and
doing it correctly is fiddlier than it looks: RFC 4180 quoting, duplicate column
headers, JSX-rendering cells, grouped rows, and Excel's BOM requirement all have
non-obvious right answers. This puts them in one place.

Design decisions worth reviewing

Row scope reads getPrePaginationRowModel(), not getFilteredRowModel().
This is the most important detail in the diff. TanStack's pipeline is
core → filtered → grouped → sorted → expanded → paginated, so
getFilteredRowModel() returns rows in original data order and silently
discards the user's sort. Someone sorts by revenue, hits Export, and gets an
unsorted file with no error. There is a named regression test.

The UTF-8 BOM belongs to download(), never toCSV(). Excel misreads
accented characters in a BOM-less UTF-8 CSV, so the file path needs one. But a
BOM is an invisible leading character that corrupts an API upload and breaks
snapshot tests, so the string path must not have one. The split is on
"produces a file" vs "produces a string". Overridable with { bom: false }.

Cell renderers are never invoked. Exports use the raw accessor value, with a
per-column override via meta: { exportValue: (row) => … }. Rendering cells to
text would need react-dom/server and would turn an action-button column into
the word "Delete" and an avatar column into an empty string. There is a test
asserting zero renderer calls.

download() throws during SSR; toRows/toCSV/toJSON are SSR-safe.
Deliberately unlike src/utils/url.ts, which swallows SSR errors silently — URL
sync runs automatically in an effect on every render, so silence is correct
there, whereas download() only ever runs from a click handler, so reaching it
during SSR is a genuine bug and should say so.

CSV and JSON only, no Excel. Every .xlsx writer is heavy (90 KB–950 KB)
and SheetJS carries licensing and CVE baggage. A correctly encoded CSV opens in
Excel. If real .xlsx is ever wanted it belongs behind its own subpath entry
with its own optional peer, following the /query and /virtual precedent.

Three bugs found and fixed during the build

All three originated in the implementation plan, not the code written from it:

  1. dedupeLabels didn't de-duplicate. It counted repeats per original
    label, so ['Name','Name','Name (2)'] emitted 'Name (2)' twice — the exact
    collision it exists to prevent. Fixed by tracking emitted labels.
  2. Exporting a grouped table duplicated every row — 3 source rows exported
    as 6. flatRows lists each leaf twice under grouping (once top-level, once
    as a sub-row of its group header), so filtering out group headers left both
    copies. Fixed by de-duping on row.id, preserving first-seen order.
  3. The BOM tests asserted something impossible. Blob.text() performs a
    WHATWG UTF-8 decode that strips a leading BOM, which also made the two
    negative BOM tests pass vacuously. All three now assert raw bytes via
    FileReader.readAsArrayBuffer.

Testing

Tests   416 passed (366 baseline + 50 new)
Lint    tsc --noEmit, clean
Build   check-dts: OK, check-entry-deps: OK

check-entry-deps: OK confirms the root entry's dependency boundary is
unchanged — the export hook adds nothing that reaches an optional peer.

Two test groups are deliberately strict and should not be relaxed: the grouped
export asserts exact count and exact order (set-based and toBeGreaterThan
assertions passed happily with duplicated rows and hid bug 2), and the BOM tests
assert raw bytes for the reason above.

Breaking changes

None. Additive only.

Note for the reviewer

toRows() keys its objects by column header (de-duplicated) rather than by
column id. This is a genuine coin-flip that has never been explicitly ratified,
and it becomes public API on merge. Header keys give readable output
({ "First Name": "Ada" }); column-id keys would be stable across header and
i18n changes ({ firstName: "Ada" }). Worth settling in review.

package.json is still 3.0.0 here — the version bump lives in the a11y PR.

@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
tablecraft Ready Ready Preview Sep 2, 2026 9:12am UTC
tablecraft-g1vu Ready Ready Preview Sep 2, 2026 9:12am UTC

@Marvinkwame
Marvinkwame merged commit a7aea1f into main Sep 2, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant