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
5 changes: 5 additions & 0 deletions .changeset/fix-alphanumeric-sorting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/table-core': patch
---

Fix inconsistent alphanumeric sorting where letters and digits were ordered differently depending on position in the string
78 changes: 44 additions & 34 deletions packages/table-core/src/sortingFns.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { SortingFn } from './features/RowSorting'

/**
* @deprecated No longer used internally. Kept for backwards compatibility.
*/
export const reSplitAlphaNumeric = /([0-9]+)/gm

const alphanumeric: SortingFn<any> = (rowA, rowB, columnId) => {
Expand Down Expand Up @@ -67,51 +70,58 @@ function toString(a: any) {
return ''
}

function isDigitChar(ch: string): boolean {
return ch >= '0' && ch <= '9'
}

// Mixed sorting is slow, but very inclusive of many edge cases.
// It handles numbers, mixed alphanumeric combinations, and even
// null, undefined, and Infinity
//
// Uses a character-by-character approach to ensure consistent ordering
// between letters and digits regardless of their position in the string.
// Letters always sort before digits (e.g. "appleA" < "apple1").
function compareAlphanumeric(aStr: string, bStr: string) {
// Split on number groups, but keep the delimiter
// Then remove falsey split values
const a = aStr.split(reSplitAlphaNumeric).filter(Boolean)
const b = bStr.split(reSplitAlphaNumeric).filter(Boolean)

// While
while (a.length && b.length) {
const aa = a.shift()!
const bb = b.shift()!

const an = parseInt(aa, 10)
const bn = parseInt(bb, 10)

const combo = [an, bn].sort()

// Both are string
if (isNaN(combo[0]!)) {
if (aa > bb) {
let ai = 0
let bi = 0

while (ai < aStr.length && bi < bStr.length) {
const aIsDigit = isDigitChar(aStr[ai]!)
const bIsDigit = isDigitChar(bStr[bi]!)

if (aIsDigit && bIsDigit) {
// Both are digits - extract full numeric sequences and compare as numbers
let aNumStr = ''
let bNumStr = ''
while (ai < aStr.length && isDigitChar(aStr[ai]!)) {
aNumStr += aStr[ai]
ai++
}
while (bi < bStr.length && isDigitChar(bStr[bi]!)) {
bNumStr += bStr[bi]
bi++
}
const diff = parseInt(aNumStr, 10) - parseInt(bNumStr, 10)
if (diff !== 0) {
return diff
}
} else if (aIsDigit !== bIsDigit) {
// One is a digit, one is a letter - letters sort before digits
return aIsDigit ? 1 : -1
} else {
// Both are non-digit characters - compare lexicographically
if (aStr[ai]! > bStr[bi]!) {
return 1
}
if (bb > aa) {
if (aStr[ai]! < bStr[bi]!) {
return -1
}
continue
}

// One is a string, one is a number
if (isNaN(combo[1]!)) {
return isNaN(an) ? -1 : 1
}

// Both are numbers
if (an > bn) {
return 1
}
if (bn > an) {
return -1
ai++
bi++
}
}

return a.length - b.length
return aStr.length - bStr.length
}

// Exports
Expand Down