Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@ jobs:
- name: Run browser tests
run: yarn test:browser

- name: Install Playwright Chromium
run: yarn playwright install --with-deps chromium

# Replays the golden scenarios with real Chromium input and fails if a
# trace differs from its jsdom golden file. The github-actions reporter
# puts each failure in a check annotation.
- name: Browser cross-check of golden traces
run: yarn golden:browser --reporter=default --reporter=github-actions

coverage:
runs-on: ubuntu-latest
permissions:
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,9 @@ whichever instance drags first (or, more simply, set it consistently everywhere)

## Contributing

Interaction behavior is pinned by [golden traces](test/golden/README.md).
Explain golden diffs in your PR before regenerating them.

- Fork the project
- Run `yarn dev` to start the development server
- Make changes and add tests
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
"test": "vitest run --reporter=verbose",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"golden:update": "GOLDEN_UPDATE=1 vitest run test/golden/scenarios --reporter=verbose",
"golden:typecheck": "tsc --noEmit -p test/golden",
"golden:browser": "vitest run --config vitest.golden-browser.config.js",
"golden:legacy": "vitest run --config vitest.golden-legacy.config.js",
"golden:legacy:update": "GOLDEN_UPDATE=1 vitest run --config vitest.golden-legacy.config.js",
"test:ui": "vitest --ui",
"test:browser": "yarn build && vitest run --config vitest.browser.config.js",
"test:all": "yarn test && yarn test:browser",
Expand Down Expand Up @@ -74,6 +79,7 @@
"eslint-plugin-react": "^7.37.5",
"globals": "^16.5.0",
"jsdom": "^27.4.0",
"playwright": "1.62.1",
"pre-commit": "^1.2.2",
"process": "^0.11.10",
"puppeteer": "^25.11.0",
Expand Down Expand Up @@ -107,4 +113,4 @@
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
}
}
137 changes: 137 additions & 0 deletions test/golden/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Golden interaction traces

These tests pin react-draggable 4.7.2 behavior, including known bugs, before
refactors change the event pipeline. They import `lib/` source and use local React.

```text
scenario → DOM input → React commit → callbacks + DOM + listeners
↓
__golden__/<name>.golden.json
```

## Run and regenerate

| Command | Purpose |
| --- | --- |
| `corepack yarn test` | Run existing tests and the golden suite |
| `corepack yarn test test/golden` | Compare only golden tests and serializer checks |
| `corepack yarn golden:update` | Explicitly regenerate interaction traces |
| `corepack yarn golden:typecheck` | Check the TypeScript test tooling |
| `corepack yarn golden:browser` | Run the separate Playwright cross-check |
| `corepack yarn golden:legacy` | Compare the optional React 18 traces |
| `corepack yarn golden:legacy:update` | Regenerate the optional React 18 traces |

Golden diffs in a PR must be explained. Describe the changed inputs, callbacks,
or rendered output, and whether the behavior change is intended. Review the diff
before committing regenerated files. A missing golden fails an ordinary test run;
only `GOLDEN_UPDATE=1` permits writes.

## What a trace records

| Row | Recorded values |
| --- | --- |
| Input | Event type, target, coordinates, buttons, touch lists, initial cancellation |
| Callback | Ordered `onMouseDown`, `onStart`, `onDrag`, `onStop`; event type, return value, all six data coordinates and node ID |
| Listener | Native add/remove operations, capture/passive options, target |
| Dispatched | Whether the event was cancelled, including touch scrolling prevention |
| Snapshot | Transform style, CSS translate, SVG transform attribute, class set, node connectivity, selection class/style/nonce, selection ranges, attached library listeners |
| Action/error | Rerender, node swap, selection, unmount, or reported event error |

Each trace step occupies one line of valid JSON. Object keys are sorted.
`undefined` becomes `{"$type":"undefined"}`; nonfinite numbers and negative zero
use `{"$number":"NaN"}`, `"Infinity"`, `"-Infinity"`, or `"-0"`. Finite coordinates
are rounded to six decimal places. Timestamps and unstable node identities are omitted.

The recorder tracks listener identity and capture mode, so a duplicate add does
not invent a second attachment. It records failed removals too. Framework event
delegation is excluded; document move/stop listeners and native node touchstart
bindings are included. Cleanup after the final snapshot removes even deliberately
leaked listeners from known-bug cases, keeping scenarios independent.

## Event cadence and geometry

`harness.ts` dispatches one event per `act()` by default, then snapshots after React
commits. This reaches `Draggable.onDrag`, including bounds, slack, and the user
callback. The older helpers can dispatch a whole gesture before the start state
commits, leaving that path untested.

The `batched` cadence deliberately puts the entire gesture in one `act()`. Its
event snapshots show the DOM before commit, followed by a `commit` snapshot.
`afterBatch` inputs run in separate acts after that commit. This pins the latent
stuck-dragging behavior without confusing it with ordinary event delivery.

Geometry fixtures specify parent/node size, offsets, rectangles, scroll,
padding, borders, and margins. The harness stubs layout reads and the relevant
computed styles. Defaults are a 300×200 parent at (0,0), a 40×30 node, and zero
insets/scroll. `useOffsetParent` supplies a separate parent through the public prop.
The existing synchronous animation-frame setup is retained; `flush-frame` is an
explicit timeline marker, not a deferred-frame race test.

`scenarios/cases.ts` owns the matrix. Shared cases exercise both components;
position, axis, and bounds cases exercise the wrapper. Cadence mirrors focus on
state and lifecycle boundaries. React 19 without `nodeRef` records today's start
error because that runtime no longer has `findDOMNode`.

The optional `vitest.golden-legacy.config.js` project exercises the actual React 18
`findDOMNode` path for both components. Set `REACT18_MODULE_ROOT` to a separate
`node_modules` containing matching React 18 and ReactDOM 18 packages and their
dependencies. The config checks their versions and resolution before running:

```sh
REACT18_MODULE_ROOT=/path/to/react18/node_modules corepack yarn golden:legacy
```

This keeps the two React runtimes isolated. The normal suite pins React 19, and
`golden:update` regenerates its traces. Use `golden:legacy:update` with the same
environment variable to regenerate the two legacy traces. A separate cached
React 18.3.1 installation was used to generate and verify them offline.

## Known bugs

Keep a passing trace of the observed bug and add a top-level `knownBug` string:

```json
{"knownBug":"R7: release coordinates bypass bounds clamping in onStop."}
```

| ID | Pinned behavior |
| --- | --- |
| R1-stuck | A batched gesture retains dragging state and accepts a later move |
| R2 | A vetoed stop over the node runs twice |
| R3 | Returning false from a touch drag fails to stop it |
| R4 | A second start repeats the start callback and resets the origin |
| R5 | Idle sibling unmount clears the active selection hack and text selection |
| R6 | Stop data includes release movement that the transform never renders |
| R7 | Stop data bypasses bounds |
| R9 | Replacing the child leaves touchstart attached to the detached node |
| R10 | Hybrid mouse/touch stop reports NaN and removes the wrong listeners |
| R11 | Pointer-only input has no start handler |

When fixing a bug later, update its trace and explain or remove the `knownBug`
annotation in the same PR. This suite makes no library behavior changes.

## Browser cross-check

The separate project serves source and local React through Vite. Playwright sends
real mouse input through the same selected scenarios, using native CSS layout
rather than jsdom stubs. The existing Puppeteer/UMD/CDN suite remains separate.

`playwright` is a devDependency and CI runs this check after the browser tests.
Run `yarn playwright install chromium` once, or set `PLAYWRIGHT_MODULE_PATH` to
another Playwright package directory. `PLAYWRIGHT_EXECUTABLE_PATH` optionally selects an
installed Chromium executable. The runner never installs packages or browsers.
For example:

```sh
PLAYWRIGHT_MODULE_PATH=/path/to/node_modules/playwright corepack yarn golden:browser
```

The comparison retains callback data and DOM/body/listener snapshots. It omits
selection range counts because native clicks create selection in the browser,
and normalizes whitespace around transform commas. The checked-in traces keep
their exact jsdom strings. Each input runs in its own browser task; snapshots
wait for React and animation-frame cleanup. Page requests to other origins fail.

This project was collected and its source fixture compiled during authoring.
Chromium could not launch in the macOS sandbox (MachPort permission denied), so
browser parity remains unverified. Launch failures fail the suite rather than skip it.
29 changes: 29 additions & 0 deletions test/golden/__golden__/core-cancel-match.golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"cadence": "per-event",
"component": "DraggableCore",
"name": "core-cancel-match",
"schemaVersion": 1,
"steps": [
{"capture":true,"kind":"listener","operation":"add","passive":false,"target":"node","type":"touchstart"},
{"body":{"hasSelectionClass":false,"styleElExists":false,"styleElNonce":null},"dom":[{"classes":["seed"],"connected":true,"id":"node","styleTransform":"","svgTransformAttr":null,"translate":""}],"kind":"snapshot","label":"mount","listeners":[{"capture":true,"passive":false,"target":"node","type":"touchstart"}],"selection":0},
{"button":0,"clientX":10,"clientY":10,"ctrlKey":false,"defaultPrevented":false,"kind":"input","target":"cancel","type":"mousedown","x":10,"y":10},
{"component":"DraggableCore","data":{"$type":"undefined"},"eventType":"mousedown","kind":"callback","name":"onMouseDown","returned":{"$type":"undefined"}},
{"defaultPrevented":false,"kind":"dispatched","type":"mousedown"},
{"body":{"hasSelectionClass":false,"styleElExists":false,"styleElNonce":null},"dom":[{"classes":["seed"],"connected":true,"id":"node","styleTransform":"","svgTransformAttr":null,"translate":""}],"kind":"snapshot","label":"mousedown","listeners":[{"capture":true,"passive":false,"target":"node","type":"touchstart"}],"selection":0},
{"button":0,"clientX":35,"clientY":25,"ctrlKey":false,"defaultPrevented":false,"kind":"input","target":"document","type":"mousemove","x":35,"y":25},
{"defaultPrevented":false,"kind":"dispatched","type":"mousemove"},
{"body":{"hasSelectionClass":false,"styleElExists":false,"styleElNonce":null},"dom":[{"classes":["seed"],"connected":true,"id":"node","styleTransform":"","svgTransformAttr":null,"translate":""}],"kind":"snapshot","label":"mousemove","listeners":[{"capture":true,"passive":false,"target":"node","type":"touchstart"}],"selection":0},
{"button":0,"clientX":65,"clientY":45,"ctrlKey":false,"defaultPrevented":false,"kind":"input","target":"document","type":"mousemove","x":65,"y":45},
{"defaultPrevented":false,"kind":"dispatched","type":"mousemove"},
{"body":{"hasSelectionClass":false,"styleElExists":false,"styleElNonce":null},"dom":[{"classes":["seed"],"connected":true,"id":"node","styleTransform":"","svgTransformAttr":null,"translate":""}],"kind":"snapshot","label":"mousemove","listeners":[{"capture":true,"passive":false,"target":"node","type":"touchstart"}],"selection":0},
{"button":0,"clientX":65,"clientY":45,"ctrlKey":false,"defaultPrevented":false,"kind":"input","target":"document","type":"mouseup","x":65,"y":45},
{"defaultPrevented":false,"kind":"dispatched","type":"mouseup"},
{"body":{"hasSelectionClass":false,"styleElExists":false,"styleElNonce":null},"dom":[{"classes":["seed"],"connected":true,"id":"node","styleTransform":"","svgTransformAttr":null,"translate":""}],"kind":"snapshot","label":"mouseup","listeners":[{"capture":true,"passive":false,"target":"node","type":"touchstart"}],"selection":0},
{"capture":true,"kind":"listener","operation":"remove","passive":{"$type":"undefined"},"target":"document","type":"mousemove"},
{"capture":true,"kind":"listener","operation":"remove","passive":{"$type":"undefined"},"target":"document","type":"touchmove"},
{"capture":true,"kind":"listener","operation":"remove","passive":{"$type":"undefined"},"target":"document","type":"mouseup"},
{"capture":true,"kind":"listener","operation":"remove","passive":{"$type":"undefined"},"target":"document","type":"touchend"},
{"capture":true,"kind":"listener","operation":"remove","passive":false,"target":"node","type":"touchstart"},
{"body":{"hasSelectionClass":false,"styleElExists":false,"styleElNonce":null},"dom":[{"classes":["seed"],"connected":false,"id":"node","styleTransform":"","svgTransformAttr":null,"translate":""}],"kind":"snapshot","label":"unmount","listeners":[],"selection":0}
]
}
37 changes: 37 additions & 0 deletions test/golden/__golden__/core-cancel-miss.golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"cadence": "per-event",
"component": "DraggableCore",
"name": "core-cancel-miss",
"schemaVersion": 1,
"steps": [
{"capture":true,"kind":"listener","operation":"add","passive":false,"target":"node","type":"touchstart"},
{"body":{"hasSelectionClass":false,"styleElExists":false,"styleElNonce":null},"dom":[{"classes":["seed"],"connected":true,"id":"node","styleTransform":"","svgTransformAttr":null,"translate":""}],"kind":"snapshot","label":"mount","listeners":[{"capture":true,"passive":false,"target":"node","type":"touchstart"}],"selection":0},
{"button":0,"clientX":10,"clientY":10,"ctrlKey":false,"defaultPrevented":false,"kind":"input","target":"node","type":"mousedown","x":10,"y":10},
{"component":"DraggableCore","data":{"$type":"undefined"},"eventType":"mousedown","kind":"callback","name":"onMouseDown","returned":{"$type":"undefined"}},
{"component":"DraggableCore","data":{"deltaX":0,"deltaY":0,"lastX":10,"lastY":10,"nodeId":"node","x":10,"y":10},"eventType":"mousedown","kind":"callback","name":"onStart","returned":{"$type":"undefined"}},
{"capture":true,"kind":"listener","operation":"add","passive":{"$type":"undefined"},"target":"document","type":"mousemove"},
{"capture":true,"kind":"listener","operation":"add","passive":{"$type":"undefined"},"target":"document","type":"mouseup"},
{"defaultPrevented":false,"kind":"dispatched","type":"mousedown"},
{"body":{"hasSelectionClass":true,"styleElExists":true,"styleElNonce":null},"dom":[{"classes":["seed"],"connected":true,"id":"node","styleTransform":"","svgTransformAttr":null,"translate":""}],"kind":"snapshot","label":"mousedown","listeners":[{"capture":true,"passive":{"$type":"undefined"},"target":"document","type":"mousemove"},{"capture":true,"passive":{"$type":"undefined"},"target":"document","type":"mouseup"},{"capture":true,"passive":false,"target":"node","type":"touchstart"}],"selection":0},
{"button":0,"clientX":35,"clientY":25,"ctrlKey":false,"defaultPrevented":false,"kind":"input","target":"document","type":"mousemove","x":35,"y":25},
{"component":"DraggableCore","data":{"deltaX":25,"deltaY":15,"lastX":10,"lastY":10,"nodeId":"node","x":35,"y":25},"eventType":"mousemove","kind":"callback","name":"onDrag","returned":{"$type":"undefined"}},
{"defaultPrevented":false,"kind":"dispatched","type":"mousemove"},
{"body":{"hasSelectionClass":true,"styleElExists":true,"styleElNonce":null},"dom":[{"classes":["seed"],"connected":true,"id":"node","styleTransform":"","svgTransformAttr":null,"translate":""}],"kind":"snapshot","label":"mousemove","listeners":[{"capture":true,"passive":{"$type":"undefined"},"target":"document","type":"mousemove"},{"capture":true,"passive":{"$type":"undefined"},"target":"document","type":"mouseup"},{"capture":true,"passive":false,"target":"node","type":"touchstart"}],"selection":0},
{"button":0,"clientX":65,"clientY":45,"ctrlKey":false,"defaultPrevented":false,"kind":"input","target":"document","type":"mousemove","x":65,"y":45},
{"component":"DraggableCore","data":{"deltaX":30,"deltaY":20,"lastX":35,"lastY":25,"nodeId":"node","x":65,"y":45},"eventType":"mousemove","kind":"callback","name":"onDrag","returned":{"$type":"undefined"}},
{"defaultPrevented":false,"kind":"dispatched","type":"mousemove"},
{"body":{"hasSelectionClass":true,"styleElExists":true,"styleElNonce":null},"dom":[{"classes":["seed"],"connected":true,"id":"node","styleTransform":"","svgTransformAttr":null,"translate":""}],"kind":"snapshot","label":"mousemove","listeners":[{"capture":true,"passive":{"$type":"undefined"},"target":"document","type":"mousemove"},{"capture":true,"passive":{"$type":"undefined"},"target":"document","type":"mouseup"},{"capture":true,"passive":false,"target":"node","type":"touchstart"}],"selection":0},
{"button":0,"clientX":65,"clientY":45,"ctrlKey":false,"defaultPrevented":false,"kind":"input","target":"document","type":"mouseup","x":65,"y":45},
{"component":"DraggableCore","data":{"deltaX":0,"deltaY":0,"lastX":65,"lastY":45,"nodeId":"node","x":65,"y":45},"eventType":"mouseup","kind":"callback","name":"onStop","returned":{"$type":"undefined"}},
{"capture":true,"kind":"listener","operation":"remove","passive":{"$type":"undefined"},"target":"document","type":"mousemove"},
{"capture":true,"kind":"listener","operation":"remove","passive":{"$type":"undefined"},"target":"document","type":"mouseup"},
{"defaultPrevented":false,"kind":"dispatched","type":"mouseup"},
{"body":{"hasSelectionClass":false,"styleElExists":true,"styleElNonce":null},"dom":[{"classes":["seed"],"connected":true,"id":"node","styleTransform":"","svgTransformAttr":null,"translate":""}],"kind":"snapshot","label":"mouseup","listeners":[{"capture":true,"passive":false,"target":"node","type":"touchstart"}],"selection":0},
{"capture":true,"kind":"listener","operation":"remove","passive":{"$type":"undefined"},"target":"document","type":"mousemove"},
{"capture":true,"kind":"listener","operation":"remove","passive":{"$type":"undefined"},"target":"document","type":"touchmove"},
{"capture":true,"kind":"listener","operation":"remove","passive":{"$type":"undefined"},"target":"document","type":"mouseup"},
{"capture":true,"kind":"listener","operation":"remove","passive":{"$type":"undefined"},"target":"document","type":"touchend"},
{"capture":true,"kind":"listener","operation":"remove","passive":false,"target":"node","type":"touchstart"},
{"body":{"hasSelectionClass":false,"styleElExists":true,"styleElNonce":null},"dom":[{"classes":["seed"],"connected":false,"id":"node","styleTransform":"","svgTransformAttr":null,"translate":""}],"kind":"snapshot","label":"unmount","listeners":[],"selection":0}
]
}
Loading
Loading