From 9c4ee5f32b707a7f66e6e7b3ab93057e5b8c3017 Mon Sep 17 00:00:00 2001 From: Thanikan Date: Tue, 1 Sep 2026 07:51:32 +0530 Subject: [PATCH 1/8] feat!: add shapeSheet to persist SpreadsheetControl's sheet as records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit x-spreadsheet's columnHeader/rowHeader flags were purely cosmetic (preview table labels only) — extend them to also drive the persisted `sheet` shape: columnHeader shapes one record per data row, keyed by row 1; rowHeader (alone) shapes one record per other column, transposed, keyed by column A. Neither set: unchanged raw matrix. Both flags true is rejected outright (shapeSheet throws) rather than silently picking a winner — each orientation is independently meaningful in real usage, so guessing would silently discard whichever one the schema author actually meant. Breaking change for any schema that already sets columnHeader or rowHeader: their persisted shape changes from a raw matrix to records. Schemas that leave both unset are unaffected. Shape is told apart at read time via isRecordsSheet (Array.isArray on the first element), not a stored discriminant field. Formula evaluation is completely unaffected — x-evaluate still runs against the original, unshaped matrix; shaping only happens afterward, when building the persisted value. Co-Authored-By: Claude Sonnet 5 --- .../src/utils/spreadsheet/index.ts | 5 +- .../utils/spreadsheet/process.records.test.ts | 189 ++++++++++++++++++ .../src/utils/spreadsheet/process.ts | 93 ++++++++- .../src/utils/spreadsheet/types.ts | 9 +- 4 files changed, 290 insertions(+), 6 deletions(-) create mode 100644 packages/jsonforms-renderers/src/utils/spreadsheet/process.records.test.ts diff --git a/packages/jsonforms-renderers/src/utils/spreadsheet/index.ts b/packages/jsonforms-renderers/src/utils/spreadsheet/index.ts index 523fdcc..728ab37 100644 --- a/packages/jsonforms-renderers/src/utils/spreadsheet/index.ts +++ b/packages/jsonforms-renderers/src/utils/spreadsheet/index.ts @@ -5,8 +5,8 @@ export { evaluateFormulaWithVariables, describeFormulaError, } from './expression' -export { processMatrix } from './process' -export type { ProcessMatrixOptions } from './process' +export { processMatrix, shapeSheet, isRecordsSheet } from './process' +export type { ProcessMatrixOptions, ShapeSheetOptions } from './process' export type { CellValue, Matrix, @@ -16,4 +16,5 @@ export type { FormulaErrorCode, DerivationResult, SpreadsheetValue, + SheetData, } from './types' diff --git a/packages/jsonforms-renderers/src/utils/spreadsheet/process.records.test.ts b/packages/jsonforms-renderers/src/utils/spreadsheet/process.records.test.ts new file mode 100644 index 0000000..9bd27f9 --- /dev/null +++ b/packages/jsonforms-renderers/src/utils/spreadsheet/process.records.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest' +import { isRecordsSheet, processMatrix, shapeSheet } from './process' +import type { CellValue } from './types' + +describe('shapeSheet', () => { + it('returns the matrix unchanged when neither flag is set', () => { + const matrix: CellValue[][] = [ + ['Item', 'Qty'], + ['Widget', 10], + ] + expect(shapeSheet(matrix)).toBe(matrix) + }) + + describe('columnHeader', () => { + it('turns each data row into one record keyed by row 1', () => { + const matrix: CellValue[][] = [ + ['Item', 'Qty'], + ['Widget', 10], + ['Gadget', 20], + ] + expect(shapeSheet(matrix, { columnHeader: true })).toEqual([ + { Item: 'Widget', Qty: 10 }, + { Item: 'Gadget', Qty: 20 }, + ]) + }) + + it('yields an empty array for a header-only sheet (no data rows)', () => { + expect(shapeSheet([['Item', 'Qty']], { columnHeader: true })).toEqual([]) + }) + + it('yields an empty array for an empty matrix', () => { + expect(shapeSheet([], { columnHeader: true })).toEqual([]) + }) + + it('drops a null/blank header cell instead of stringifying it to a key', () => { + const matrix: CellValue[][] = [ + ['A', '', 'C'], + ['1', '2', '3'], + ] + expect(shapeSheet(matrix, { columnHeader: true })).toEqual([{ A: '1', C: '3' }]) + }) + + it('fills a ragged row shorter than the header with null for its missing keys', () => { + const matrix: CellValue[][] = [ + ['A', 'B', 'C'], + ['x', 'y'], + ] + expect(shapeSheet(matrix, { columnHeader: true })).toEqual([{ A: 'x', B: 'y', C: null }]) + }) + + it('drops a data row longer than the header instead of keeping its unheaded cells', () => { + const matrix: CellValue[][] = [ + ['A', 'B'], + ['x', 'y', 'z'], + ] + expect(shapeSheet(matrix, { columnHeader: true })).toEqual([{ A: 'x', B: 'y' }]) + }) + + it('a duplicate header value collides last-write-wins', () => { + const matrix: CellValue[][] = [ + ['A', 'A'], + ['1', '2'], + ] + expect(shapeSheet(matrix, { columnHeader: true })).toEqual([{ A: '2' }]) + }) + + it('stringifies a non-string header cell with plain String(), not locale-aware formatting', () => { + const matrix: CellValue[][] = [ + [100, true], + ['x', 'y'], + ] + expect(shapeSheet(matrix, { columnHeader: true })).toEqual([{ '100': 'x', true: 'y' }]) + }) + }) + + describe('rowHeader (columnHeader not set)', () => { + it('turns each OTHER column into one record keyed by column A, transposed', () => { + const matrix: CellValue[][] = [ + ['Metric', 'Q1', 'Q2'], + ['Revenue', 100, 120], + ['Cost', 40, 55], + ] + expect(shapeSheet(matrix, { rowHeader: true })).toEqual([ + { Metric: 'Q1', Revenue: 100, Cost: 40 }, + { Metric: 'Q2', Revenue: 120, Cost: 55 }, + ]) + }) + + it('yields an empty array when there is only a column A and no other columns', () => { + expect(shapeSheet([['Metric'], ['Revenue'], ['Cost']], { rowHeader: true })).toEqual([]) + }) + + it('yields an empty array for an empty matrix', () => { + expect(shapeSheet([], { rowHeader: true })).toEqual([]) + }) + + it('drops a null/blank column-A cell instead of stringifying it to a key', () => { + const matrix: CellValue[][] = [ + ['Metric', 'Q1'], + ['Revenue', 100], + ['', 999], + ['Cost', 40], + ] + expect(shapeSheet(matrix, { rowHeader: true })).toEqual([{ Metric: 'Q1', Revenue: 100, Cost: 40 }]) + }) + + it('fills a short row with null for the missing column', () => { + const matrix: CellValue[][] = [ + ['Metric', 'Q1', 'Q2'], + ['Revenue', 100], + ] + expect(shapeSheet(matrix, { rowHeader: true })).toEqual([ + { Metric: 'Q1', Revenue: 100 }, + { Metric: 'Q2', Revenue: null }, + ]) + }) + + it('a duplicate column-A value collides last-write-wins', () => { + const matrix: CellValue[][] = [ + ['Metric', 'Q1'], + ['Revenue', 100], + ['Revenue', 200], + ] + expect(shapeSheet(matrix, { rowHeader: true })).toEqual([{ Metric: 'Q1', Revenue: 200 }]) + }) + }) + + it('throws when both columnHeader and rowHeader are true, rather than picking a winner', () => { + const matrix: CellValue[][] = [ + ['A', 'B'], + ['1', '2'], + ] + expect(() => shapeSheet(matrix, { columnHeader: true, rowHeader: true })).toThrow( + /columnHeader and rowHeader cannot both be true/, + ) + }) +}) + +describe('isRecordsSheet', () => { + it('is false for a matrix', () => { + expect( + isRecordsSheet([ + ['a', 'b'], + ['c', 'd'], + ]), + ).toBe(false) + }) + + it('is true for records', () => { + expect(isRecordsSheet([{ a: 1 }, { a: 2 }])).toBe(true) + }) + + it('is true for an empty sheet (documented, unavoidable tie-break)', () => { + expect(isRecordsSheet([])).toBe(true) + }) +}) + +describe('processMatrix records-shaping integration', () => { + const matrix: CellValue[][] = [ + ['Item', 'Qty'], + ['Widget', 10], + ['Gadget', 20], + ] + + it('shapes sheet as records while derivations still reflect the raw, unshaped matrix', async () => { + const result = await processMatrix(matrix, [{ id: 'total', label: 'Total', expression: '=SUM(B2:B3)' }], { + columnHeader: true, + }) + expect(result.sheet).toEqual([ + { Item: 'Widget', Qty: 10 }, + { Item: 'Gadget', Qty: 20 }, + ]) + expect(result.derivations).toEqual({ total: { label: 'Total', value: 30 } }) + }) + + it('omits sheet entirely when persistSheet is false, even with a header flag set', async () => { + const result = await processMatrix(matrix, [{ id: 'total', label: 'Total', expression: '=SUM(B2:B3)' }], { + persistSheet: false, + columnHeader: true, + }) + expect(result).not.toHaveProperty('sheet') + }) + + it('stays matrix-shaped when neither header flag is set (backward-compat regression guard)', async () => { + const result = await processMatrix(matrix, [{ id: 'total', label: 'Total', expression: '=SUM(B2:B3)' }]) + expect(Array.isArray(result.sheet)).toBe(true) + expect(Array.isArray((result.sheet as CellValue[][])[0])).toBe(true) + }) +}) diff --git a/packages/jsonforms-renderers/src/utils/spreadsheet/process.ts b/packages/jsonforms-renderers/src/utils/spreadsheet/process.ts index cf304dc..926b1c1 100644 --- a/packages/jsonforms-renderers/src/utils/spreadsheet/process.ts +++ b/packages/jsonforms-renderers/src/utils/spreadsheet/process.ts @@ -1,10 +1,96 @@ import { evaluateExpressions } from './expression' -import type { CellValue, DerivationResult, FormulaConfigEntry, SpreadsheetValue } from './types' +import type { CellValue, DerivationResult, FormulaConfigEntry, SheetData, SpreadsheetValue } from './types' -export interface ProcessMatrixOptions { +export interface ShapeSheetOptions { + columnHeader?: boolean + rowHeader?: boolean +} + +export interface ProcessMatrixOptions extends ShapeSheetOptions { persistSheet?: boolean } +// Shapes a raw matrix into what gets persisted as `sheet`. Never touches +// formula evaluation — only ever called when building the PERSISTED value, +// after derivations are already computed against the original, unshaped +// matrix. Both flags true is a schema-authoring error, not a "pick a +// winner" situation — each orientation is independently meaningful in real +// usage, so silently guessing would silently discard the other. +// +// Same format-agnostic contract as processMatrix below (see its own +// comment): this operates purely on the normalized CellValue[][] matrix, +// never on the original file format. A future XML (or any other) upload +// path only needs to produce that same matrix shape — with, if it wants +// records-shaping, a real header row/column at position 0 in it, the same +// convention columnHeader/rowHeader already use for the preview table +// today — and this function works on it completely unchanged. Not adding +// any further pluggability (e.g. an injectable key-extraction strategy) +// ahead of that actually existing: the one real assumption here (keys come +// from row/column 0 of the matrix) is already the existing convention, not +// a new one, and speculatively generalizing further for a format that +// doesn't exist yet isn't worth it until its actual needs are known. +export function shapeSheet(matrix: CellValue[][], options: ShapeSheetOptions = {}): SheetData { + if (options.columnHeader && options.rowHeader) { + throw new Error( + 'x-spreadsheet: columnHeader and rowHeader cannot both be true — pick one orientation for the persisted sheet shape.', + ) + } + if (options.columnHeader) return rowsToRecords(matrix) + if (options.rowHeader) return columnsToRecords(matrix) + return matrix +} + +// columnHeader: row 1 = keys, every row after it = one record. A blank/null +// header cell contributes no key (that column is absent from every record, +// not present under a stringified "null"/""). A data row shorter than the +// header fills missing trailing values with null; a row longer than the +// header silently drops its unheaded trailing cells. A duplicate header +// value collides last-write-wins (mirrors the existing duplicate x-evaluate +// id precedent in processMatrix below). Keys use plain String(), not the +// display-oriented formatCell (SpreadsheetControl.tsx) — formatCell's Date +// handling is locale-dependent, which would make persisted KEYS vary by the +// uploading browser's locale; only display formatting may be locale-aware. +function rowsToRecords(matrix: CellValue[][]): Record[] { + const [headerRow, ...bodyRows] = matrix + if (!headerRow) return [] + const keys = headerRow.map((cell) => (cell == null || cell === '' ? null : String(cell))) + return bodyRows.map((row) => { + const record: Record = {} + keys.forEach((key, i) => { + if (key == null) return + record[key] = row[i] ?? null + }) + return record + }) +} + +// rowHeader: column A = keys (on every row), every OTHER column = one +// record, transposed. Column A is fully consumed as the key source, +// symmetric with how row 1 is fully consumed above. Same blank/duplicate +// handling as rowsToRecords, transposed. +function columnsToRecords(matrix: CellValue[][]): Record[] { + const keys = matrix.map((row) => (row[0] == null || row[0] === '' ? null : String(row[0]))) + const width = Math.max(0, ...matrix.map((row) => row.length)) + const colCount = Math.max(0, width - 1) + return Array.from({ length: colCount }, (_, i) => { + const col = i + 1 + const record: Record = {} + keys.forEach((key, r) => { + if (key == null) return + record[key] = matrix[r][col] ?? null + }) + return record + }) +} + +// Told apart at read time, not via a stored field: a matrix row is itself +// an array; a record is a plain object. Array.isArray(undefined) is false, +// so an empty persisted sheet ([]) reads as "records" — harmless, since +// SpreadsheetControl renders zero rows for an empty sheet either way. +export function isRecordsSheet(sheet: SheetData): sheet is Record[] { + return !Array.isArray(sheet[0]) +} + // Matrix-in, persisted-value-out. Deliberately format-agnostic: doesn't care // whether the matrix came from an xlsx/csv upload or (in the future) an XML // one — this is the reusable seam for both. @@ -25,5 +111,6 @@ export async function processMatrix( for (const { id, label, value, error } of results) { derivations[id] = error === undefined ? { label, value } : { label, value, error } } - return options.persistSheet !== false ? { sheet: matrix, derivations } : { derivations } + if (options.persistSheet === false) return { derivations } + return { sheet: shapeSheet(matrix, options), derivations } } diff --git a/packages/jsonforms-renderers/src/utils/spreadsheet/types.ts b/packages/jsonforms-renderers/src/utils/spreadsheet/types.ts index 1ac947b..c1c4036 100644 --- a/packages/jsonforms-renderers/src/utils/spreadsheet/types.ts +++ b/packages/jsonforms-renderers/src/utils/spreadsheet/types.ts @@ -39,6 +39,13 @@ export interface DerivationResult { error?: string } +// Either shape a persisted `sheet` can take: a raw, address-preserving +// matrix (default), or an array of plain objects shaped by +// utils/spreadsheet/process.ts's shapeSheet when x-spreadsheet.columnHeader +// or .rowHeader is set at upload time. Told apart at read time, not via a +// stored discriminant field — see process.ts's isRecordsSheet. +export type SheetData = CellValue[][] | Record[] + // Persisted value shape for SpreadsheetControl — the field's data is an // object, not a string key. No `fileName`: there's no storage service to // name a retrievable file for. `derivations` is keyed by each x-evaluate @@ -48,6 +55,6 @@ export interface DerivationResult { // anywhere. Shared here (rather than kept private to SpreadsheetControl.tsx) // so sibling renderers, like ComputedControl, can read it with the same type. export interface SpreadsheetValue { - sheet?: CellValue[][] + sheet?: SheetData derivations: Record } From cdacbe2d537e5f952a03f3d171c09a2ec9e40904 Mon Sep 17 00:00:00 2001 From: Thanikan Date: Tue, 1 Sep 2026 07:51:42 +0530 Subject: [PATCH 2/8] feat: render records-shaped sheets, reject columnHeader+rowHeader together MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires columnHeader/rowHeader through to processMatrix so uploads actually get shaped, and adds a second, small rendering branch for when a reload reads back an already-records-shaped persisted value (a fresh upload this session always renders via the existing matrix branch, since the raw parsed matrix kept in local state is never itself shaped — only the persisted value is). Also adds an early, friendly config-validation error when both flags are set — matches shapeSheet's own thrown Error, but as an inline message instead of a crash, consistent with this control's existing red-text error conventions. Co-Authored-By: Claude Sonnet 5 --- .../src/renderers/SpreadsheetControl.tsx | 101 ++++++++++++++++-- 1 file changed, 93 insertions(+), 8 deletions(-) diff --git a/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx b/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx index b7acdc5..806ac8d 100644 --- a/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx @@ -11,6 +11,7 @@ import { parseWorkbookToMatrix, columnLetter, processMatrix, + isRecordsSheet, SheetParseError, type CellValue, type FormulaConfigEntry, @@ -24,9 +25,19 @@ interface XSpreadsheetOptions { maxSize?: number /** Include the parsed sheet in the persisted value, so it survives a reload. Default true. */ persistSheet?: boolean - /** Use row 1's values as column labels instead of A/B/C. Default false. */ + /** + * Use row 1's values as column labels in the preview, AND persist `sheet` + * as one record per data row, keyed by those labels, instead of a raw + * matrix. Default false. Cannot be combined with rowHeader — see + * docs/spreadsheet-value-shape.md. + */ columnHeader?: boolean - /** Use column A's values as row labels instead of 1/2/3. Default false. */ + /** + * Use column A's values as row labels in the preview, AND (only when + * columnHeader is not also set) persist `sheet` as one record per OTHER + * column, transposed, keyed by those labels. Default false. Cannot be + * combined with columnHeader — see docs/spreadsheet-value-shape.md. + */ rowHeader?: boolean /** Render the grid preview at all. Default true — set false to show only computed values. */ showSheet?: boolean @@ -101,9 +112,18 @@ const SpreadsheetControl = ({ // The grid always renders from localMatrix first — so even with // persistSheet: false, a fresh upload shows immediately this session, even - // though it won't survive a reload. - const matrix = localMatrix ?? value?.sheet ?? null + // though it won't survive a reload. localMatrix (this session's own + // freshly parsed upload) is always matrix-shaped — shaping only happens + // when building the PERSISTED value — so a fresh upload always renders via + // the matrix branch this session; only a reload (no localMatrix, reading + // the already-shaped persisted value back) can render via the records + // branch below. Told apart via isRecordsSheet, not a stored field. + const persistedSheet = value?.sheet ?? null + const matrix = localMatrix ?? (persistedSheet && !isRecordsSheet(persistedSheet) ? persistedSheet : null) + const records = localMatrix == null && persistedSheet && isRecordsSheet(persistedSheet) ? persistedSheet : null const derivations = value?.derivations ?? {} + // records != null always implies value != null (records is derived only + // from value?.sheet), so this already covers the records case too. const hasValue = matrix != null || value != null const processFile = useCallback( @@ -144,17 +164,34 @@ const SpreadsheetControl = ({ } setLocalMatrix(parsedMatrix) - const value = await processMatrix(parsedMatrix, xEvaluate, { persistSheet }) + const value = await processMatrix(parsedMatrix, xEvaluate, { persistSheet, columnHeader, rowHeader }) handleChange(path, value) setStatus('ready') }, - [accept, maxSize, persistSheet, sheetName, xEvaluate, path, handleChange], + [accept, maxSize, persistSheet, columnHeader, rowHeader, sheetName, xEvaluate, path, handleChange], ) if (visible === false) { return null } + // columnHeader and rowHeader are independently meaningful for the persisted + // records shape (see shapeSheet in utils/spreadsheet/process.ts) — neither + // one is a safe default when both are set, so this is rejected outright + // rather than silently picking a winner. + if (columnHeader && rowHeader) { + return ( + + + {label} + + + Invalid x-spreadsheet config: columnHeader and rowHeader cannot both be true — pick one orientation. + + + ) + } + if (!canEdit && !hasValue) return null const handleDrag = (e: DragEvent) => { @@ -199,8 +236,21 @@ const SpreadsheetControl = ({ Math.max(0, ...visibleRows.map((row) => Math.max(0, row.length - colOffset))), ) const colIndices = Array.from({ length: colCount }, (_, i) => i + colOffset) - const showStoredNote = showSheet && matrix == null && value != null - const cornerLabel = columnHeader && rowHeader ? formatCell(matrix?.[0]?.[0]) : '' + const showStoredNote = showSheet && matrix == null && records == null && value != null + // columnHeader && rowHeader together is rejected above, so there's never a + // meaningful corner cell to show here — the header row/column consumes it. + const cornerLabel = '' + + // records-mode preview: no row/col "offset" concept (shapeSheet already + // consumed the header row/column when building each record) — just the + // same MAX_PREVIEW_ROWS/COLS truncation the matrix branch above uses. + // Headers are the union of all VISIBLE records' own keys, not just the + // first record's — every record shapeSheet itself produces has an + // identical key set, so this only matters for a hand-supplied `data` + // value with non-uniform records (sheet crosses a serialization boundary + // — it isn't guaranteed to have been produced by shapeSheet). + const visibleRecords = records ? records.slice(0, MAX_PREVIEW_ROWS) : [] + const recordHeaders = Array.from(new Set(visibleRecords.flatMap((r) => Object.keys(r)))).slice(0, MAX_PREVIEW_COLS) return ( @@ -355,6 +405,41 @@ const SpreadsheetControl = ({ )} )} + + {/* ── Records-shaped sheet preview — reload of an already-persisted, + columnHeader/rowHeader-shaped value; mutually exclusive with the + matrix preview above ── */} + {records && showSheet && ( + + + + + + + {recordHeaders.map((key) => ( + {key} + ))} + + + + {visibleRecords.map((record, r) => ( + + {r + 1} + {recordHeaders.map((key) => ( + {formatCell(record[key])} + ))} + + ))} + + + + {records.length > MAX_PREVIEW_ROWS && ( + + Showing first {MAX_PREVIEW_ROWS} of {records.length} rows + + )} + + )} {showStoredNote && ( Sheet preview not stored for this field — re-upload to view contents. From 51be6f7a3d4ad38a3aa74fa76a03a4ef2d75d98a Mon Sep 17 00:00:00 2001 From: Thanikan Date: Tue, 1 Sep 2026 07:51:49 +0530 Subject: [PATCH 3/8] docs: document the two persisted sheet shapes Neither doc previously documented SpreadsheetValue's actual persisted JSON shape at all. New doc scoped to just that question: matrix vs records, when each applies, the both-flags-true restriction and why, duplicate-key handling, and the Array.isArray detection convention. Co-Authored-By: Claude Sonnet 5 --- .../docs/spreadsheet-formulas.md | 2 +- .../docs/spreadsheet-value-shape.md | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 packages/jsonforms-renderers/docs/spreadsheet-value-shape.md diff --git a/packages/jsonforms-renderers/docs/spreadsheet-formulas.md b/packages/jsonforms-renderers/docs/spreadsheet-formulas.md index 54e0ceb..4dd60ba 100644 --- a/packages/jsonforms-renderers/docs/spreadsheet-formulas.md +++ b/packages/jsonforms-renderers/docs/spreadsheet-formulas.md @@ -23,7 +23,7 @@ Each entry's `id` is mandatory and doubles as the key `SpreadsheetControl` persi Any schema describing this field's shape (see the `spreadsheet` fixture in `dev/fixtures.ts` for a full example) must declare `derivations` as `type: "object"`, not `"array"` — otherwise a successful, correctly-computed write fails AJV validation. -Cell and range addressing is literal (`B2`, `I2:I6`), the same as in Excel — row 1 is always the sheet's first row, column A is always the sheet's first column, regardless of the control's `columnHeader`/`rowHeader` display options. +Cell and range addressing is literal (`B2`, `I2:I6`), the same as in Excel — row 1 is always the sheet's first row, column A is always the sheet's first column, regardless of the control's `columnHeader`/`rowHeader` display options. Those same two options also control what shape the persisted `sheet` itself ends up in (raw matrix vs. an array of records) — see [spreadsheet-value-shape.md](./spreadsheet-value-shape.md). Formulas are parsed and evaluated by [`fast-formula-parser`](https://www.npmjs.com/package/fast-formula-parser), backfilled with [`@formulajs/formulajs`](https://www.npmjs.com/package/formulajs) for functions the former only stubs out or gets wrong for our data (both MIT licensed). diff --git a/packages/jsonforms-renderers/docs/spreadsheet-value-shape.md b/packages/jsonforms-renderers/docs/spreadsheet-value-shape.md new file mode 100644 index 0000000..1bbf326 --- /dev/null +++ b/packages/jsonforms-renderers/docs/spreadsheet-value-shape.md @@ -0,0 +1,36 @@ +# Persisted sheet shape (`sheet`: matrix vs records) + +`SpreadsheetControl` persists `{ sheet, derivations }` (see [spreadsheet-formulas.md](./spreadsheet-formulas.md) for `derivations`/formula details, and [computed-fields.md](./computed-fields.md) for how a sibling field addresses one). `sheet` itself can take one of two shapes, driven entirely by the existing `x-spreadsheet.columnHeader`/`rowHeader` options — no separate switch: + +```json +"x-spreadsheet": { "columnHeader": true } +``` + +- **Neither flag set (default):** `sheet` is the raw, address-preserving matrix — an array of rows, each a plain `CellValue[]`. Unchanged from before this shape existed. +- **`columnHeader: true`:** row 1 is treated as each column's field name; every row after it becomes one record. For a header of `Item, Qty`, uploading `Widget, 10` / `Gadget, 20` persists: + ```json + [ + { "Item": "Widget", "Qty": 10 }, + { "Item": "Gadget", "Qty": 20 } + ] + ``` +- **`rowHeader: true`** (and `columnHeader` not also set): transposed — column A is treated as each row's field name; every _other_ column becomes one record. For a sheet with `Metric` down column A (`Revenue`, `Cost`) and `Q1`/`Q2` across row 1, this persists one record per quarter: + ```json + [ + { "Metric": "Q1", "Revenue": 100, "Cost": 40 }, + { "Metric": "Q2", "Revenue": 120, "Cost": 55 } + ] + ``` +- **Both set is rejected outright** — `SpreadsheetControl` shows a configuration-error message instead of rendering, and the underlying `shapeSheet` helper throws if called directly. Each orientation is independently meaningful in real usage, so there's no safe default to silently pick; a schema author must choose exactly one. (`columnHeader`/`rowHeader` still work together as before for the _preview table's_ corner-label display — this restriction is specific to the persisted shape.) + +**`x-evaluate` formula addressing is completely unaffected.** `B2`, `SUM(I2:I6)`, etc. always address the original, unshaped matrix — shaping only happens afterward, when building the persisted value. + +## Detecting which shape you have + +There's no stored discriminant field. Told apart at read time the same way `SpreadsheetControl` itself does — `Array.isArray(sheet[0])`: `true` for a matrix (each row is itself an array), `false` for records (each entry is a plain object). Exported as `isRecordsSheet` from `utils/spreadsheet`. An empty persisted sheet (`[]`) reads as records under this check — harmless, since there's nothing to render either way. + +## Duplicate keys + +A duplicate header value (`columnHeader`) or duplicate column-A value (`rowHeader`) collides last-write-wins in every affected record — the same convention already used for a duplicate `x-evaluate` id colliding in the `derivations` map. Not flagged as an error either place; ids/headers are schema-author-controlled. + +A blank/null header (or column-A) cell contributes no key at all, rather than a stringified `"null"`/`""` — that column (or row, for `rowHeader`) is simply absent from every record. From e63540c97675c7ab0e9368ef59475995995453d4 Mon Sep 17 00:00:00 2001 From: Thanikan Date: Tue, 1 Sep 2026 07:51:57 +0530 Subject: [PATCH 4/8] chore(dev): add a rowHeader-transposed spreadsheet fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No existing fixture reached the rowHeader-alone shaping path (the other one with both flags set now hits the columnHeader+rowHeader error instead) — new 'Spreadsheet (rowHeader records)' fixture with a generic, industry-agnostic "wide" quarterly-metrics sample exercises it. Also updates 'Computed Control (with Spreadsheet)', which previously set both flags true for cosmetic corner-labeling, to columnHeader only (its sales-data rows are the natural records) — noted in its description since sales_data.sheet's persisted shape changes as a result. Co-Authored-By: Claude Sonnet 5 --- packages/jsonforms-renderers/dev/fixtures.ts | 37 +++++++++++++++++- .../generate-quarterly-metrics-sample.cjs | 25 ++++++++++++ .../quarterly-metrics-sample.xlsx | Bin 0 -> 16327 bytes 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 packages/jsonforms-renderers/dev/sample-files/generate-quarterly-metrics-sample.cjs create mode 100644 packages/jsonforms-renderers/dev/sample-files/quarterly-metrics-sample.xlsx diff --git a/packages/jsonforms-renderers/dev/fixtures.ts b/packages/jsonforms-renderers/dev/fixtures.ts index 2662f23..ae15e42 100644 --- a/packages/jsonforms-renderers/dev/fixtures.ts +++ b/packages/jsonforms-renderers/dev/fixtures.ts @@ -432,13 +432,13 @@ export const fixtures: Fixture[] = [ type: 'object', title: 'Sales Data', description: - 'Upload dev/sample-files/sales-data-sample.xlsx (regenerate via generate-sales-data-sample.cjs).', + "Upload dev/sample-files/sales-data-sample.xlsx (regenerate via generate-sales-data-sample.cjs). Since columnHeader is true, sales_data.sheet persists as one record per row (keyed by row 1's headers), not a raw matrix — see docs/spreadsheet-value-shape.md.", 'x-spreadsheet': { accept: '.xlsx,.xls,.csv', maxSize: 10485760, persistSheet: true, columnHeader: true, - rowHeader: true, + rowHeader: false, }, 'x-evaluate': [{ id: 'total_quantity', label: 'Total Quantity', expression: '=SUM(D2:D4)' }], properties: { @@ -487,6 +487,39 @@ export const fixtures: Fixture[] = [ ], } as UISchemaElement, }, + { + id: 'spreadsheet-row-header', + name: 'Spreadsheet (rowHeader records)', + schema: { + type: 'object', + properties: { + quarterly_metrics: { + type: 'object', + title: 'Quarterly Metrics', + description: + "Upload dev/sample-files/quarterly-metrics-sample.xlsx (regenerate via generate-quarterly-metrics-sample.cjs) — column A holds each metric's name (row header), columns B-D hold one quarter each. With rowHeader: true and columnHeader: false, the persisted sheet is the TRANSPOSED records shape: each quarter becomes one record, keyed by column A's metric names — see docs/spreadsheet-value-shape.md. Contrast with 'Spreadsheet' and 'Computed Control (with Spreadsheet)', which both use columnHeader and persist one record per row instead.", + 'x-spreadsheet': { + accept: '.xlsx,.xls,.csv', + maxSize: 10485760, + persistSheet: true, + columnHeader: false, + rowHeader: true, + }, + 'x-evaluate': [ + { id: 'total_units_sold', label: 'Total Units Sold (all quarters)', expression: '=SUM(B2:D2)' }, + ], + properties: { + sheet: { type: 'array' }, + derivations: { type: 'object' }, + }, + }, + }, + } as unknown as JsonSchema, + uischema: { + type: 'VerticalLayout', + elements: [{ type: 'Control', scope: '#/properties/quarterly_metrics' }], + } as UISchemaElement, + }, { id: 'array', name: 'Array (objects)', diff --git a/packages/jsonforms-renderers/dev/sample-files/generate-quarterly-metrics-sample.cjs b/packages/jsonforms-renderers/dev/sample-files/generate-quarterly-metrics-sample.cjs new file mode 100644 index 0000000..07a4437 --- /dev/null +++ b/packages/jsonforms-renderers/dev/sample-files/generate-quarterly-metrics-sample.cjs @@ -0,0 +1,25 @@ +// Regenerates quarterly-metrics-sample.xlsx — a small, generic +// (industry-agnostic) "wide" metrics table used to manually test +// SpreadsheetControl's rowHeader: true (transposed records) shaping in the +// dev playground (see the 'spreadsheet-row-header' fixture in +// dev/fixtures.ts). Column A holds each metric's name; every OTHER column +// (one per quarter) becomes one persisted record when shaped. Run with: +// node dev/sample-files/generate-quarterly-metrics-sample.cjs +const { utils, writeFile } = require('@e965/xlsx') +const path = require('node:path') + +// x-evaluate: total_units_sold = SUM(B2:D2) -> 120 + 150 + 200 = 470 +const rows = [ + ['Metric', 'Q1', 'Q2', 'Q3'], + ['Units Sold', 120, 150, 200], + ['Returns', 5, 8, 6], + ['Net Units', 115, 142, 194], +] + +const worksheet = utils.aoa_to_sheet(rows) +const workbook = utils.book_new() +utils.book_append_sheet(workbook, worksheet, 'Quarterly Metrics') + +const outPath = path.join(__dirname, 'quarterly-metrics-sample.xlsx') +writeFile(workbook, outPath) +console.log(`Wrote ${outPath}`) diff --git a/packages/jsonforms-renderers/dev/sample-files/quarterly-metrics-sample.xlsx b/packages/jsonforms-renderers/dev/sample-files/quarterly-metrics-sample.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..fb0e5cc8390b0db950878ecaf23cc8c63d2ece45 GIT binary patch literal 16327 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 RA0ePK`1cfyVzEX~{{}~m2|EA) literal 0 HcmV?d00001 From e1a82623c9a932fddf4b19683c39b8da631764f9 Mon Sep 17 00:00:00 2001 From: Thanikan Date: Tue, 1 Sep 2026 11:59:47 +0530 Subject: [PATCH 5/8] fix: clean up the preview table when only rowHeader is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes to the matrix preview once exactly one of columnHeader/rowHeader is set: - Suppress the OTHER axis's positional fallback label (A/B/C column letters when only rowHeader is set, 1/2/3 row numbers when only columnHeader is set) — noise now that that axis is a record key rather than plain data. Unaffected when neither flag is set. - When rowHeader alone is set, the entire column-header row would now be blank (every cell suppressed per the above) — drop it entirely instead of rendering a dead row, and give the row-label column a bold/tinted look so it still reads clearly as labels rather than data. Both-true is already rejected earlier in the component, so that combination never reaches this code. --- .../src/renderers/SpreadsheetControl.tsx | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx b/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx index 806ac8d..50852fc 100644 --- a/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx @@ -240,6 +240,10 @@ const SpreadsheetControl = ({ // columnHeader && rowHeader together is rejected above, so there's never a // meaningful corner cell to show here — the header row/column consumes it. const cornerLabel = '' + // rowHeader alone means every cell in the column-header row below is blank + // (see its own comment) — the whole row would just be dead space, so skip + // it entirely and lean on the row-label column's distinct styling instead. + const suppressColumnHeaderRow = rowHeader && !columnHeader // records-mode preview: no row/col "offset" concept (shapeSheet already // consumed the header row/column when building each record) — just the @@ -371,23 +375,36 @@ const SpreadsheetControl = ({ - - - {cornerLabel} - {colIndices.map((c) => ( - - {columnHeader ? formatCell(matrix[0]?.[c]) : columnLetter(c)} - - ))} - - + {!suppressColumnHeaderRow && ( + + + {cornerLabel} + {colIndices.map((c) => ( + // This row only renders when rowHeader isn't the sole + // flag set (see suppressColumnHeaderRow) — so reaching + // here, either columnHeader is true (real labels) or + // both are false (columnLetter fallback). + + {columnHeader ? formatCell(matrix[0]?.[c]) : columnLetter(c)} + + ))} + + + )} {visibleRows.map((row, r) => { const actualRow = rowOffset + r return ( - - {rowHeader ? formatCell(matrix[actualRow]?.[0]) : actualRow + 1} + + {/* columnHeader alone means these are shaped into + records keyed by row 1 — a 1/2/3 fallback number + here isn't a real label, so it's suppressed (the + column-header row above stays, since it's real + labels there, not suppressed). */} + {rowHeader ? formatCell(matrix[actualRow]?.[0]) : columnHeader ? '' : actualRow + 1} {colIndices.map((c) => ( {formatCell(row[c])} From f3ff5ce2c0f48c571cff7831fcf37a760a030fdb Mon Sep 17 00:00:00 2001 From: Thanikan Date: Sun, 6 Sep 2026 23:32:10 +0530 Subject: [PATCH 6/8] fix: make record keys safe against __proto__ and Date locale drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same bug as processMatrix's derivations accumulator (#32), but worse here since these keys come from the uploaded file itself, not a schema-author- controlled x-evaluate id: rowsToRecords/columnsToRecords built each record as a plain {}, so a header (or column-A) cell of "__proto__" silently changed the record's prototype instead of becoming a real key. Both now use Object.create(null), matching the existing fix. Also: keys were built via String(cell), and the comment claimed this was locale-independent — wrong for Date specifically, since String(date)/date.toString() renders in the local time zone. A Date header cell now keys via date.toISOString() (fixed, UTC) instead. --- .../utils/spreadsheet/process.records.test.ts | 42 +++++++++++++++++++ .../src/utils/spreadsheet/process.ts | 32 ++++++++++---- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/packages/jsonforms-renderers/src/utils/spreadsheet/process.records.test.ts b/packages/jsonforms-renderers/src/utils/spreadsheet/process.records.test.ts index 9bd27f9..589b6b0 100644 --- a/packages/jsonforms-renderers/src/utils/spreadsheet/process.records.test.ts +++ b/packages/jsonforms-renderers/src/utils/spreadsheet/process.records.test.ts @@ -71,6 +71,27 @@ describe('shapeSheet', () => { ] expect(shapeSheet(matrix, { columnHeader: true })).toEqual([{ '100': 'x', true: 'y' }]) }) + + it('a header value of "__proto__" is a real, enumerable own key, not a prototype override', () => { + const matrix: CellValue[][] = [ + ['__proto__', 'B'], + ['x', 'y'], + ] + const [record] = shapeSheet(matrix, { columnHeader: true }) as Record[] + expect(Object.prototype.hasOwnProperty.call(record, '__proto__')).toBe(true) + expect(Object.keys(record)).toEqual(['__proto__', 'B']) + expect(JSON.parse(JSON.stringify(record))).toEqual({ ['__proto__']: 'x', B: 'y' }) + }) + + it('keys a Date header cell by its fixed ISO instant, not the locale-dependent String(date)', () => { + const date = new Date(Date.UTC(2026, 0, 15, 12, 30)) + const matrix: CellValue[][] = [ + [date, 'B'], + ['x', 'y'], + ] + const [record] = shapeSheet(matrix, { columnHeader: true }) as Record[] + expect(Object.keys(record)).toEqual([date.toISOString(), 'B']) + }) }) describe('rowHeader (columnHeader not set)', () => { @@ -123,6 +144,27 @@ describe('shapeSheet', () => { ] expect(shapeSheet(matrix, { rowHeader: true })).toEqual([{ Metric: 'Q1', Revenue: 200 }]) }) + + it('a column-A value of "__proto__" is a real, enumerable own key, not a prototype override', () => { + const matrix: CellValue[][] = [ + ['__proto__', 'Q1'], + ['Revenue', 100], + ] + const [record] = shapeSheet(matrix, { rowHeader: true }) as Record[] + expect(Object.prototype.hasOwnProperty.call(record, '__proto__')).toBe(true) + expect(Object.keys(record)).toEqual(['__proto__', 'Revenue']) + expect(JSON.parse(JSON.stringify(record))).toEqual({ ['__proto__']: 'Q1', Revenue: 100 }) + }) + + it('keys a Date column-A cell by its fixed ISO instant, not the locale-dependent String(date)', () => { + const date = new Date(Date.UTC(2026, 0, 15, 12, 30)) + const matrix: CellValue[][] = [ + [date, 'Q1'], + ['Revenue', 100], + ] + const [record] = shapeSheet(matrix, { rowHeader: true }) as Record[] + expect(Object.keys(record)).toEqual([date.toISOString(), 'Revenue']) + }) }) it('throws when both columnHeader and rowHeader are true, rather than picking a winner', () => { diff --git a/packages/jsonforms-renderers/src/utils/spreadsheet/process.ts b/packages/jsonforms-renderers/src/utils/spreadsheet/process.ts index 926b1c1..aa277f3 100644 --- a/packages/jsonforms-renderers/src/utils/spreadsheet/process.ts +++ b/packages/jsonforms-renderers/src/utils/spreadsheet/process.ts @@ -40,22 +40,38 @@ export function shapeSheet(matrix: CellValue[][], options: ShapeSheetOptions = { return matrix } +// Fixed, timezone-independent stringification for a header/column-A cell. +// String(date)/date.toString() renders in the LOCAL time zone, which would +// make the persisted record's KEYS vary depending on which time zone the +// uploading browser is in; date.toISOString() (fixed, UTC) doesn't. Only +// display formatting (formatCell, SpreadsheetControl.tsx) is allowed to be +// locale-aware — persisted keys must be deterministic. +function cellKey(cell: CellValue): string | null { + if (cell == null || cell === '') return null + if (cell instanceof Date) return cell.toISOString() + return String(cell) +} + // columnHeader: row 1 = keys, every row after it = one record. A blank/null // header cell contributes no key (that column is absent from every record, // not present under a stringified "null"/""). A data row shorter than the // header fills missing trailing values with null; a row longer than the // header silently drops its unheaded trailing cells. A duplicate header // value collides last-write-wins (mirrors the existing duplicate x-evaluate -// id precedent in processMatrix below). Keys use plain String(), not the -// display-oriented formatCell (SpreadsheetControl.tsx) — formatCell's Date -// handling is locale-dependent, which would make persisted KEYS vary by the -// uploading browser's locale; only display formatting may be locale-aware. +// id precedent in processMatrix below). function rowsToRecords(matrix: CellValue[][]): Record[] { const [headerRow, ...bodyRows] = matrix if (!headerRow) return [] - const keys = headerRow.map((cell) => (cell == null || cell === '' ? null : String(cell))) + const keys = headerRow.map(cellKey) return bodyRows.map((row) => { - const record: Record = {} + // Object.create(null), not {} — a header cell of "__proto__" would + // otherwise set the record's prototype instead of creating an + // enumerable own property, silently dropping that column from + // Object.keys/entries and JSON serialization. Same fix already applied + // to processMatrix's derivations accumulator below (#32) — worse here + // since these keys come from the uploaded file, not a schema-author- + // controlled x-evaluate id. + const record: Record = Object.create(null) keys.forEach((key, i) => { if (key == null) return record[key] = row[i] ?? null @@ -69,12 +85,12 @@ function rowsToRecords(matrix: CellValue[][]): Record[] { // symmetric with how row 1 is fully consumed above. Same blank/duplicate // handling as rowsToRecords, transposed. function columnsToRecords(matrix: CellValue[][]): Record[] { - const keys = matrix.map((row) => (row[0] == null || row[0] === '' ? null : String(row[0]))) + const keys = matrix.map((row) => cellKey(row[0])) const width = Math.max(0, ...matrix.map((row) => row.length)) const colCount = Math.max(0, width - 1) return Array.from({ length: colCount }, (_, i) => { const col = i + 1 - const record: Record = {} + const record: Record = Object.create(null) keys.forEach((key, r) => { if (key == null) return record[key] = matrix[r][col] ?? null From fd98dc2b511ded75443a523636907535a14b584a Mon Sep 17 00:00:00 2001 From: Thanikan Date: Sun, 6 Sep 2026 23:32:21 +0530 Subject: [PATCH 7/8] fix: catch a processMatrix failure the same way a parse failure is caught MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processFile had a try/catch around parseWorkbookToMatrix but not around the processMatrix call after it — a throw there (e.g. the columnHeader + rowHeader validation error this same PR added) left status stuck on 'parsing' forever, with localMatrix already set from the successful parse. --- .../src/renderers/SpreadsheetControl.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx b/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx index 50852fc..68aa623 100644 --- a/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/SpreadsheetControl.tsx @@ -164,7 +164,15 @@ const SpreadsheetControl = ({ } setLocalMatrix(parsedMatrix) - const value = await processMatrix(parsedMatrix, xEvaluate, { persistSheet, columnHeader, rowHeader }) + + let value: SpreadsheetValue + try { + value = await processMatrix(parsedMatrix, xEvaluate, { persistSheet, columnHeader, rowHeader }) + } catch (err) { + setStatus('error') + setError(err instanceof Error ? err.message : 'Failed to process the uploaded spreadsheet.') + return + } handleChange(path, value) setStatus('ready') }, From 22598e257ba20d953fec62d57570a81f8f697fbb Mon Sep 17 00:00:00 2001 From: Thanikan Date: Sun, 6 Sep 2026 23:32:30 +0530 Subject: [PATCH 8/8] docs: correct the both-flags-true note, document key-safety handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "both set is rejected outright" bullet still claimed columnHeader/ rowHeader work together for the preview table's corner label — no longer true since both-true now rejects before rendering anything at all. Also documents the __proto__-safety and Date-key normalization just fixed, alongside the existing "Duplicate keys" section's edge-case notes. --- .../jsonforms-renderers/docs/spreadsheet-value-shape.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/jsonforms-renderers/docs/spreadsheet-value-shape.md b/packages/jsonforms-renderers/docs/spreadsheet-value-shape.md index 1bbf326..21b23ad 100644 --- a/packages/jsonforms-renderers/docs/spreadsheet-value-shape.md +++ b/packages/jsonforms-renderers/docs/spreadsheet-value-shape.md @@ -21,7 +21,7 @@ { "Metric": "Q2", "Revenue": 120, "Cost": 55 } ] ``` -- **Both set is rejected outright** — `SpreadsheetControl` shows a configuration-error message instead of rendering, and the underlying `shapeSheet` helper throws if called directly. Each orientation is independently meaningful in real usage, so there's no safe default to silently pick; a schema author must choose exactly one. (`columnHeader`/`rowHeader` still work together as before for the _preview table's_ corner-label display — this restriction is specific to the persisted shape.) +- **Both set is rejected outright** — `SpreadsheetControl` shows a configuration-error message instead of rendering anything (no preview, no corner label), and the underlying `shapeSheet` helper throws if called directly. Each orientation is independently meaningful in real usage, so there's no safe default to silently pick; a schema author must choose exactly one. **`x-evaluate` formula addressing is completely unaffected.** `B2`, `SUM(I2:I6)`, etc. always address the original, unshaped matrix — shaping only happens afterward, when building the persisted value. @@ -34,3 +34,10 @@ There's no stored discriminant field. Told apart at read time the same way `Spre A duplicate header value (`columnHeader`) or duplicate column-A value (`rowHeader`) collides last-write-wins in every affected record — the same convention already used for a duplicate `x-evaluate` id colliding in the `derivations` map. Not flagged as an error either place; ids/headers are schema-author-controlled. A blank/null header (or column-A) cell contributes no key at all, rather than a stringified `"null"`/`""` — that column (or row, for `rowHeader`) is simply absent from every record. + +## Special keys + +Unlike a schema-author-controlled `x-evaluate` id, a header/column-A value comes from whatever's in the uploaded file, so two edge cases get handled explicitly rather than assumed away: + +- **A header value of `"__proto__"` is a safe, real key.** Each record is built with `Object.create(null)`, not `{}` — a plain object's inherited `__proto__` setter would otherwise intercept the write and change the record's _prototype_ instead of creating an enumerable own property, silently dropping that column from `Object.keys`/`Object.entries` and from JSON serialization (the same fix already applied to `processMatrix`'s `derivations` accumulator). +- **A `Date` header/column-A value keys deterministically.** Keys are built via `date.toISOString()` for `Date` cells, not `String(date)` — the latter renders in the _local_ time zone, which would make the same uploaded file produce different record keys depending on which time zone the uploading browser is in.