From 96a75e7dfbf012a2fae620d627d214e77ca49d1b Mon Sep 17 00:00:00 2001 From: xarmian Date: Fri, 25 Sep 2026 21:48:05 -0400 Subject: [PATCH] fix(web): a stored field value String() cannot convert no longer crashes the list, board, table, child items or shared views (BUG-3052 unit 1) (#1553) An item's field values are JSON of any shape, whatever the declared type. A stored object like {"toString":0} makes String(v), template interpolation, new Date(v) and Svelte text conversion throw "Cannot convert object to primitive value". The census on the BUG-3052 trail found 13 such throw sites. The worst was laneValue, the helper every grouped view reads lanes through. This adds the read-side layer the lead ruled for, web/src/lib/fields/fieldShape.ts: - safeString: exactly String, but total. For a value String cannot convert, it falls back to the value's JSON text. - safeText: null/undefined read as '', otherwise safeString. Routed through it: - laneValue, which covers list grouping, board buckets and drops, relation lanes and shared-board grouping; - the TableView sort and plain text cell; - the ChildItems group key; - shareView.matchesFilter and the /s/[token] field formatter; - QuickActionsMenu and quick-action-preview (categoricalTemplateValue, fields, plan/phase); - the playbooks sort and trigger/scope chips; - three ItemDetail sites; - ChildChart's parent dates, where only objects are converted and every primitive reaches new Date untouched. Output is byte-identical for every value the old code could convert. Rendering garbage, type-changing writes and misfilters are units 2 and 3; the server SQL is split out as BUG-3221. Tests cover each throw class (String conversion, a string method on a non-string, new Date, Svelte text), plus the helpers' own contract. With every changed source file swapped back to origin/main, the new legs fail. --- web/src/lib/collections/boardColumns.test.ts | 11 ++++ web/src/lib/collections/boardColumns.ts | 9 ++- .../ChildChart.shape.svelte.test.ts | 32 +++++++++ web/src/lib/components/ChildChart.svelte | 14 +++- web/src/lib/components/ChildItems.svelte | 8 ++- .../lib/components/ChildItems.svelte.test.ts | 40 +++++++++++ .../TableView.listValue.svelte.test.ts | 15 +++++ .../components/collections/TableView.svelte | 11 +++- .../components/common/QuickActionsMenu.svelte | 7 +- .../lib/components/items/ItemDetail.svelte | 9 ++- .../lib/components/share/shareView.test.ts | 9 +++ web/src/lib/components/share/shareView.ts | 12 ++-- web/src/lib/fields/fieldShape.test.ts | 41 ++++++++++++ web/src/lib/fields/fieldShape.ts | 66 +++++++++++++++++++ .../utils/quick-action-preview.shape.test.ts | 20 ++++++ web/src/lib/utils/quick-action-preview.ts | 11 ++-- .../[workspace]/playbooks/+page.svelte | 9 ++- web/src/routes/s/[token]/+page.svelte | 8 ++- 18 files changed, 303 insertions(+), 29 deletions(-) create mode 100644 web/src/lib/components/ChildChart.shape.svelte.test.ts create mode 100644 web/src/lib/fields/fieldShape.test.ts create mode 100644 web/src/lib/fields/fieldShape.ts create mode 100644 web/src/lib/utils/quick-action-preview.shape.test.ts 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 @@