Skip to content

v2.0.0: pure core engine, new React API and headless hooks, Solid port - #87

Merged
akash-aman merged 43 commits into
mainfrom
v2
Oct 5, 2026
Merged

akash-aman merged 43 commits into
mainfrom
v2

Conversation

@akash-aman

Copy link
Copy Markdown
Collaborator

What does this PR do?

Ships v2.0.0 of @dynamix-layout/core, @dynamix-layout/react and @dynamix-layout/solid (major for all three via changesets). Plan and reasoning: docs/v2/01-audit.md and docs/v2/02-design.md.

  • Core: createLayout(options) returns an independent, pure instance: immutable snapshots with structural sharing, typed actions (move, select, add/remove, resize, maximize/restore, fold/unfold, one drag model, hit testing), onLayoutChange(layout, reason), a versioned LayoutJSON format, and automatic migration of v1 layouts (pixel-identical against recorded v1 fixtures). No module state, timers, DOM access or import-time side effects.
  • React: <DynamixLayout tabs defaultLayout|layout onLayoutChange onTabClose ref> plus headless hooks (useDynamixLayout, useTabset, useTab, useSplitter, useLayoutState, useLayoutActions, useDragState). Pointer-event drags that never re-render React, ResizeObserver, WAI-ARIA tabs and splitters, keyboard move mode, SSR-safe with a 'use client' entry. React 18 and 19.
  • Solid: minimal port onto the v2 core, keeping the v1 props, plus reactive tabs, closable tabs, onReady(layout) and updateJSON(layout, reason).
  • Packaging: working require() (real CommonJS), exports maps with per-condition types, Node 18+, licence comment kept in every bundle.
  • Docs: getting started, API reference, guides (custom components, theming, persistence, Next.js/SSR, accessibility, multiple layouts, core without React, performance) and the v1 → v2 migration guide.
  • Examples: every example has an all-features showcase (drag/split/dock, resize, close, add, fold, maximize/restore, select and move from code, persistence incl. added/closed tabs, reset, change log): React, Next.js (/showcase), Solid, SolidStart (/showcase), and a new plain-JavaScript example on the core (examples/vanilla).

Full change lists, including every breaking change, are in .changeset/v2-core.md, v2-react.md and v2-solid.md; they become the 2.0.0 changelogs.

Related issues

Follows the final 1.x releases (#84, #83, #86: core 1.2.0, react 1.1.0, solid 1.1.0), which added deprecation notes pointing at the v2 APIs. main is merged into this branch.

Testing done

  • pnpm build, pnpm lint and vitest run: 21 files, 349 tests passing (characterization tests written against v1 behaviour first, then the v2 suites).
  • React 18 smoke test against the packed tarballs (also a CI job).
  • pnpm changeset status: core, react and solid at major.
  • Browser checks of each showcase: React, Solid, plain JS, Next.js and SolidStart. Add, close, maximize/restore, fold, select, move and reload-restore in all five; tab and splitter drags in the plain-JS and SolidStart showcases; no page errors; no hydration warnings in Next.js.

Additional context

Merging this makes the release workflow open a "Version Packages" PR for 2.0.0; merging that publishes to npm.

Records 16 seeded scenarios of UI-reachable operations (moves, splitter
drags, fold, maximize, resize) against the v1 engine: canonical structure,
tabset and splitter rects, and saved v1 layouts. The v2 engine will replay
the same operations and must match pixel for pixel.
Adds the v2 model (rows, tabsets and tabs as immutable data with explicit
directions and weights) and pure tree functions: default layout, find,
normalize, remove, insert, move, maximize/fold view state, tab
reconciliation and a deterministic id generator. Nothing is exported yet;
the v1 engine is untouched.

Behaviour follows v1, including how weights survive when rows dissolve,
with two intended differences: center drops append the tab (v1 inserted
it before the last tab), and splitting beside a folded tabset unfolds it
(v1 left the new row stuck at its minimum size).
Ports v1 min-size propagation, weighted distribution with cumulative
boundary rounding, root overflow, fold and maximize overrides and the
splitter clamp-and-reweight to pure functions over the immutable model.
Rects are in layout-root coordinates and unchanged rects keep their
identity. Tests compare the results with the v1 engine pixel for pixel.
Pure hit testing against engine rects: root-edge zones, tab bar insertion
markers from measured tab rects, folded strips (always into the tabset)
and tabset thirds. Invalid targets return null instead of showing an
indicator that the drop then refuses. Indicator rects and positions are
checked against the v1 preview helpers.
Adds the versioned LayoutJSON format, a validating parser that reports
the path of malformed input and repairs what it safely can, and
migrateLayoutFromV1. Migration keeps v1 semantics (tab identity is the
label, nodPart becomes weight, directions become explicit), so 106 of
the 112 saved v1 layouts in the fixtures render pixel-identically. The
other six capture v1 bugs v2 does not reproduce and are compared by
structure.

Weights of 0 are valid (v1 writes them when a splitter is dragged to a
neighbour's minimum); a row whose open children all weigh 0 now shares
its space equally instead of keeping stale sizes.
createLayout() returns an independent instance: no module state, no
timers, no DOM. It publishes immutable snapshots that share unchanged
parts by reference, reports committed changes through onLayoutChange
with a reason, and drives tabs, tabsets and splitters through one drag
model (startDrag/updateDrag/endDrag/cancelDrag). Saved layouts load as
v2 or v1.

The v1 fixtures replay through the public API: over 400 steps match v1
exactly, and the four places they stop are classified v1 bugs (a row
stuck at its minimum next to a folded tabset, stale sizes when every
open child weighs 0, and a root that stays oversized after the
container shrinks). A root that is horizontal or has one child is now
always a plain root, which keeps its JSON stable.

The v2 API is exported alongside v1 until the adapters move over.
vite-plugin-dts only emitted files that reach the bundle graph, so
modules imported with `import type` alone (layout-types, model/types,
the React types) got no .d.ts, and the types they declare resolved to
any for consumers. List the sources explicitly.
Adapters measure tab bars into DropMeasurements; give the entry a
public name. LayoutTreeV1 accepts readonly child arrays, so typed
literals and frozen data can be passed for migration.
…d slot components

Rebuilds @dynamix-layout/react on the v2 core instance API.

- <DynamixLayout> takes `tabs` as objects, `defaultLayout` or `layout`
  (v1 or v2 JSON) and reports changes through `onLayoutChange`. Sizes,
  slots, classNames and styles replace the 46 v1 props. The engine is
  created once per component and never rebuilt by prop changes; an
  inline `tabs` array with unchanged ids does no engine work and
  remounts nothing.
- Headless API: useDynamixLayout with DynamixLayoutProvider, plus
  useTabset, useTab, useSplitter, useLayoutState, useLayoutActions and
  useDragState. Prop getters merge refs, classes, styles and handlers.
- Pointer events drive tab, tabset and splitter drags (touch long press,
  Escape cancels). A ResizeObserver follows the container, and rects are
  relative to the root, so a layout no longer needs to sit at the
  viewport's top-left.
- Pointer moves and resizes never re-render React: an internal fast
  path writes positions computed by the same function the render uses.
- Tabs follow the WAI-ARIA tabs pattern, splitters the window splitter
  pattern, and a keyboard move mode (Mod+Shift+M) moves tabs without a
  mouse. Shortcuts only act when focus is inside the layout.
- SSR-safe and deterministic: the Next.js example now renders on the
  server.

The React and Next.js examples move to the new API.

BREAKING CHANGE: The v1 props, wrapper components (WrapTab*, SliderElement,
HoverElement, RootSplitterHoverEl), useDynamixLayout return shape,
getTabOutput and the tuple `tabs` format are removed. `ref` returns a
DynamixLayoutHandle (`handle.element` is the root). `onLayoutChange`
replaces `updateJSON`, receives v2 JSON and is not called on mount.
Default class names change to `.dx-*`, and data attributes to
`data-dx-*`. See docs/v2/02-design.md for the full mapping.
A minimal port: <DynamixLayout> keeps its props, default components and
HTML5 tab drag-and-drop, but runs on one createLayout instance per
component instead of the v1 global engine. Drop targets come from the
core hit testing, positions follow a snapshot signal, and rects are
relative to the root, so two Solid layouts on a page no longer share
state and a layout away from the viewport's top-left is placed
correctly.

BREAKING CHANGE: `layoutTree` accepts v2 JSON (v1 trees are migrated) and
`updateJSON` receives v2 `LayoutJSON`. `getTabOutput` and the
`TabEntry`/`TabInput`/`TabOutput` types are removed, tab ids are the tab
labels, `useDynamixLayout` returns a new shape, and the slider timeout
options no longer apply (splitters update once per frame).
…er and window global

The v1 engine is gone: DynamixLayoutCore with its static accessors, Node,
Bond and Node.cache, the module-level layoutState, Queue,
createReactiveState, the comparators, the v1 drop-preview and DOM
helpers, and the LayoutTree/NodeOptions types. Importing the package no
longer logs a banner or sets window.__DYNAMIX_LAYOUT__; `version` is
exported instead. createFrameScheduler stays, moved to dom/.

The v1 test suites go with the engine. What they checked is covered by
the v2 tests and by fixtures recorded from v1 (scenario replays, default
layouts and drop previews), which stay as the reference.

BREAKING CHANGE: use createLayout() instead of new DynamixLayoutCore(),
layout.toJSON() instead of DynamixLayoutCore._root.toJSON(), and the
snapshot from layout.getSnapshot()/subscribe() instead of Node.cache.
Saved v1 trees load through migrateLayoutFromV1 (automatic in
createLayout and layout.load). See docs/v2/02-design.md for the full
rename table.
vite-plugin-dts rewrote the tsconfig path alias for @dynamix-layout/core
into a relative import of the core sources ('../../../core/src'), which
doesn't exist in the published package, so every core type resolved to
any for users. The published react 1.0.2 has the same problem. Keep the
package specifier, and emit declarations for types-only Solid modules.
Prop getters now set `position: absolute` (and the root
`position: relative`) inline next to the rects, and hidden slots get the
native `hidden` attribute as well as `data-dx-hidden`, so headless
layouts work without importing styles.css.
The React example app now has one page per level, picked by URL hash:
custom slot components with a saved v1 layout and live size controls
(the existing demo), the basic component, headless hooks with no library
styles, and controlled mode persisted to localStorage.
…act 18 check

- Every package ships ESM (`index.js`) and CommonJS (`index.cjs`). The
  CommonJS build used a `.js` extension in a `"type": "module"` package,
  so Node loaded it as ESM and `require()` failed.
- `exports` maps put `types` first in each condition; CommonJS gets
  `index.d.cts`. Declarations are rolled into one file, built against
  the published core instead of its sources.
- React: `react/jsx-runtime` and `react-dom/*` are external (the bundle
  inlined React 19's runtime), peers are `^18.0.0 || ^19.0.0`, the
  bundle starts with 'use client', IIFE/UMD builds are gone, and the
  stylesheet is `styles.css` (`style.css` stays as an alias).
- Solid: `solid-js` is a peer dependency, with the same exports layout.
- Core: one UMD build for CDNs (`unpkg`), no `browser` field, and a
  one-line licence banner instead of the full text.
- `sideEffects` lists only CSS. publint and attw report no problems.
- A `react-18` CI job installs the packed packages into a project that
  only has React 18 and renders on the server and in jsdom.
…age tooling

DynamixLayout.tsx keeps the root component and LayoutParts.tsx the
memoized per-tabset, tab, content and splitter parts; keyboard move mode
gets its own module. rootDir moves to the declaration-only tsconfig so
the dev typecheck, which reads core from source, is clean again.

Adds @vitest/coverage-v8 and tests for the public core exports (no v1
leftovers, no window global), custom id factories, setOptions,
moveTabset, targeted addTab, splitter sources in drop helpers and the
production duplicate-id path. Core coverage: 93.8% statements, 89.5%
branches, 96.1% functions, 96.1% lines.
Rewrites the root and package READMEs for v2 (pitch, demo, install,
quick start, links to the docs), points every repository link at
xcode-studio/dynamix-layout, fixes the documented stylesheet path, and
updates the site's install snippets to the v2 API.
migrateLayoutFromV1 replaced a negative or non-numeric nodPart with 100
without telling anyone. It now reports INVALID_WEIGHT with the path,
like the v2 parser.
…on guide

- getting-started: concepts, the first layout, saving and restoring,
  and adding, closing and selecting tabs.
- api/: one page per export (DynamixLayout, useDynamixLayout and the
  provider, useTabset, useTab, useSplitter, useLayoutState,
  useLayoutActions, useDragState, createLayout, LayoutJSON,
  migrateLayoutFromV1), each with the signature, a table of every
  option, the return value, examples and common pitfalls.
- guides/: custom components, theming, persisting layouts, Next.js
  and SSR, keyboard and accessibility, multiple layouts, the core
  without React, and performance (with measured bundle sizes).
- migration-v1-to-v2: steps, the full rename table, before/after code
  for each breaking change, behaviour changes and saved layouts.
getRootProps listed the measured state as a dependency, so its identity
changed once after mount, contrary to the documented guarantee. The flag
now lives on the controller, which getters read at call time, and a
test checks that every function from useDynamixLayout and useTab keeps
its identity across renders.
Adds @example to the slot components, useTabset, useTab, useSplitter,
useDragState and the core helpers (isLayoutV1, getRootDropZoneRect,
getTabBarPlacement, getTabContentRect, applyRect, createFrameScheduler,
DynamixLayoutError, version), with @PARAM and @returns where they were
missing. Slot examples forward refs, as React 18 requires.
…rbo env var

The published packages required node >=22.16.0 and pnpm >=10.13.1,
which made installs fail (or warn) for apps on Node 18 or 20 with strict
engine checks, although the ES2020 builds run there. They now declare
node >=18; the repo's own Node and pnpm versions stay in the root
package.json. Also removes UPDATE_V1_FIXTURES from turbo.json, left over
from the v1 fixture recorder.
# Conflicts:
#	README.md
#	packages/core/README.md
#	packages/core/package.json
#	packages/core/src/app/comparator.ts
#	packages/core/src/app/dom.ts
#	packages/core/src/app/drop-preview.ts
#	packages/core/src/app/dynamix.ts
#	packages/core/src/app/node.ts
#	packages/core/src/app/queue.ts
#	packages/core/src/app/reactive-state.ts
#	packages/core/src/index.ts
#	packages/core/src/type/index.ts
#	packages/core/vite.config.ts
#	packages/react/README.md
#	packages/react/package.json
#	packages/react/src/components/Default.tsx
#	packages/react/src/components/Layout.tsx
#	packages/react/src/hooks/useLayout.ts
#	packages/react/src/types/index.ts
#	packages/react/tsconfig.build.json
#	packages/react/vite.config.ts
#	packages/solid/package.json
#	packages/solid/src/types/index.ts
#	packages/solid/tsconfig.build.json
#	packages/solid/vite.config.ts
`tabs` is now reactive (new or removed entries update the same engine;
existing content stays mounted). A third tuple element { title,
closable } adds a close button that calls onTabClose, and onReady hands
over the engine for actions from code. All additive.
Drag, split, resize, close, add (anywhere or at a target), fold,
maximize and restore (toolbar, double-click, shortcuts and API buttons),
select and move from code, keyboard move mode, localStorage persistence
and reset, with a live log of onLayoutChange reasons. It's the default
page of the React example.
A small DOM renderer (examples/vanilla/src/dom-layout.ts) over
@dynamix-layout/core with the same feature set as the React and Solid
showcases: tab and tab bar drags, splitters, closable tabs, add, fold,
maximize/restore, select, move, persistence and reset.
Client-only layout restore keeps server and client markup identical.
Rollup's output.banner is added before Vite minifies, and the minifier
dropped it, so core shipped without the comment the design called for and
react and solid had none. A shared plugin now adds it after minification,
on line 1 (after 'use client') so source maps stay aligned.
The second argument is the core's LayoutChangeReason, or 'mount' for the
call on mount. Existing one-argument callbacks keep working.
The saved layout lists every open tab, so each showcase rebuilds its tabs
from it. The Solid showcases log change reasons instead of "saved".
@vercel

vercel Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
dynamix-layout-react Ready Ready Preview Oct 5, 2026 3:35am UTC

This branch was successfully deployed

1 active deployment
Preview – dynamix-layout-react — 46b6fe77 Deployed Oct 5, 2026 by vercel[bot]
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