Skip to content

css grid rewrite#29

Merged
treardon17 merged 10 commits into
mainfrom
tyler/css-grid
May 15, 2026
Merged

css grid rewrite#29
treardon17 merged 10 commits into
mainfrom
tyler/css-grid

Conversation

@treardon17
Copy link
Copy Markdown
Collaborator

splitpanel: CSS Grid rewrite

Why

There were big performance issues in the previous iteration, and some bugs that were hard to fix given the architecture. It also had a few external dependencies when it really didn't need them. The consumer API also was pretty terrible.

New Architecture

CSS Grid owns layout. Each container is a real display: grid element with a single grid-template-columns (or -rows) string composed from the tree's stored sizes; the browser handles min/max via clamp() in the track string and transitionend signals "layout settled". The runtime's job shrinks to writing one inline --sp-tracks value per container — JS no longer computes pixels CSS will then re-derive.

State lives in framework-free TypeScript (src/). Sizes are stored in one consistent denominator (pctBudgetPx) so saturated layouts always sum to 100, and the math is unit-tested in isolation. The optional Vue wrapper (src/vue/) publishes a SplitGridHandle facade keyed by container id — descendants and ancestors both reach it via useSplitGrid(id), the same handle survives v-if / HMR cycles, and event hooks registered before mount drain to the live grid on attach.

No reactivity dependency, no lodash, no animejs, no sass. Plain CSS, plain TypeScript.

📚 Bookkeeping:

Testing (if applicable):

  • Ran/wrote unit tests for this

Checklist

  • Assigned PR to myself
  • Added at least 1 person on the team as reviewer
  • Release Notes: PRs types that have the 🗒️ next to them also require release notes to be added to the CHANGELOG.md

Replaces the JS-driven sizing engine with one backed by CSS Grid, drops
the runtime dependencies on @madronejs/core / lodash / animejs / sass,
and reshapes the Vue API around a durable handle.

WHY

The previous engine resolved every panel size in JS and wrote
per-element widths/heights. Those numbers disagreed with what CSS
itself resolved (resizer-track px, min-clamp interactions,
container-resize timing), so layouts overflowed or shrank under common
conditions; fix attempts compounded. The wrapper API was wired up via
template refs + SplitGridViewApi, with no cross-tree access or
pre-mount listener registration.

ARCHITECTURE

CSS Grid owns layout. Each container is a real `display: grid` element
with a single `grid-template-columns` (or `-rows`) string composed
from the tree's stored sizes; the browser handles min/max via
`clamp()` in the track string and `transitionend` signals "layout
settled". The runtime's job shrinks to writing one inline `--sp-tracks`
value per container — JS no longer computes pixels CSS will then
re-derive.

State lives in framework-free TypeScript (src/). Sizes are stored in
one consistent denominator (`pctBudgetPx`) so saturated layouts always
sum to 100, and the math is unit-tested in isolation. The Vue wrapper
(src/vue/) publishes a `SplitGridHandle` facade keyed by container id
— descendants and ancestors both reach it via `useSplitGrid(id)`, the
same handle survives v-if / HMR cycles, and event hooks registered
before mount drain to the live grid on attach.

No reactivity dependency, no lodash, no animejs, no sass. Plain CSS,
plain TypeScript.

See CHANGELOG.md for the breaking surface, new APIs, and the bug class
the rewrite eliminates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@treardon17 treardon17 requested a review from bitencode May 14, 2026 17:41
@treardon17 treardon17 self-assigned this May 14, 2026
treardon17 and others added 9 commits May 14, 2026 11:17
The `id` prop on `<SplitGridView>` defaulted to required, which forced
consumers to invent a string even when they had no cross-tree handle
access in mind. It also produced a registry collision when two
component instances declared the same id (e.g. two stacked
ScdBuilders both naming their root `'scb-panels'`), causing the second
`attachHandle` to throw.

Now:

  - `id` is optional. When omitted, an instance-scoped id is generated
    via `useAutoId('sgv')`. Stable per instance, unique across them.
  - `useAutoId(prefix, explicit?)` is re-exported from the public
    `@madronejs/splitpanel/vue` entry. Consumers that need parent-side
    `useSplitGrid(id)` access but don't want a global identifier can do
    `const ROOT_ID = useAutoId('myprefix')` and pass that id to both
    `useSplitGrid` and the wrapper — instance-scoped uniqueness with
    setup-phase handle access.
  - Auto-id handles are pruned from the module registry on unmount. No
    external consumer can reach them, so retaining them would just
    leak across v-if cycles. Explicit-id handles still persist (queued
    listeners survive a remount under the same id; documented feature).
  - Explicit-id collisions still throw on second attach. Explicit ids
    are global identifiers by intent — silently sharing a handle would
    cross-wire two unrelated trees.

Four new regression tests in SplitGridView-props.dom.spec.ts cover
the optional-id path, two-instances-without-id coexistence, auto-id
registry cleanup on unmount, and explicit-id preservation on unmount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a declarative `<SplitPanel>` was v-if'd off and back on, the
re-registered panel landed at the end of the parent's children array
(`addChild`'s default is append). A panel that started in slot
position 2 ended up at position N after a hide/show cycle — visible
ordering drift in any consumer with toggle-able panels.

New optional `:order?: string[]` prop on `<SplitGridView>`: declares
the canonical id sequence. At register time, the wrapper computes the
insertion index from the new id's position in `order` and inserts
before the first existing child whose order-index is greater.

Semantics:
  - IDs in the tree but not in `order` keep their relative position.
  - IDs in `order` not in the tree are ignored.
  - Insertion-only — changing the `order` prop at runtime doesn't
    re-shuffle existing children. Consumer calls `grid.syncChildren`
    if they want that.

Two regression tests in SplitGridView-props.dom.spec.ts:
  - v-if'd-off-then-on panel re-inserts at its canonical slot
  - ids absent from `order` still append (`order` is an override for
    the panels it names, not a whitelist that drops the rest).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`ResizerState.before` and `.after` are `Node<T> | undefined` — a
`Leaf<T> | Container<T>` union. Only the leaf variant carries `data`,
so templates that wanted the leaf payload had to narrow the union with
an `as` cast (containers don't have `data`, the type system rightly
refuses the direct access).

Add typed convenience accessors: `beforeData: T | undefined` and
`afterData: T | undefined`. Container neighbors → `undefined`
(same shape as `PanelState.data` exposes for container nodes), so
consumers read `resizer.afterData?.foo` without narrowing.

The raw `before` / `after` `Node<T>` fields stay for the (rare)
consumer who needs container-vs-leaf access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new state queries on SplitGrid (the framework-free runtime):

  - `getMaximizedIndex(containerId)`: index of the currently-maximized
    child within the container's children array, or -1 if none. Belongs
    on the runtime, not on the Vue PanelState — derived projection of
    container state, useful to any consumer building "expand prev /
    expand next" UI.
  - `areChildrenEqual(containerId)`: true when 0-1 children, or when
    every child shares the same `Length`. The "is equalize a no-op?"
    check that toolbars want for disabled-state on an Equalize button.

Both surface on `PanelState` for container nodes (as
`maximizedChildIndex` + `childrenEqual`) and on the `SplitGridHandle`
public API (alongside isMaximized / isAtDefault) so consumers reach
them via `useSplitGrid(id)` too.

Also exposes `beforeData` / `afterData` on `ResizerState` — `T |
undefined` projections of the leaf payload, so consumer templates no
longer have to `as` cast `Node<T>` from a `Leaf | Container` union
just to read `.data`.

While in handle.ts, collapsed the six reactive-read methods to
one-liner ternaries. The bare `isReady.value;` statement + lint-disable
at each call was duplicated boilerplate; the ternary `isReady.value ?
real : fallback` does double duty — gates the attach check AND
registers the reactive dep so pre-attach computeds re-evaluate at
attach time. One block comment above the six lines explains the trick.

Five new node-suite tests pin getMaximizedIndex (-1 / index / leaf /
unknown / minimize-restores-minus-one) and areChildrenEqual (true post
equalize / false post setSize / true with one child / false on leaves
and unknown ids).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`scheduleMeasure` (fired by the rootObserver `ResizeObserver` on
container size changes) used to update `c.availPx` via `measureAll`
but never re-emit `--sp-tracks`. CSS Grid then re-resolved the
LAST-written pct string against the new container width — which
drifts whenever px-sized siblings or resizer-track px are in play
(the scale factor `budget / containerAxisPx` depends on the
container size).

Now scheduleMeasure also calls `writeAllTracks(this.rootEl)` after
the measure, with `animate: false` (the user IS the resize input; CSS
just keeps in lockstep). Layouts re-fit the new container width
without drifting off-axis.

Regression test in layout.browser.spec.ts: 1000px container with a
pct/fr player + px markers + a maximize op (which freezes player to
stored pct). Shrink the host to 800px and assert
sum(panels + resizer) ≈ 800.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Squashes 12 review-driven commits:

Bugs (must-fix):
- emit distinct 'maximize' / 'minimize' reason via a shared
  applySize() helper instead of routing through setSize, so
  subscribers can tell "user dragged to 100%" from "panel maximized."
- setBounds on the root persists the merged bounds and emits
  'set-bounds' — previously early-returned silently.
- prepareForLayoutOp returns false when availPx <= 0; every entry
  caller (applySize, setBounds, toggleExpand, equalize) bails so
  pre-measure / detached / display:none paths don't store
  [0%, 0%, …] and paint a collapsed grid.
- <SplitGridView> pre-flights the handle before mounting. The old
  mount → subscribe → attachHandle order leaked an orphan grid (DOM
  + ResizeObserver + subscriber) when attachHandle threw on a
  duplicate id; onUnmounted only cleaned the winning grid.

Risks:
- scheduleMeasure's pending rAF is tracked and cancelled in unmount;
  rafScheduled reset so a remount on the same instance doesn't
  silently swallow its first measure.
- dragHandle reads immMin alongside immMax and floors the immediate
  at its bounds.min on apply so stale-state callers can't leave it
  below its declared floor.
- equalize's storage doc clarified per unit mix (all-pct sums to
  100, mixed splits, all-px stores avail/N px). Tests lock both
  shapes.

BREAKING:
- LayoutChangeEvent.containerId is now string | [string, string]
  and sizes is now Length[] | [Length[], Length[]]. Cross-parent
  swap emits ONE composed event covering both containers;
  same-parent swap still uses the single-string form. Consumers
  narrow with Array.isArray.

Convention drift:
- Registry typed Map<string, SplitGridHandle<unknown>> instead of
  <any>; lookup-site cast in useSplitGrid<T>(id).
- toPx returns NaN for 'fr' (was the denom sentinel) so unit-mix
  bugs surface instead of returning fake numbers.
- trackForChild always emits minmax(min, 1fr) for fr tracks; max
  is dropped on fr (the prior minmax(min, max) form silently
  switched the track from flex-fill to a bounded fixed range).
- getMaximizedIndex uses the O(1) findMaximizedIndex helper.
- unmount disposes resizers via detachResizers per container so
  the ':scope > .sp-resizer' selector matches the per-container
  churn path.
- emit wraps cfg.onChange in try/catch matching the subscribers
  loop.

Tests:
- invariant.browser asserts storage sum-to-100 in addition to
  rendered-px sum-to-container.
- syncChildren event-count locks per-op emit shape.
- :order runtime no-reshuffle.
- PanelState container fields (maximizedChildIndex, childrenEqual)
  + leaves report undefined.
- ResizerState beforeData/afterData exposure.
- dragHandle: immediate-neighbor min respected on small deltas.
- setBounds on root, setSize when availPx=0, unmount cancels rAF.

Tooling:
- pnpm watch (vite build --watch) for live dev-link iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0.1.0 carried a default rgba(0,0,0,0.2) background on the resizer
track so the divider was visible without consumer styling; the CSS
Grid rewrite dropped it. Restore a default tint, but adapt to
dark mode via color-mix against currentColor (8% idle, 16% on
hover / active drag).

Overridable via --sp-resizer-bg / --sp-resizer-bg-hover (any
CSS color, or transparent to opt out).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The split-out maximize/minimize reasons weren't in RESIZE_REASONS,
so consumers using handle.onResize() to react to any size change
stopped seeing those events. They're size changes; categorize them
as such.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- maximize() now freezes fr → pct BEFORE snapshotting parent.max.restore.
  A fresh container (sizes still fr) used to snapshot fr entries, and
  the eventual toggleExpand-restore wrote them back into c.sizes —
  writeTracks then emitted `minmax(0, 1fr)` against a previously-pct
  track, hitting the function-shape mismatch styles.css warns about
  (background flashes through the transition).
- Cross-parent swap clears BOTH containers' max inside the single
  mutator callback. Previously, pB.max stayed referencing an id whose
  node had moved to pA until the second mutateStructure(pB) ran —
  a fully synchronous but stale window.
- getRawDefinition({ withCurrentSizes: true }) falls back to the
  no-sizes path when the host has no measurable rect (pre-paint,
  detached, display:none) instead of writing "0px" into every child's
  bounds.size.
- <SplitContainer> propagates `bounds` prop changes to grid.setBounds,
  matching <SplitGridView>. `resizer` is documented as mount-time
  only (no setResizer in the core).
- Doc nits: setBounds merge spread semantics (`{ min: undefined }`
  clears, intentional), <SplitPanel>'s `data` prop call-out on
  inline-literal binding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@bitencode bitencode left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉 LGTM - I like this interface much better. Will be interesting to see how people use it 👍

@treardon17 treardon17 merged commit 0ce2637 into main May 15, 2026
4 checks passed
@treardon17 treardon17 deleted the tyler/css-grid branch May 15, 2026 22:14
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.

2 participants