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
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,6 @@ export function column_getAutoSortFn<
const sortFns: Record<string, SortFn<TFeatures, TData>> | undefined =
column.table._rowModelFns.sortFns

let sortFn: SortFn<TFeatures, TData> | undefined

const firstRows = column.table.getFilteredRowModel().flatRows.slice(0, 10)

let isString = false
Expand All @@ -99,23 +97,27 @@ export function column_getAutoSortFn<
const value = firstRows[i]!.getValue(column.id)

if (Object.prototype.toString.call(value) === '[object Date]') {
sortFn = sortFns?.datetime
if (sortFns?.datetime) {
return sortFns.datetime
}
}

if (typeof value === 'string') {
isString = true

if (value.split(reSplitAlphaNumeric).length > 1) {
sortFn = sortFns?.alphanumeric
if (sortFns?.alphanumeric) {
return sortFns.alphanumeric
}
}
}
}

if (isString) {
sortFn = sortFns?.text
return sortFns?.text ?? sortFn_basic
}

return sortFn ?? sortFn_basic
return sortFn_basic
}

/**
Expand Down
43 changes: 33 additions & 10 deletions packages/table-core/src/fns/sortFns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,27 +151,38 @@ function toString(a: any) {
// It handles numbers, mixed alphanumeric combinations, and even
// null, undefined, and Infinity
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)
const a = aStr.split(reSplitAlphaNumeric)
const b = bStr.split(reSplitAlphaNumeric)

let ai = 0
let bi = 0
const aLen = a.length
const bLen = b.length

while (ai < aLen && bi < bLen) {
// Skip the empty boundary chunks that .filter(Boolean) used to remove
if (!a[ai]) {
ai++
continue
}
if (!b[bi]) {
bi++
continue
}

const aa = a[ai++]!
const bb = b[bi++]!

// Chunks are either all-digit (parseInt always succeeds) or digit-free
// (parseInt is always NaN), so NaN-ness fully classifies each chunk
const an = parseInt(aa, 10)
const bn = parseInt(bb, 10)

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

// Both are string
if (isNaN(combo[0]!)) {
if (aIsNaN && bIsNaN) {
if (aa > bb) {
return 1
}
Expand All @@ -181,9 +192,9 @@ function compareAlphanumeric(aStr: string, bStr: string) {
continue
}

// One is a string, one is a number
if (isNaN(combo[1]!)) {
return isNaN(an) ? -1 : 1
// One is a string, one is a number — the string chunk sorts first
if (aIsNaN || bIsNaN) {
return aIsNaN ? -1 : 1
}

// Both are numbers
Expand All @@ -195,7 +206,19 @@ function compareAlphanumeric(aStr: string, bStr: string) {
}
}

return aLen - ai - (bLen - bi)
// One side is exhausted — compare the counts of remaining non-empty chunks
let remaining = 0
for (; ai < aLen; ai++) {
if (a[ai]) {
remaining++
}
}
for (; bi < bLen; bi++) {
if (b[bi]) {
remaining--
}
}
return remaining
}

// Exports
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import {
constructTable,
coreFeatures,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_basic,
sortFn_datetime,
sortFn_text,
sortFns,
tableFeatures,
} from '../../../../src'
import { column_getAutoSortFn } from '../../../../src/static-functions'
import { storeReactivityBindings } from '../../../../src/store-reactivity-bindings'
import type { ColumnDef } from '../../../../src'

type Sample = {
plainText: string
alphaNum: string
createdAt: Date
amount: number
}

const features = tableFeatures({ ...coreFeatures, rowSortingFeature })

const sampleKeys: Array<keyof Sample> = [
'plainText',
'alphaNum',
'createdAt',
'amount',
]

function generateAutoSortTestTable(data: Array<Sample>) {
const columns: Array<ColumnDef<typeof features, Sample, any>> =
sampleKeys.map((key) => ({ accessorKey: key, id: key }))

const table = constructTable<typeof features, Sample>({
data,
columns,
features: {
...features,
coreReativityFeature: storeReactivityBindings(),
},
})

// Normally assigned by createSortedRowModel when the sorted row model is wired up
table._rowModelFns.sortFns = sortFns

return table
}

describe('column_getAutoSortFn', () => {
const data: Array<Sample> = [
{
plainText: 'apple',
alphaNum: 'item1',
createdAt: new Date('2024-01-01'),
amount: 1,
},
{
plainText: 'banana',
alphaNum: 'item10',
createdAt: new Date('2024-02-01'),
amount: 2,
},
]

it('selects datetime for Date values', () => {
const table = generateAutoSortTestTable(data)
const column = table.getColumn('createdAt')!

expect(column_getAutoSortFn(column)).toBe(sortFn_datetime)
})

it('selects alphanumeric for strings mixing text and numbers', () => {
// Regression: the text fallback used to clobber the alphanumeric match
const table = generateAutoSortTestTable(data)
const column = table.getColumn('alphaNum')!

expect(column_getAutoSortFn(column)).toBe(sortFn_alphanumeric)
})

it('selects text for plain strings', () => {
const table = generateAutoSortTestTable(data)
const column = table.getColumn('plainText')!

expect(column_getAutoSortFn(column)).toBe(sortFn_text)
})

it('falls back to basic for non-string, non-date values', () => {
const table = generateAutoSortTestTable(data)
const column = table.getColumn('amount')!

expect(column_getAutoSortFn(column)).toBe(sortFn_basic)
})

it('falls back to basic when no rows exist', () => {
const table = generateAutoSortTestTable([])
const column = table.getColumn('plainText')!

expect(column_getAutoSortFn(column)).toBe(sortFn_basic)
})

it('falls back to text when alphanumeric is not registered', () => {
const table = generateAutoSortTestTable(data)
table._rowModelFns.sortFns = { text: sortFn_text }
const column = table.getColumn('alphaNum')!

expect(column_getAutoSortFn(column)).toBe(sortFn_text)
})
})
104 changes: 104 additions & 0 deletions packages/table-core/tests/unit/fns/sortFns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,110 @@ describe('sortFn_basic', () => {
})
})

describe('compareAlphanumeric boundary chunks (filter-removal regression)', () => {
// The inline empty-chunk skipping must match the old `.filter(Boolean)`
// behavior; empty chunks occur where a string starts/ends with digits.

it('handles leading digit groups', () => {
// Number chunk vs string chunk at position 0 — string sorts first
expect(cmp(sortFn_alphanumeric, '1abc', 'abc1')).toBe(1)
expect(cmp(sortFn_alphanumeric, 'abc1', '1abc')).toBe(-1)
expect(cmp(sortFn_alphanumeric, '2abc', '10abc')).toBe(-1)
})

it('handles pure digit strings (leading and trailing empties)', () => {
expect(cmp(sortFn_alphanumeric, '12', '12')).toBe(0)
expect(cmp(sortFn_alphanumeric, '2', '10')).toBe(-1)
expect(cmp(sortFn_alphanumeric, '10', '2')).toBe(1)
})

it('treats leading zeros as numerically equal', () => {
expect(cmp(sortFn_alphanumeric, 'item007', 'item7')).toBe(0)
})

it('counts only non-empty chunks in the prefix tail', () => {
// a exhausts past its trailing empty; b has one real chunk remaining
expect(cmp(sortFn_alphanumeric, '1', '1abc')).toBe(-1)
expect(cmp(sortFn_alphanumeric, '1abc', '1')).toBe(1)
expect(cmp(sortFn_alphanumeric, 'abc', 'abc123')).toBe(-1)
})

it('handles empty vs non-empty strings', () => {
expect(cmp(sortFn_alphanumeric, '', 'a')).toBe(-1)
expect(cmp(sortFn_alphanumeric, 'a', '')).toBe(1)
})

it('matches the previous filter-based implementation across all vocab pairs', () => {
// Reference: the implementation before the allocation refactor, verbatim
const reference = (aStr: string, bStr: string): number => {
const a = aStr.split(/([0-9]+)/gm).filter(Boolean)
const b = bStr.split(/([0-9]+)/gm).filter(Boolean)
let ai = 0
let bi = 0
const aLen = a.length
const bLen = b.length
while (ai < aLen && bi < bLen) {
const aa = a[ai++]!
const bb = b[bi++]!
const an = parseInt(aa, 10)
const bn = parseInt(bb, 10)
const combo = [an, bn].sort()
if (isNaN(combo[0]!)) {
if (aa > bb) return 1
if (bb > aa) return -1
continue
}
if (isNaN(combo[1]!)) {
return isNaN(an) ? -1 : 1
}
if (an > bn) return 1
if (bn > an) return -1
}
return aLen - ai - (bLen - bi)
}

const vocab = [
'',
'a',
'ab',
'1',
'0',
'12',
'007',
'7',
'1a',
'a1',
'1a1',
'a1a',
'1a2b',
'a1b2',
'12ab34',
'ab12cd',
'item2',
'item10',
'2item',
'10item',
'a007b',
'a7b',
'nan',
'infinity',
'999999999999999999999999999999',
]

for (const a of vocab) {
for (const b of vocab) {
// Case-sensitive variant is a pure passthrough for string inputs
const actual = sortFn_alphanumericCaseSensitive(
makeRow(a),
makeRow(b),
'col',
)
expect(actual, `compare("${a}", "${b}")`).toBe(reference(a, b))
}
}
})
})

describe('sortFn_datetime', () => {
it('returns 0 for equal dates', () => {
const d = new Date(2026, 1, 1)
Expand Down
Loading
Loading