Skip to content
Open
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 .changeset/quiet-mice-resize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@tanstack/table-core': patch
---

End a column resize when its `mouseup` was missed. Dragging a resize handle installs `mousemove` and `mouseup` listeners on the document, and a release that never reaches the page β€” switching tabs, taking a screenshot, any focus steal β€” left those listeners attached with the dragged column still active, so the handle followed the pointer with no button held. The next `mousemove` reports `buttons === 0`, which says the gesture is over; the resize now commits and cleans up there, matching what the touch path already does on `touchcancel`.

`buttons` is a bitmask, so a nonzero value alone does not prove the drag is alive: the button that started the resize can be released while a different one stays down. The gesture now ends when the initiating button's bit is clear, so a move that reports another button no longer keeps the handle attached.

`button` and `buttons` do not order the non-primary buttons the same way (middle is `button: 1` with `buttons: 4`, right is `button: 2` with `buttons: 2`), so the initiating button's mask is looked up rather than shifted from its value; a middle- or right-button drag now stays attached.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ import type { Column_Internal } from '../../types/Column'
import type { ColumnSizingState } from '../column-sizing/columnSizingFeature.types'
import type { columnResizingState } from './columnResizingFeature.types'

/**
* The `buttons` bit of each `button` value. The two properties do not share an
* order: the primary button is bit 0 in both, but the middle button is
* `button: 1` with `buttons: 4` and the secondary one `button: 2` with
* `buttons: 2`, so shifting the button value maps those two the wrong way.
*/
const BUTTON_MASK: Readonly<Record<number, number>> = {
0: 1, // primary
1: 4, // auxiliary (middle)
2: 2, // secondary (right)
3: 8, // fourth (back)
4: 16, // fifth (forward)
}

/**
* Creates the default transient column resizing state.
*
Expand Down Expand Up @@ -121,6 +135,15 @@ export function header_getResizeHandler<
? Math.round(event.touches[0]!.clientX)
: (event as MouseEvent).clientX

// `buttons` is a bitmask, so a nonzero value does not mean this drag is
// still alive: the button that started it can be released while another one
// stays down. Remember which bit the gesture began with and require that
// same bit on every move. A `mousedown` without a button value is the
// primary one.
const startButtonMask = isTouchStartEvent(event)
? 0
: (BUTTON_MASK[(event as MouseEvent).button] ?? BUTTON_MASK[0]!)

const newColumnSizing: ColumnSizingState = makeObjectMap()

const updateOffset = (eventType: 'move' | 'end', clientXPos?: number) => {
Expand Down Expand Up @@ -240,7 +263,31 @@ export function header_getResizeHandler<
_contextDocument || (typeof document !== 'undefined' ? document : null)

const mouseEvents = {
moveHandler: (e: MouseEvent) => onMove(e.clientX),
moveHandler: (e: MouseEvent) => {
// A resize can lose its `mouseup`: switching tabs, taking a screenshot,
// or any other focus steal stops the release from reaching the page,
// and the listeners stay on the document with the drag still active.
// The next move is the only place left to notice β€” the button that
// started the gesture is no longer held, so the gesture is over.
//
// That move carries a position of its own, but it is not where the drag
// ended: the pointer travelled on after the button came up. Ending at it
// would size the column by how far the pointer drifted, so end at the
// last position observed while the button was still held.
if ((e.buttons & startButtonMask) === 0) {
mouseEvents.endAtLastHeldPosition()
return
}
onMove(e.clientX)
},
endAtLastHeldPosition: () => {
contextDocument?.removeEventListener(
'mousemove',
mouseEvents.moveHandler,
)
contextDocument?.removeEventListener('mouseup', mouseEvents.upHandler)
onEnd()
},
upHandler: (e: MouseEvent) => {
contextDocument?.removeEventListener(
'mousemove',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ describe('header_getResizeHandler', () => {
handler({ type: 'mousedown', clientX: 100 })

// Simulate mouse move
const moveEvent = new MouseEvent('mousemove', { clientX: 150 })
const moveEvent = new MouseEvent('mousemove', { clientX: 150, buttons: 1 })
document.dispatchEvent(moveEvent)

expect(onColumnSizingChange).toHaveBeenCalled()
Expand Down Expand Up @@ -379,7 +379,7 @@ describe('header_getResizeHandler', () => {
const handler = header_getResizeHandler(header as any, document)
handler({ type: 'mousedown', clientX: 100 })

const moveEvent = new MouseEvent('mousemove', { clientX: 150 })
const moveEvent = new MouseEvent('mousemove', { clientX: 150, buttons: 1 })
document.dispatchEvent(moveEvent)

const lastUpdate = sizingUpdates[sizingUpdates.length - 1]
Expand Down Expand Up @@ -426,7 +426,7 @@ describe('header_getResizeHandler', () => {
const handler = header_getResizeHandler(header as any, document)
handler({ type: 'mousedown', clientX: 100 })

const moveEvent = new MouseEvent('mousemove', { clientX: 150 })
const moveEvent = new MouseEvent('mousemove', { clientX: 150, buttons: 1 })
document.dispatchEvent(moveEvent)

const lastResizing = resizingUpdates[resizingUpdates.length - 1]
Expand Down Expand Up @@ -460,9 +460,15 @@ describe('header_getResizeHandler', () => {
const handler = header_getResizeHandler(header as any, document)
handler({ type: 'mousedown', clientX: 100 })

document.dispatchEvent(new MouseEvent('mousemove', { clientX: 110 }))
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 120 }))
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 130 }))
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 110, buttons: 1 }),
)
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 120, buttons: 1 }),
)
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 130, buttons: 1 }),
)

// leading edge applies immediately; later moves wait for the frame
expect(onColumnSizingChange).toHaveBeenCalledTimes(1)
Expand Down Expand Up @@ -508,8 +514,12 @@ describe('header_getResizeHandler', () => {
const handler = header_getResizeHandler(header as any, document)
handler({ type: 'mousedown', clientX: 100 })

document.dispatchEvent(new MouseEvent('mousemove', { clientX: 110 }))
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 120 }))
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 110, buttons: 1 }),
)
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 120, buttons: 1 }),
)
expect(sizingUpdates).toHaveLength(1)

// mouseup before the frame fires: pending frame is canceled and the end
Expand Down Expand Up @@ -538,7 +548,9 @@ describe('header_getResizeHandler', () => {
const afterDown = notifications.length

// a move writes columnResizing AND columnSizing, batched into one flush
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 150 }))
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 150, buttons: 1 }),
)
const afterMove = notifications.length

// drag end: 'end' commit (two writes) + resizing reset, one flush total
Expand Down Expand Up @@ -569,7 +581,9 @@ describe('header_getResizeHandler', () => {

// single move: the rAF throttle applies the first move of a frame
// immediately, later moves wait for the next frame
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 140 }))
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 140, buttons: 1 }),
)
expect(sizingUpdates).toHaveLength(0)

// transient resize info still tracks the drag during moves
Expand Down Expand Up @@ -660,6 +674,172 @@ describe('header_getResizeHandler', () => {

removeEventListenerSpy.mockRestore()
})

it('should end the resize when a move arrives with no button held', () => {
// A resize started with the mouse can lose its `mouseup`: switching tabs,
// taking a screenshot, or any other focus steal stops the button release
// from reaching the page. The next move reports `buttons: 0`, which is the
// only signal left that the gesture is over.
const table = makeTable(1, {
columnResizeMode: 'onChange',
})
const sizingUpdates: Array<Record<string, number>> = []
table.options.onColumnSizingChange = (updater) => {
sizingUpdates.push(
typeof updater === 'function'
? updater(table.atoms.columnSizing.get())
: updater,
)
}

const header = createTestResizeHeader(table)
const handler = header_getResizeHandler(header as any, document)
handler({ type: 'mousedown', clientX: 100 })

document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 150, buttons: 1 }),
)
expect(sizingUpdates).toHaveLength(1)
expect(table.atoms.columnResizing.get().isResizingColumn).toBe('firstName')

// the release happened while the page was not listening, so the next move
// carries the truth: no button is down
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 150, buttons: 0 }),
)

expect(table.atoms.columnResizing.get().isResizingColumn).toBe(false)

// the listeners are gone, so later moves cannot resurrect the drag
const updatesAfterEnd = sizingUpdates.length
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 400, buttons: 0 }),
)
expect(sizingUpdates).toHaveLength(updatesAfterEnd)
})

it('should commit the last held-button position when the release was missed', () => {
// The move that reveals the missed release is a move of its own: the pointer
// travelled on after the button came up, and its position says nothing about
// where the drag actually ended. The size has to come from the last move made
// while the button was still down, the same position a real `mouseup` would
// have reported.
const table = makeTable(1, {
columnResizeMode: 'onChange',
})
const sizingUpdates: Array<Record<string, number>> = []
table.options.onColumnSizingChange = (updater) => {
sizingUpdates.push(
typeof updater === 'function'
? updater(table.atoms.columnSizing.get())
: updater,
)
}

const header = createTestResizeHeader(table)
const handler = header_getResizeHandler(header as any, document)
handler({ type: 'mousedown', clientX: 100 })

// the drag itself ends here, at 150
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 150, buttons: 1 }),
)
const sizeAtRelease = sizingUpdates[sizingUpdates.length - 1]!['firstName']

// the button came up unobserved, and the pointer wandered to 400 before the
// next move reported it
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 400, buttons: 0 }),
)

expect(table.atoms.columnResizing.get().isResizingColumn).toBe(false)
expect(sizingUpdates[sizingUpdates.length - 1]!['firstName']).toBe(
sizeAtRelease,
)
})

it('should end the resize when the released button is not the one that started it', () => {
// `buttons` is a bitmask: 0 means nothing is held, but a nonzero value does
// not mean the drag is still alive. If the left button was released
// unobserved and the same move reports the right button instead, the mask is
// 2 while the gesture that started the resize is long over.
const table = makeTable(1, {
columnResizeMode: 'onChange',
})
const sizingUpdates: Array<Record<string, number>> = []
table.options.onColumnSizingChange = (updater) => {
sizingUpdates.push(
typeof updater === 'function'
? updater(table.atoms.columnSizing.get())
: updater,
)
}

const header = createTestResizeHeader(table)
const handler = header_getResizeHandler(header as any, document)
handler({ type: 'mousedown', clientX: 100, button: 0 })

// the drag itself ends here, at 150
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 150, buttons: 1 }),
)
const sizeAtRelease = sizingUpdates[sizingUpdates.length - 1]!['firstName']

// the left button came up unobserved; the pointer keeps moving with only the
// right button held
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 400, buttons: 2 }),
)

expect(table.atoms.columnResizing.get().isResizingColumn).toBe(false)
expect(sizingUpdates[sizingUpdates.length - 1]!['firstName']).toBe(
sizeAtRelease,
)
})

it.each([
['middle', 1, 4],
['right', 2, 2],
])(
'should keep resizing while a %s-button drag holds its own button',
(_name, button, mask) => {
// `button` and `buttons` order the non-primary buttons differently: the
// middle button is `button: 1` but `buttons: 4`, the right one `button: 2`
// and `buttons: 2`. A mask derived by shifting the button value would end
// a middle-button drag on its first move and a right-button drag on one
// that still holds it.
const table = makeTable(1, {
columnResizeMode: 'onChange',
})
const sizingUpdates: Array<Record<string, number>> = []
table.options.onColumnSizingChange = (updater) => {
sizingUpdates.push(
typeof updater === 'function'
? updater(table.atoms.columnSizing.get())
: updater,
)
}

const header = createTestResizeHeader(table)
const handler = header_getResizeHandler(header as any, document)
handler({ type: 'mousedown', clientX: 100, button })

document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 400, buttons: mask }),
)
expect(table.atoms.columnResizing.get().isResizingColumn).toBe(
'firstName',
)
// the drag is still tracking the pointer, not merely still flagged as active
expect(table.atoms.columnResizing.get().deltaOffset).toBe(300)

// the initiating button comes up; nothing of the gesture is left
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: 600, buttons: 0 }),
)
expect(table.atoms.columnResizing.get().isResizingColumn).toBe(false)
},
)
})

describe('passiveEventSupported', () => {
Expand Down