npx agentspec-cli install 8fb68e47-5f40-4816-82b3-fb5586a7ab5d --file-type claude-md→ .claude/rules/
# CLAUDE.md
## Build Commands
- `npm run build` — builds via microbundle-crl (`--no-compress --format modern,cjs`) into `dist/`
- `npm run release` — releases via release-it (with conventional changelog and GitHub release)
- `npm run prepare` — runs build automatically on `npm install`
- `npm test` — runs the Jest regression suite in `src/__tests__/` (responder termination, mid-drag data changes, key stability, mirrored/RTL layouts). No linting configured.
## Development Workflow
After making changes to source files, rebuild and test in the example app:
```sh
npm run build
cd example
npm i .. # reinstalls the freshly built dist/ into the example
npm run android # or npm run ios
```
All validation is manual — test on **both iOS and Android**. Key test cases:
- Drag reorder in short and long/scrolling lists
- Auto-scroll when dragging beyond list bounds
- Drag-and-release back to original position
- "Scroll to Top" button (verifies forwardRef)
- Horizontal list dragging
- The same horizontal cases under RTL, via the example app's direction toggle. On iOS the toggle's
reload isn't enough — relaunch natively (`xcrun simctl terminate`/`launch`) for `forceRTL` to take
effect. Watch the printed data order under the horizontal list: the visual order mirrors, so it's
the only way to tell a correct drop from one that landed at the mirrored index.
## Architecture
Two source files:
- **`src/index.tsx`** — `DragList` component (forwardRef wrapper around FlatList). Manages drag state via PanResponder, auto-scrolling, and reorder logic. Exports `DragListRenderItemInfo<T>` with `onDragStart`/`onDragEnd`/`isActive`. Contains `CellRendererComponent` which handles per-cell animation (slide displacement via `Animated.timing`).
- **`src/DragListContext.tsx`** — `DragListProvider` context and `useDragListContext` hook. Passes drag state (activeData, pan, panIndex, layouts) from DragList into CellRendererComponent without prop drilling.
### Key Patterns
- **Built-in Animated API + PanResponder only** — intentionally avoids react-native-reanimated. No external dependencies beyond React/React Native peer deps.
- **Layout caching in refs** — `layouts` ref stores `{ pos, extent }` per item key, used for hit-testing and displacement calculations. Axis-independent (pos/extent works for both horizontal and vertical).
- **Stable keys + static idle transforms** — item keys are stable across data changes (required for `maintainVisibleContentPosition` on Fabric, and avoids remounting every row). Cells attach Animated transform nodes ONLY while a drag is active; when idle they render a static `transform: 0`, so the commit that applies reordered data carries zeroed transforms atomically with the new layout. This is the flash-on-drop fix: async native-animated commands can never race the commit because nodes attach at value 0 and detach in commits that already specify 0. Do not reintroduce always-attached Animated transforms or generation-suffixed keys. The `layouts` cache is pruned of removed keys on each data change.
- **Drag teardown invariant** — `props.onDragBegin`/`props.onDragEnd` must always pair up. `dragEndOwedRef` tracks the debt; every teardown path (release, `onPanResponderTerminate`, mid-drag data change) settles it via `fireOwedDragEnd`. Responder termination commits the reorder at the current hover index (deliberate choice — see README caveat on gesture recognizers).
- **Auto-scroll frame loop** — dragging past an edge drives `scrollToOffset({animated: false})` from a `requestAnimationFrame` loop at a speed that ramps with how far past the edge you are. It used to step a whole item per 200ms interval with `animated: true`, which read as a series of jumps (#94). `onContentSizeChange` is destructured out of `rest` and chained (like `onScroll`/`onLayout`) because `rest` is spread last and would otherwise let a host silently replace our handler.
**The loop, not `onScroll`, is the authority on where the list is mid-drag.** This is the one invariant to keep in mind when touching any of this. `onScroll` reports lag a frame or more *and arrive unevenly*, so the loop integrates its own offset (`autoScrollOffsetRef` in flow space, `autoScrollScrollPosRef` its cartesian twin) and `updateRendering` draws against that via `effectiveScrollPos()`. Drawing against `scrollPos` instead jitters the dragged item against smoothly moving content, and leaves the loop's last command before an end-of-list pin undrawn so a release reorders to a stale slot.
The invariant is that the loop's offset equals the list's real offset for as long as a drag is live. It has exactly three dependencies, and *every* bug found in this feature so far has been one of them breaking. Check new work against this list rather than rediscovering them:
1. **Nothing else scrolls the list mid-drag.** `scrollEnabled` is forced false during a drag, which is why it's rendered *after* the `{...rest}` spread — a host passing `scrollEnabled={true}` would otherwise let a second finger move the list out from under the loop. Data changes (including `maintainVisibleContentPosition` shifting offsets) go through `reset()`, which clears the seed. A host calling `scrollToOffset`/`scrollToIndex` through the forwarded ref mid-drag is *deliberately* overridden on the next frame; reconciling two writers per frame would mean distinguishing an external scroll from a lagging report of our own command. Fling momentum still in flight when the drag starts is fine: `ReactScrollView.onTouchEvent` cancels post-touch scrolling on `ACTION_DOWN`, while `scrollEnabled` is still true.
2. **Our clamp matches the platform's** — `contentExtentRef - flatWrapLayout.extent + trailingInset` at the far end and `-max(leadingInset, 0)` at the near one, stopping when pinned so the offset can't run away past an end. That mirrors the corners of the renderers' own `maxRect` (`RCTScrollView.m`, `RCTScrollViewComponentView.mm:812`). Note that a leading inset moves the near bound *only*: `maxRect`'s width adds `fmax(leadingInset, 0)`, which reads like far-end slack, but the rect's origin is `-leadingInset`, so `CGRectGetMaxX` — origin plus width — cancels the term. Mirroring a `CGRect` means mirroring its corners, not its dimensions; an earlier pass got this wrong in the *loose* direction, which is exactly what the rule below exists to prevent. The near bound is the *inset*, deliberately not wherever the list happens to be resting: elastic overscroll parks a list at a negative offset too, and honoring that as a floor would let the loop creep through the bounce commanding offsets the platform clamps to zero. Any negative resting offset arrived via `onScroll`, which reports the inset in the same event, so the inset is always known when it could matter.
**When in doubt, err tight.** This clamp is a *prediction* of the platform's own bound (see `RCTScrollView.m`'s `maxRect`), assembled from content size, viewport and insets, and each input is another chance to be wrong — so it matters that the two error directions are wildly asymmetric. Too tight pins early: the list stops short of content it could have reached, which is cosmetic and self-healing. Too loose breaks the invariant outright: the platform silently caps a command the loop believes landed, and the loop's offset stays ahead of reality for the rest of the drag, drawing the dragged item at an offset and dropping it in the wrong slot. Prefer a guaranteed-tight approximation to a clever exact one.
The application of that: **both insets are zero under a mirrored layout**, because which cartesian side backs onto the end of the data depends on how many times the flow offset is mirrored on the way down (`VirtualizedList._scrollToParamsFromOffset` converts, and `RCTScrollView`'s own `scrollToOffset:` may convert again depending on architecture) — unresolved, and not worth resolving by reading platform source.
One thing that is *not* a gap, despite looking like one: matching the scroll event's `contentInset` rather than UIKit's `adjustedContentInset` is exactly right, because both renderers clamp `scrollTo` against `_scrollView.contentInset` (`RCTScrollView.m`, `RCTScrollViewComponentView.mm:812`) and serialize that same value into the event (`RCTScrollEvent.m`, `RCTScrollViewComponentView.mm:539`). `adjustedContentInset` never enters the command path, so under `contentInsetAdjustmentBehavior="automatic"` the platform caps our commands by the explicit inset too. Which is also why dropping the upper clamp is never the answer to an inset question: it's the loose direction.
Because every input above is learned late, being pinned has to be a *recoverable* state rather than a stop — hence the loop's three states: idle (no velocity), running, and pinned against an end (`autoScrollPinnedRef`, keeping its velocity so `resumeAutoScrollIfPinned` knows which way the drag still wants to go). Pinned is a flag rather than inferred from a null frame handle, because the handle is *also* null mid-tick — cleared at the top, rescheduled at the bottom — so anything reentrant landing in that window (a synchronous `onScroll`, a host's `onHoverChanged` triggering a relayout) would schedule a second loop beside the tick's own, and each duplicate would spawn more. Every handler that feeds the clamp calls it: `onScroll` (offsets and insets), `onContentSizeChange` (content length) and `onDragLayout` (the viewport, which a split-view resize can shrink mid-drag). Add an input to the clamp and it needs a resume call too. Without that, a list without `getItemLayout` — whose content size grows as rows mount — strands a *stationary* finger at whatever the old estimate said was the end, since only a move event could have restarted the loop. Resuming while still pinned costs one frame that computes zero travel and returns, so it can't spin.
Known gaps, all in the tight direction: insets are ignored entirely under a mirrored layout, per above; and `flatWrapLayout.extent` is the wrapper's extent rather than the scroll view's `layoutMeasurement`, which differ if a host's `containerStyle` pads the wrapper.
One accepted limitation lives next door, in `updateRendering` rather than the clamp: the hover scan stops at the first row missing from `layouts`, so a fast auto-scroll that outruns cell measurement can compute a hover index short of the finger. Mid-scroll that self-corrects on the next frame, and the common end-of-list case resolves through the resume path (mounting rows grow the content size, which revives the loop and repaints). What isn't covered is rows measuring *without* changing the content estimate while pinned, where an immediate release can drop at the stale slot. Closing it means repainting from cell layout, which puts an O(n) scan on every cell mount during a drag — the exact cost `hoverBus` exists to avoid — so it stays open deliberately.
3. **The seed is fresh at the start of each drag, and only then.** Seeded once *per drag* — not per run, since the loop stops and restarts every time your finger dips back inside, and reseeding from the lagging `scrollPos` jerks the drag backwards. So `autoScrollSeededRef` is cleared at *both* ends: `reset()` for normal teardown, and `startDrag` for a drag that supersedes a reorder still awaiting (or inside its grace window), which never goes through `reset()`. Miss the second one and the stale offset is read against a `grantScrollPosRef` this drag captured from `scrollPos`, displacing the new item on its first move. `startDrag` also tears the loop itself down, rather than leaning on `shouldCapturePan` to prove none can be running: `onDragStart` is public API, so a host driving it from its own recognizer can land there mid-loop.
- **Ref-based state for non-render paths** — `activeDataRef`, `panIndex`, `scrollPos`, `panGrantedRef` etc. are refs to avoid unnecessary re-renders during drag. `setExtra` is used sparingly to trigger re-renders only when needed.
- **Reorder serialization** — `isReorderingRef` prevents new pan captures during an async `onReordered` callback to avoid stale-index bugs.
- **Flow order vs. coordinate order** — Anything relating positions to indices must walk in *flow* order (increasing with data index), which equals coordinate order only when the layout isn't mirrored. `isLayoutMirrored(horizontal)` is true for horizontal lists under `I18nManager.isRTL`, where Yoga mirrors the row so `layouts[key].pos` descends as the index rises. The hover scan compares negated coordinates via `flowTrailingEdge()`, and cell slide targets flip sign, since transforms aren't mirrored for us the way layout is. Separately, `scrollToOffset` takes a flow-relative offset under RTL (counted from the start of the data) while `onScroll`'s `contentOffset` stays cartesian, so `scrollPos`/`flowScrollPos` track both spaces; feeding the cartesian value to `scrollToOffset` makes VirtualizedList mirror an already-mirrored number and flings the list most of its length. `inverted` is a *different* mirror — render-stage `scaleX`/`scaleY` that Yoga never sees, so it flips the pointer-to-content mapping instead of the index ordering — and is unsupported; it needs its own predicate, not this one.
### Platform Workarounds
- **React Native Web** — `onLayout` doesn't fire as expected on web (`CellRendererComponent` line 636–646), so cell measurement falls back to `measure()` in a `useEffect`.
- **RN 0.76.3+** — `pan.setValue()` stopped working for the active item; replaced with zero-duration `Animated.timing`. Also, elevation/zIndex require `Animated.Value` instead of plain numbers.
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Always start each question by looking up relevant documentation sections in the Svelte MCP. Always run "npm run format" followed by "npm run checks" before finishing a task, and fix any linting issues.
by AgentHub Crawler
Project Information
Claude Code project instructions and development guidelines.
by AgentHub Crawler
Building and running
Before submitting any changes, it is crucial to validate them by running the full preflight check. This command will build the repository, run all tests, check for type errors, and lint the code. To run the full suite of checks, execute the following command:
by AgentHub Crawler
Sign in to leave a comment.
Claude Development Notes
This document contains important guidelines for Claude instances working on this codebase.
by AgentHub Crawler