diff --git a/web/src/lib/collections/boardColumns.test.ts b/web/src/lib/collections/boardColumns.test.ts index 99806fedd..139965144 100644 --- a/web/src/lib/collections/boardColumns.test.ts +++ b/web/src/lib/collections/boardColumns.test.ts @@ -193,3 +193,14 @@ describe('lanes named after an Object.prototype member (BUG-3208)', () => { }); } }); + +describe('a stored value String() cannot convert (BUG-3052)', () => { + const HOSTILE = JSON.parse('{"toString":0}'); + it('laneValue does not throw, and names the value by its JSON', () => { + expect(laneValue(HOSTILE)).toBe('{"toString":0}'); + }); + it('bucketByColumn keeps the item, in Uncategorized', () => { + const result = bucketByColumn([item('h', { status: HOSTILE })], 'status', ['open']); + expect(result.get(UNCATEGORIZED)!.map((i) => i.id)).toEqual(['h']); + }); +}); diff --git a/web/src/lib/collections/boardColumns.ts b/web/src/lib/collections/boardColumns.ts index d9dd0dcde..da5adea35 100644 --- a/web/src/lib/collections/boardColumns.ts +++ b/web/src/lib/collections/boardColumns.ts @@ -12,6 +12,7 @@ // lane writes back to the field (clearing it). import type { Item } from '$lib/types'; import { parseFields } from '$lib/types'; +import { safeText } from '$lib/fields/fieldShape'; /** Lane key for items with no (or an unrecognised) group value. */ export const UNCATEGORIZED = ''; @@ -38,9 +39,11 @@ export const UNCATEGORIZED = ''; * is what stops the two halves drifting apart again. */ export function laneValue(raw: unknown): string { - if (typeof raw === 'string') return raw; - if (raw == null) return ''; - return String(raw); + // `safeText`, not `String` (BUG-3052): a stored `{"toString":0}` made + // `String(raw)` throw here, and every grouped view reads its lanes through + // this function, so one hostile value took the whole list or board down. + // Identical to `String` for every value it could already convert. + return safeText(raw); } /** diff --git a/web/src/lib/components/ChildChart.shape.svelte.test.ts b/web/src/lib/components/ChildChart.shape.svelte.test.ts new file mode 100644 index 000000000..f9e2e8e47 --- /dev/null +++ b/web/src/lib/components/ChildChart.shape.svelte.test.ts @@ -0,0 +1,32 @@ +// BUG-3052 unit 1, the `new Date` throw class: the parent's start/end date are +// stored field values, and `new Date({"toString":0})` throws, which took the +// whole children section's chart down. +import { afterEach, describe, expect, it } from 'vitest'; +import { flushSync, mount, unmount } from 'svelte'; +import ChildChart from './ChildChart.svelte'; + +const kid = (id: string) => + ({ id, slug: id, title: id, collection_slug: 'tasks', fields: '{"status":"open"}', + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-02T00:00:00Z' }) as never; + +let instance: ReturnType | undefined; +let target: HTMLElement | undefined; +afterEach(() => { + if (instance) unmount(instance); + instance = undefined; + target?.remove(); +}); + +describe('ChildChart with a stored date String() cannot convert (BUG-3052)', () => { + it('mounts, rather than throwing', () => { + const hostile = JSON.parse('{"toString":0}'); + target = document.body.appendChild(document.createElement('div')); + expect(() => { + instance = mount(ChildChart, { + target: target!, + props: { children: [kid('a'), kid('b')], startDate: hostile, endDate: hostile, wsSlug: 'ws' }, + }); + flushSync(); + }).not.toThrow(); + }); +}); diff --git a/web/src/lib/components/ChildChart.svelte b/web/src/lib/components/ChildChart.svelte index a1846e9e9..f69ed7f02 100644 --- a/web/src/lib/components/ChildChart.svelte +++ b/web/src/lib/components/ChildChart.svelte @@ -1,4 +1,5 @@