fix(react): the clipboard carries only real data columns - #495
Merged
Conversation
Pasting from Excel into a grouped grid lost the user's first column. The synthetic `__pretable_group__` column held a paste slot, so N values for N visible columns tiled across N+1 targets: the first landed in a column nothing can be written to, and every other value shifted one right. The clipboard is a spreadsheet interchange format, not an internal round-trip format. Both sides now filter the same predicate (`isSyntheticColumnId`), and a group row puts its label in the leftmost column of the copied range with aggregates in their own — the shape Excel's Subtotal and Sheets' pivot output produce. Both sides together, deliberately: #485 measured that removing paste's slot alone silently blanks column `a` on a copy-then-paste and shifts the rest right. `paste-map.test.ts` guards the coupling and its range is now bounded on the row-select column, so it catches a one-sided change to either emitter. Also re-anchors a range bound or paste anchor that lands on the group column to the first data column, in copy, paste, and the surface's anchor resolver — without it a whole-row grouped selection measured one column wider than anything could be written to and announced a phantom clip. Ungrouped output is byte-identical: proven against the pre-change modules over 800 copy bound/row combinations, the CSV option matrix, and the paste anchor/shape matrix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
blove
enabled auto-merge (squash)
August 27, 2026 00:36
Contributor
Vercel preview readyPreview: https://pretable-2quvkaogz-cacheplane.vercel.app Updated automatically by the |
The synthetic group column no longer occupies a clipboard field, so a grouped copy is N fields wide, not N+1. Data rows lose the empty lead; group rows keep their label, now in the leftmost real column. These pinned the pre-change shape and are the CI half of the same both-sides change — the website Playwright smoke suite is not part of `pnpm test`, so it passed locally and failed against the preview.
blove
added a commit
that referenced
this pull request
Aug 27, 2026
…carries summarizeSelection filtered only the row-select column, so while grouped it counted the derived group column too — the sidebar claimed one more column than Cmd+C actually copies. Same defect the library seams had, and it is directly in the Excel-interop story: 'N x 3 selected' next to a two-column paste. Two tests pinned the over-count and are corrected, not weakened: - selection.test.ts asserted cols: 3 and its comment claimed the group column 'is inside the rectangle Cmd+C copies'. Rows and columns are not symmetric: group ROWS carry labels and aggregates and do copy; the group COLUMN is presentation and was dropped from copy/CSV/paste in #495. - grouping.spec.ts derived its expectation as headerCells - 1, subtracting only the selector. It now counts headers carrying a data-pretable-column-id and excludes the group column, so it still fails if a hero column is added. Negative control: reverting the filter fails selection.test.ts.
blove
added a commit
that referenced
this pull request
Aug 27, 2026
… said it was (#497) * chore: stop a failing package hiding the rest of the suite, and strike a done roadmap line pnpm -r bails on the first failing package by default, so one timeout in an early package silently skipped every package after it — a full run once never reached core, renderer-dom, react or the apps at all, while the failure read as a single known flake. --no-bail runs them all and STILL exits 1 (measured on pnpm 10.12.1; pnpm's own docs claim otherwise). Left on the build step, where a failure should stop everything. Residual: the three phases are still chained with &&, so a package-test failure keeps the apps from running. Fixing that needs exit-code collection, not a flag. Roadmap: "public API/documentation gaps + stable row identity" is done. All four .api.md reports are at zero ae-forgotten-export; getRowId is required at every entry point with its index parameter removed (#293); and four fail-closed guards cover API surface (#290), docs tables (#280), engine reconciliation (#266) and facade forwarding (#301). * fix(react): the group column is not a data column, and the seams that said it was Grouping's boundaries with pinning, editing and streaming had no tests at all, which is where all three prior grouping correctness rounds came from (#259, #264, #495). Probing those three seams found two live defects, both the #495 shape: a consumer-facing "data column" set that filters only the row-select checkbox instead of `isSyntheticColumnId`. Ungrouped the two predicates are the same test, so each site looked correct for as long as nothing was grouped. 1. Announced column counts described a grid one column wider than the artifact. Grouped, Cmd+A then Cmd+C put 2 columns on the clipboard and announced 3; `exportCsv` wrote a 2-column file and announced 3. `copy.ts`, `csv.ts` and `paste.ts` all drop both synthetics, so the announcement — a statement ABOUT that clipboard or that file — has to drop them too. `computeSelectionExtent` now resolves spans in DATA ordinals with a group-column bound collapsing to data column 0, byte-for-byte `resolveRangeBounds`. 2. `onSelectedRowIdChange` went silent while grouped. A range over every real data column is a full row, but `singleFullRowSelection` was handed the drawn list, whose first entry is the synthetic group column — so the range never matched and a consumer's "open the selected record" wiring stopped firing the moment the user grouped. Tests: grouping boundaries added to the three suites that had none — right-pin (drawn order, pinned insets, grouping BY a right-pinned column, `groupColumn: {pinned:"left"}`, group-row alignment, drawn `aria-colcount`), editing (commit inside a group, group rows refuse an editor, an edit cancels when grouping hides its column or collapses its row, a commit that re-paths into a collapsed group settles), and streaming (selection/focus survive an in-place update and a key re-path, expansion and aggregates track, a removed focused row clears focus, an in-flight editor survives a re-path). Every one runs a grouped leg against an ungrouped control. Editing and streaming were probed and found CORRECT — those tests pin behaviour rather than fix it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(website): the hero's selection summary counts what the clipboard carries summarizeSelection filtered only the row-select column, so while grouped it counted the derived group column too — the sidebar claimed one more column than Cmd+C actually copies. Same defect the library seams had, and it is directly in the Excel-interop story: 'N x 3 selected' next to a two-column paste. Two tests pinned the over-count and are corrected, not weakened: - selection.test.ts asserted cols: 3 and its comment claimed the group column 'is inside the rectangle Cmd+C copies'. Rows and columns are not symmetric: group ROWS carry labels and aggregates and do copy; the group COLUMN is presentation and was dropped from copy/CSV/paste in #495. - grouping.spec.ts derived its expectation as headerCells - 1, subtracting only the selector. It now counts headers carrying a data-pretable-column-id and excludes the group column, so it still fails if a hero column is added. Negative control: reverting the filter fails selection.test.ts. * test(react): poll the header assertion that races setQuery's settle external-filter-authority polled for the rows to settle, then asserted the header state bare. setQuery settles asynchronously across cooperative slices (#321), so the rows can be drawn a slice before the header reports — under load the bare assertion loses that race and reads ariaSort 'none' / filterActive 'false'. Not caused by this branch: three full react runs on it produced three different outcomes (eviction-population-change, then this, then 1457 green), which is the signature of a load race rather than a regression. Both suspects pass 3/3 in isolation. Surfaced now because --no-bail (this branch) stops a failing package hiding the ones after it, so more of the suite actually runs. * test(website): drive the stubbed clock inside the wait it gates HeroGrid's replay test stubs requestAnimationFrame, so the replay only advances when a frame is flushed by hand. It flushed exactly two, then waitFor'd for an aggregate to change. If those two did not move THAT sector's value, the retry loop span against a frozen clock until timeout — waiting cannot rescue a value nothing is ticking. Each attempt now flushes another frame, so the loop advances the thing it waits on. HONEST LIMIT: I could not reproduce the CI failure locally to prove this fixes it. The old version passes 6/6 at load ~14; the observed failures were at load 30-50. So this is reasoned, not demonstrated — it removes a real mechanism that can produce exactly this symptom, and is strictly more correct given rAF is stubbed, but it is not a verified fix for that run. NOT caused by this branch, proven structurally rather than statistically: the failing assertions read summary-nav/summary-pnl, which come from buildModel(rows) in PortfolioSummary and depend only on rows. This branch changed summarizeSelection, whose output feeds a sibling SelectionSection. There is no path from the change to those elements. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Pasting from Excel into a grouped grid lost the user's first column.
Grouping prepends a synthetic column (
__pretable_group__) that the surface derives for the label, twisty and child count. Both clipboard sides treated it as a real field, so it held a paste slot Excel knows nothing about: a spreadsheet hands over N values for the N columns a user can actually write, and we tiled them across N+1 targets. The first value landed in a column nothing can be written to, was rejected asnot-editable, and every other value shifted one column right.The decision
The clipboard is a spreadsheet interchange format, not an internal round-trip format. Where the two conflict, the spreadsheet wins. See
docs/superpowers/specs/2026-08-26-clipboard-spreadsheet-shape-design.md.So the clipboard carries only real data columns, and a group row puts its label in the leftmost column of the copied range — of the range, not of the grid — with aggregates in their own columns:
That is what Excel's Subtotal and Google Sheets' pivot output produce, so a pasted block reads as native. Accepted cost, taken deliberately: when that column is numeric a text label lands in it. It is a header row and spreadsheets tolerate it.
Both sides, together
#485 measured that removing paste's slot alone inverts the damage — a copy-then-paste of a grouped row silently blanks column
aand shifts right. So this changes every emitter and the target space in one commit, behind a single shared predicate (isSyntheticColumnId):copy.ts— filters the group column out ofdataColumns; group label atc === colLo; a range bound on either synthetic column resolves to the first data column.csv.ts— same filter on its separate emit path; label in the first exported column.paste.ts— the group column no longer occupies a target slot, and an anchor landing on it re-anchors to the first data column, the way a row-select anchor already did.pretable-surface.tsx(resolvePasteAnchor) — measures the selection over the same column space. Without this a whole-row grouped selection came out one column wider than anything could be written to and announced a phantom "clipped to fit".clipboard.mdx(new Group rows section),paste.mdx,export.mdx,grouping.mdx.The group row is no longer dropped when it produces no field: it always carries a label, so a copied block is rectangular over every row it spans.
Proof
PretableSurface, focus on the leftmost cell, three values pasted for the three writable columns → one value per column,rejectedempty, and no synthetic id anywhere in the payload.paste-map.test.ts) is kept as written — it passes when both sides carry the column or neither, and fails only when they disagree. Its range now bounds on the row-select column (the whole-row copy), which is the bound that actually spans the synthetic space, so it catches a one-sided change to either emitter. Verified by mutation in both directions.Full gate green:
build,typecheck,lint,format,test, plusapi:check.🤖 Generated with Claude Code