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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `sorting`'s `hydrate` checked `Array.isArray` and then cast, so a null entry
in the array threw on `columnId`. Entries that do not name a column and a
direction are now dropped.
- A column width that the layout cannot draw no longer destroys the grid. A
`NaN` or `Infinity` width reached the CSS custom property as `NaNpx`, which
makes `grid-template-columns` invalid at computed-value time: the browser
dropped the declaration, every column folded into one track and the cells
stacked down the page, with nothing thrown and nothing logged. Such a width
is now refused where it becomes CSS, so no route reaches the property: a
container measured as `NaN`, a definition written with `width: NaN` or
`flex: NaN`, a snapshot carrying one, and `setWidth`/`setWidths`, where
`clamp` and `Math.round` had been passing `NaN` straight through. A track
that cannot be drawn falls back to the column's minimum.
- Two rows sharing an id no longer fail silently. The row index keeps the last
row for a repeated id, so an edit addressed to the row the user opened was
written to the other one and nothing said so. A development build now names
the ids that collided. Production pays one integer comparison for the check
and nothing more; working out which ids repeated happens only in a build that
will print it.
- `setState` no longer throws on a corrupt `columns` slice. An `order` that was
not an array reached `.filter` and threw inside the caller's own call. Values
are now read as carefully as keys already were: only string ids order the
columns, only real booleans hide a column or fold a group, and a `columns`
slice that is not an object is taken as nothing at all.

## [1.3.0] - 2026-08-24

Expand Down
8 changes: 7 additions & 1 deletion src/lib/core/columns/column-model.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,11 @@ export class ColumnModel<TRow> {
setWidth(id: string, width: number): number {
const column = this.get(id)
if (!column) return 0
// `clamp` and `Math.round` both carry `NaN` through, and a width the
// layout cannot draw takes `grid-template-columns` down with it rather
// than making one column wrong. Refuse it and say what the width still
// is.
if (!Number.isFinite(width)) return this.widthOf(id) ?? 0
const clamped = Math.round(clamp(width, column.minWidth, column.maxWidth))
this.widthOverrides = { ...this.widthOverrides, [id]: clamped }
return clamped
Expand All @@ -250,7 +255,8 @@ export class ColumnModel<TRow> {
const next = { ...this.widthOverrides }
for (const [id, width] of Object.entries(widths)) {
const column = this.get(id)
if (column) next[id] = Math.round(clamp(width, column.minWidth, column.maxWidth))
if (!column || !Number.isFinite(width)) continue
next[id] = Math.round(clamp(width, column.minWidth, column.maxWidth))
}
this.widthOverrides = next
}
Expand Down
30 changes: 30 additions & 0 deletions src/lib/core/columns/column-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,33 @@ describe('ColumnModel runtime state', () => {
expect(model.visible.map((column) => column.id)).toEqual(['x', 'y', 'z'])
})
})

describe('a width the layout could not draw', () => {
it('refuses it and reports the width the column still has', () => {
const model = createModel()
expect(model.setWidth('a', Number.NaN)).toBe(100)
expect(model.setWidth('a', Number.POSITIVE_INFINITY)).toBe(100)
expect(model.widthOverrides).toEqual({})
expect(model.widthOf('a')).toBe(100)
})

it('reports zero for a column that is not there, as before', () => {
expect(createModel().setWidth('missing', Number.NaN)).toBe(0)
})

it('skips it in a batch and keeps the rest', () => {
const model = createModel()
model.setWidths({ a: Number.NaN, c: 150, d: Number.NEGATIVE_INFINITY })

expect(model.widthOverrides).toEqual({ c: 150 })
expect(model.widthOf('a')).toBe(100)
expect(model.widthOf('d')).toBe(90)
})

it('leaves a width already set alone', () => {
const model = createModel()
model.setWidth('a', 250)
expect(model.setWidth('a', Number.NaN)).toBe(250)
expect(model.widthOf('a')).toBe(250)
})
})
56 changes: 56 additions & 0 deletions src/lib/core/columns/column-sizing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,59 @@ describe('toStyleString', () => {
expect(toStyleString({ '--a': '1px', '--b': '2px' })).toBe('--a: 1px; --b: 2px')
})
})

describe('a track the browser could not parse', () => {
// Every one of these produced `NaNpx`, which makes grid-template-columns
// invalid at computed-value time: the declaration is dropped, the columns
// fold into one track and the cells stack down the page.
it('never writes a non-finite width, whichever way it arrived', () => {
const fromOverride = buildColumnCssVars([createColumnState({ id: 'a' })], null, null, {
a: Number.NaN
})
const fromDefinition = buildColumnCssVars([
createColumnState({ id: 'a', width: Number.NaN })
])
const fromResolved = buildColumnCssVars([createColumnState({ id: 'a' })], [Number.NaN])

for (const vars of [fromOverride, fromDefinition, fromResolved]) {
expect(vars['--dg-col-a-w']).not.toContain('NaN')
}
})

it('falls back to the column minimum rather than to nothing', () => {
const vars = buildColumnCssVars(
[createColumnState({ id: 'a', minWidth: 64 })],
[Number.NaN]
)
expect(vars['--dg-col-a-w']).toBe('64px')
})

it('reads an unusable flex weight as one', () => {
expect(columnTrackSize(createColumnState({ id: 'a', flex: Number.NaN }))).toBe(
'minmax(40px, 1fr)'
)
})

it('reads an unusable minimum as a width it can draw', () => {
expect(columnTrackSize(createColumnState({ id: 'a', minWidth: Number.NaN }))).toBe(
'minmax(100px, 1fr)'
)
})

it('writes a pin offset it can draw', () => {
const vars = buildColumnCssVars([createColumnState({ id: 'a', pinned: 'left' })], [100], {
a: Number.NaN
})
expect(vars['--dg-col-a-pin']).toBe('0px')
})

it('leaves a healthy track exactly as it was', () => {
expect(columnTrackSize(createColumnState({ id: 'a', width: 120 }))).toBe('120px')
expect(columnTrackSize(createColumnState({ id: 'a', flex: 2, minWidth: 80 }))).toBe(
'minmax(80px, 2fr)'
)
expect(buildColumnCssVars([createColumnState({ id: 'a' })], [220])['--dg-col-a-w']).toBe(
'220px'
)
})
})
30 changes: 26 additions & 4 deletions src/lib/core/columns/column-sizing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,27 @@ export function createColumnState<TRow>(

export type WidthOverrides = Record<string, number>

/** What a track falls back to when nothing usable is left to fall back on. */
const LAST_RESORT_WIDTH = 100

/**
* The last gate before a number becomes CSS. A non-finite one reaches the
* custom property as `NaNpx`, and `grid-template-columns: var(...)` is then
* invalid at computed-value time: the browser drops the whole declaration,
* every column folds into a single track and the cells stack down the page,
* with nothing thrown and nothing logged.
*
* The width setters and the snapshot boundary each refuse such a value on the
* way in. This is here because they are not the only way in - a container
* measured as `NaN` and a column definition written with `width: NaN` reach
* the same line without passing either - and because a track that is merely
* the wrong size is a far smaller failure than a grid that will not lay out.
*/
function px(value: number | undefined, fallback: number): string {
if (typeof value === 'number' && Number.isFinite(value)) return `${value}px`
return `${Number.isFinite(fallback) ? fallback : LAST_RESORT_WIDTH}px`
}

function effectiveWidth<TRow>(
column: ColumnState<TRow>,
overrides: WidthOverrides
Expand All @@ -55,9 +76,10 @@ export function columnTrackSize<TRow>(
): string {
const width = effectiveWidth(column, overrides)
if (width !== undefined) {
return `${clamp(width, column.minWidth, column.maxWidth)}px`
return px(clamp(width, column.minWidth, column.maxWidth), column.minWidth)
}
return `minmax(${column.minWidth}px, ${column.flex ?? 1}fr)`
const flex = typeof column.flex === 'number' && Number.isFinite(column.flex) ? column.flex : 1
return `minmax(${px(column.minWidth, LAST_RESORT_WIDTH)}, ${flex}fr)`
}

export function trackWidthEstimates<TRow>(
Expand Down Expand Up @@ -106,10 +128,10 @@ export function buildColumnCssVars<TRow>(
const vars: Record<string, string> = {}
visible.forEach((column, index) => {
vars[column.cssVar] = resolvedWidths
? `${resolvedWidths[index]}px`
? px(resolvedWidths[index], column.minWidth)
: columnTrackSize(column, overrides)
if (column.pinned && pins) {
vars[column.pinVar] = `${pins[column.id] ?? 0}px`
vars[column.pinVar] = px(pins[column.id], 0)
}
})
vars['--dg-grid-template'] = visible.map((column) => `var(${column.cssVar})`).join(' ')
Expand Down
78 changes: 78 additions & 0 deletions src/lib/core/grid/row-node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { buildRowNodes, nodesById } from './row-node.js'

interface Row {
id: string
name: string
}

const build = (rows: Row[]) => buildRowNodes(rows, (row) => row.id)

afterEach(() => {
vi.restoreAllMocks()
})

describe('nodesById', () => {
it('keys every row by its id', () => {
const index = nodesById(
build([
{ id: 'a', name: 'first' },
{ id: 'b', name: 'second' }
])
)

expect(index.size).toBe(2)
expect(index.get('a')?.row.name).toBe('first')
})

it('says nothing when the ids are unique', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
nodesById(
build([
{ id: 'a', name: 'first' },
{ id: 'b', name: 'second' }
])
)

expect(warn).not.toHaveBeenCalled()
})

it('names the ids that collided', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
nodesById(
build([
{ id: 'a', name: 'first' },
{ id: 'a', name: 'second' },
{ id: 'b', name: 'third' }
])
)

expect(warn).toHaveBeenCalledTimes(1)
const message = warn.mock.calls[0]?.[0] as string
expect(message).toContain('getRowId')
expect(message).toContain('a')
expect(message).toContain('3 rows share 2 ids')
})

it('caps the list rather than printing every id of a broken set', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const rows = Array.from({ length: 20 }, (_, i) => ({ id: `dup-${i % 10}`, name: `r${i}` }))
nodesById(build(rows))

const message = warn.mock.calls[0]?.[0] as string
expect(message).toContain('and 5 more')
})

it('keeps the last row for a repeated id, which is why it warns', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const index = nodesById(
build([
{ id: 'a', name: 'first' },
{ id: 'a', name: 'second' }
])
)

expect(index.size).toBe(1)
expect(index.get('a')?.row.name).toBe('second')
})
})
40 changes: 40 additions & 0 deletions src/lib/core/grid/row-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,42 @@ export function buildRowNodes<TRow>(
return nodes
}

const DUPLICATES_SHOWN = 5

/**
* Two rows sharing an id is the app's bug, but the grid fails at it silently
* and in the worst possible way: the map keeps the last row for an id, and an
* edit addressed to the row the user opened is written to the other one. Said
* out loud in development, where it is cheap, rather than left to be found in
* the data later.
*
* Repeated on every rebuild on purpose. Nothing here remembers what it already
* reported, so no state outlives a grid, and the data stays wrong until it is
* fixed.
*/
function warnDuplicateIds<TRow>(nodes: RowNode<TRow>[], unique: number): void {
const seen = new Set<string>()
const repeated = new Set<string>()
for (const node of nodes) {
if (seen.has(node.id)) repeated.add(node.id)
else seen.add(node.id)
}

const shown = [...repeated].slice(0, DUPLICATES_SHOWN).join(', ')
const rest = repeated.size - DUPLICATES_SHOWN
// The one console statement in the library. A silent wrong write is worse
// than a line in a development console, and there is no other channel: the
// grid has no logger, and an error would take down an app over data it can
// still draw.
// eslint-disable-next-line no-console
console.warn(
`[sv5ui-datagrid] getRowId returned the same id for more than one row: ${shown}` +
(rest > 0 ? ` and ${rest} more` : '') +
`. ${nodes.length} rows share ${unique} ids, so an edit or a selection meant for ` +
'one of them will land on another. Give every row an id of its own.'
)
}

/**
* Rebuilt whole by the derived that owns it, so a plain Map suffices. Filled
* by hand rather than from `map`, which would allocate a second array of pairs
Expand All @@ -19,6 +55,10 @@ export function buildRowNodes<TRow>(
export function nodesById<TRow>(nodes: RowNode<TRow>[]): ReadonlyMap<string, RowNode<TRow>> {
const index = new Map<string, RowNode<TRow>>()
for (const node of nodes) index.set(node.id, node)
// The whole check, in production: one integer against another. Working out
// which ids collided costs a second pass, and only a build that will print
// it pays for that.
if (index.size !== nodes.length && import.meta.env?.DEV) warnDuplicateIds(nodes, index.size)
return index
}

Expand Down
61 changes: 61 additions & 0 deletions src/lib/core/grid/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,64 @@ describe('folded groups in a snapshot', () => {
).toEqual({})
})
})

describe('a column snapshot that has been outside the grid', () => {
const knownIds = ['a', 'b']

it('ignores an order that is not a list', () => {
expect(resolveColumnSnapshot({ order: 'a' }, knownIds).orderIds).toEqual([])
expect(resolveColumnSnapshot({ order: 42 }, knownIds).orderIds).toEqual([])
})

it('keeps only the string ids in an order', () => {
expect(resolveColumnSnapshot({ order: [null, 7, 'b'] }, knownIds).orderIds).toEqual([
'b',
'a'
])
})

it('drops a width the layout could not draw', () => {
const widths = {
a: Number.NaN,
b: Number.POSITIVE_INFINITY
}
expect(resolveColumnSnapshot({ widths }, knownIds).widthOverrides).toEqual({})
})

it('drops a width that is not a number at all', () => {
expect(
resolveColumnSnapshot({ widths: { a: '120', b: null } }, knownIds).widthOverrides
).toEqual({})
})

it('keeps a width it can draw, negative included, since the model clamps it', () => {
expect(
resolveColumnSnapshot({ widths: { a: 120, b: -5 } }, knownIds).widthOverrides
).toEqual({ a: 120, b: -5 })
})

it('keeps only real booleans for hidden and collapsed', () => {
const stored = { hidden: { a: 'yes', b: true }, collapsed: { g: 1 } }
const resolved = resolveColumnSnapshot(stored, knownIds, ['g'])
expect(resolved.hiddenOverrides).toEqual({ b: true })
expect(resolved.collapsedGroups).toEqual({})
})

it('reads an unpinnable side as unpinned rather than dropping the entry', () => {
expect(
resolveColumnSnapshot({ pinned: { a: 'middle' } }, knownIds).pinnedOverrides
).toEqual({ a: null })
})

it('takes a columns slice that is not an object as nothing at all', () => {
for (const stored of ['nope', 42, ['a'], null, undefined]) {
expect(resolveColumnSnapshot(stored, knownIds)).toEqual({
orderIds: [],
widthOverrides: {},
hiddenOverrides: {},
pinnedOverrides: {},
collapsedGroups: {}
})
}
})
})
Loading