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.
This commit is contained in:
xarmian
2026-09-25 21:48:05 -04:00
committed by GitHub
parent 9d5abc7bad
commit 96a75e7dfb
18 changed files with 303 additions and 29 deletions
@@ -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']);
});
});
+6 -3
View File
@@ -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);
}
/**
@@ -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<typeof mount> | 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();
});
});
+12 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { safeText } from '$lib/fields/fieldShape';
import type { Item } from '$lib/types';
import { countedChildren, isChildDone } from '$lib/collections/childProgress';
import { collectionStore } from '$lib/stores/collections.svelte';
@@ -14,6 +15,15 @@
}
let { children, startDate, endDate, wsSlug }: Props = $props();
// The parent's `start_date` / `end_date` are stored field values, typed
// `string` here but not guaranteed to be one (BUG-3052). `new Date` threw on a
// stored `{"toString":0}`. Only an OBJECT (arrays included) is converted, to
// its safe text; every primitive — a date string, an epoch number, even a
// boolean — reaches `new Date` untouched, exactly as before.
function dateInput(v: unknown): unknown {
return typeof v === 'object' && v !== null ? safeText(v) : v;
}
// Each child by its own collection's done field and terminal/abandoned
// values. An abandoned child is not part of the work any more and leaves
// the burndown entirely (BUG-3195).
@@ -49,7 +59,7 @@
// ── Determine start date ──────────────────────────────────────────
let start: Date;
if (startDate) {
start = new Date(startDate);
start = new Date(dateInput(startDate) as string);
} else {
// Infer: earliest created_at among the counted children
let earliest = new Date(counted[0].created_at);
@@ -64,7 +74,7 @@
// ── Determine end date ────────────────────────────────────────────
let end: Date;
if (endDate) {
end = new Date(endDate);
end = new Date(dateInput(endDate) as string);
} else if (allDone && completions.length > 0) {
// All counted children done → last completion date
end = new Date(completions[completions.length - 1].date);
+7 -1
View File
@@ -14,6 +14,7 @@
import { parseFields, parseSchema, formatItemRef } from '$lib/types';
import { collectionsNotStaleFor, categoricalValueFor } from '$lib/collections/categoricalFieldValue';
import { laneKey } from '$lib/collections/boardColumns';
import { safeText } from '$lib/fields/fieldShape';
import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
import type { DndEvent } from 'svelte-dnd-action';
import {
@@ -136,7 +137,12 @@
let groups = $derived.by(() => {
const map = new SvelteMap<string, Item[]>();
for (const child of children) {
const status = parseFields(child).status ?? 'open';
// `safeText`, not the raw value (BUG-3052): the group key feeds a
// heading formatter and a `lane:` key template, both of which threw on a
// stored `{"toString":0}`, and an array or object keyed a group by
// identity, one group per child.
const raw = parseFields(child).status;
const status = raw == null ? 'open' : safeText(raw);
if (!map.has(status)) map.set(status, []);
map.get(status)!.push(child);
}
@@ -224,3 +224,43 @@ describe('a child status that names an Object.prototype member (BUG-3054)', () =
for (const label of labels) expect(label, label).toMatch(/\(1\)$/);
});
});
// BUG-3052 unit 1: a stored status that is not a string. The group heading
// called `.replace` on it (a number has none) and the lane key template
// converted it (`{"toString":0}` cannot be converted), so either one threw and
// the whole children section failed to render.
describe('a child status that is not a string (BUG-3052)', () => {
let target: HTMLElement;
let instance: ReturnType<typeof mount> | undefined;
const kid = (id: string, fields: string) => ({
id, slug: id, title: `Child ${id}`, collection_slug: 'tasks', fields,
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
});
beforeEach(() => {
childrenMock.mockClear();
childrenMock.mockImplementation(async () => [
kid('n', '{"status":5}'),
kid('h', '{"status":{"toString":0}}'),
kid('o', '{"status":"open"}'),
]);
target = document.body.appendChild(document.createElement('div'));
});
afterEach(() => {
if (instance) unmount(instance);
instance = undefined;
target.remove();
childrenMock.mockImplementation(async () => []);
});
it('renders every child and a heading for each status', async () => {
instance = mount(ChildItems, { target, props: { wsSlug: 'ws-1', itemSlug: 'p-1', itemId: 'p-1' } });
flushSync();
await vi.waitFor(() => expect(target.querySelector('.child-count')).not.toBeNull());
flushSync();
const text = target.textContent ?? '';
for (const id of ['n', 'h', 'o']) expect(text, `child ${id} is rendered`).toContain(`Child ${id}`);
expect([...target.querySelectorAll('.group-label')]).toHaveLength(3);
});
});
@@ -362,3 +362,18 @@ describe('the table chip asks the same permission question as the card (BUG-3068
expect(onStatusChange).not.toHaveBeenCalled();
});
});
// BUG-3052 unit 1: a plain text cell whose stored value Svelte's text
// conversion cannot convert. `{fields[key] ?? ''}` threw and took the table down.
describe('a text cell holding a value String() cannot convert (BUG-3052)', () => {
it('renders the row, with the value visible as its JSON', () => {
const coll = collection([{ key: 'note', label: 'Note', type: 'text' }]);
const hostile = { id: 'car-h', workspace_id: 'ws1', collection_id: 'c1', slug: 'car-h', title: 'car-h',
content: '', fields: '{"note":{"toString":0}}', tags: '[]', status: 'open',
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' } as unknown as Item;
const screen = render(TableView, { props: { items: [hostile, item('car-2', { note: 'plain' })], collection: coll } as never });
expect(screen.container.textContent).toContain('car-h');
expect(screen.container.textContent).toContain('{"toString":0}');
expect(screen.container.textContent).toContain('plain');
});
});
@@ -1,4 +1,5 @@
<script lang="ts">
import { safeText } from '$lib/fields/fieldShape';
import type { Item, Collection, FieldDef } from '$lib/types';
import { isRelationType, relationValuesOf } from '$lib/items/relationFieldTypes';
import { narrowRelationRow, relationChipFor, UNRESOLVED_TITLE } from '$lib/collections/relationGroups';
@@ -136,8 +137,10 @@
return sortDir === 'asc' ? aVal - bVal : bVal - aVal;
}
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
// safeText (BUG-3052): String threw on a stored `{"toString":0}`, which
// broke the whole sort, and so the table.
const aStr = safeText(aVal).toLowerCase();
const bStr = safeText(bVal).toLowerCase();
const cmp = aStr.localeCompare(bStr);
return sortDir === 'asc' ? cmp : -cmp;
});
@@ -351,7 +354,9 @@
<span class="cell-value">{fields[field.key]}</span>
{/if}
{:else}
<span class="cell-value">{fields[field.key] ?? ''}</span>
<!-- safeText (BUG-3052): Svelte's text conversion threw on a
stored `{"toString":0}` and took the table down. -->
<span class="cell-value">{safeText(fields[field.key])}</span>
{/if}
</div>
{/each}
@@ -1,4 +1,5 @@
<script lang="ts">
import { safeString, safeText } from '$lib/fields/fieldShape';
import { localIndex } from '$lib/stores/localIndex.svelte';
import { categoricalTemplateValue } from '$lib/utils/quick-action-preview';
import { tick, untrack } from 'svelte';
@@ -99,10 +100,10 @@
collection: collection.name,
content: item?.content ? item.content.slice(0, 200) : '',
fields: Object.entries(fields)
.map(([k, v]) => `${k}: ${v}`)
.map(([k, v]) => `${k}: ${safeString(v)}`)
.join(', '),
plan: item ? String(fields['plan'] ?? '') : '',
phase: item ? String(fields['phase'] ?? fields['plan'] ?? '') : ''
plan: item ? safeText(fields['plan']) : '',
phase: item ? safeText(fields['phase'] ?? fields['plan']) : ''
};
for (const [key, value] of Object.entries(vars)) {
@@ -1,4 +1,5 @@
<script lang="ts">
import { safeString, safeText } from '$lib/fields/fieldShape';
import { page, navigating } from '$app/state';
import { tick, onMount, onDestroy, untrack } from 'svelte';
import { api, PadApiError, isUpdateConflictError, type ImportURLResponse } from '$lib/api/client';
@@ -4770,7 +4771,8 @@
// first report of a load is ChildItems' initial fetch, which the load's
// own GET /progress already answers, so a load issues no extra request.
const sig = items
.map((i) => `${i.id}:${parseFields(i).status ?? ''}`)
// safeText (BUG-3052): `${}` threw on a stored `{"toString":0}`.
.map((i) => `${i.id}:${safeText(parseFields(i).status)}`)
.sort()
.join('|');
const sameLoad = childrenSigGen === loadGeneration;
@@ -4804,7 +4806,8 @@
function formatFieldDisplay(value: any): string {
if (value === null || value === undefined || value === '') return '—';
return String(value).replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
// safeText (BUG-3052): String threw on a stored `{"toString":0}`.
return safeText(value).replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
function relationLabel(ref?: string, title?: string, fallback?: string): string {
@@ -5899,7 +5902,7 @@
<button
type="button"
class="source-chip"
title={`Source: ${fields.pad_source_url}${fields.pad_imported_at ? `\nImported: ${fields.pad_imported_at}` : ''}`}
title={`Source: ${safeString(fields.pad_source_url)}${fields.pad_imported_at ? `\nImported: ${safeString(fields.pad_imported_at)}` : ''}`}
disabled={refreshing}
onclick={refreshFromSource}
>
@@ -198,3 +198,12 @@ describe('groupItems with falsy-but-present values', () => {
expect(total).toBe(items.length);
});
});
describe('matchesFilter on a stored value String() cannot convert (BUG-3052)', () => {
it('does not throw, and matches nothing it should not', async () => {
const { matchesFilter } = await import('./shareView');
const item = { fields: JSON.parse('{"status":{"toString":0}}') } as never;
expect(matchesFilter(item, { field: 'status', op: 'eq', value: 'open' })).toBe(false);
expect(matchesFilter(item, { field: 'status', op: 'in', value: ['open'] })).toBe(false);
});
});
+8 -4
View File
@@ -17,6 +17,7 @@
import type { FieldDef } from '$lib/types';
import { isRelationType, relationValuesOf } from '$lib/items/relationFieldTypes';
import { UNPARENTED_FILTER_FIELD } from '$lib/collections/unparentedFilter';
import { safeString, safeText } from '$lib/fields/fieldShape';
// Re-exported so existing/future imports of `UNPARENTED_FILTER_FIELD` from
// this module keep working — the canonical definition lives in
@@ -244,16 +245,19 @@ export function matchesFilter(item: PublicItem, filter: unknown): boolean {
if (typeof f.field !== 'string') return true;
if (f.field === UNPARENTED_FILTER_FIELD) return true;
const fieldVal = item.fields[f.field];
// `safeText` / `safeString`, not `String` (BUG-3052): a stored
// `{"toString":0}` made `String` throw, which took the whole shared view
// down. Same comparisons, same answers, for every value `String` handled.
if (f.op === 'eq') {
return String(fieldVal ?? '') === String(f.value ?? '');
return safeText(fieldVal) === safeText(f.value);
}
if (f.op === 'in') {
const wanted = Array.isArray(f.value) ? f.value.map((v) => String(v)) : [String(f.value)];
const wanted = Array.isArray(f.value) ? f.value.map((v) => safeString(v)) : [safeString(f.value)];
// Tags-style array fields: match if any item value is wanted.
if (Array.isArray(fieldVal)) {
return fieldVal.some((v) => wanted.includes(String(v)));
return fieldVal.some((v) => wanted.includes(safeString(v)));
}
return wanted.includes(String(fieldVal ?? ''));
return wanted.includes(safeText(fieldVal));
}
return true;
}
+41
View File
@@ -0,0 +1,41 @@
// BUG-3052 unit 1: the read-side text conversion never throws.
import { describe, expect, it } from 'vitest';
import { safeString, safeText } from './fieldShape';
// A value JSON.parse happily produces, on which String() / `${}` / new Date() throw.
const HOSTILE = JSON.parse('{"toString":0}');
describe('safeText', () => {
it('control: String() really does throw on the hostile value', () => {
// Without this, every leg below could pass on a runtime where the value is
// harmless, and measure nothing.
expect(() => String(HOSTILE)).toThrow();
});
it('does not throw on the hostile value, and keeps it visible as its JSON', () => {
expect(safeText(HOSTILE)).toBe('{"toString":0}');
expect(safeText([HOSTILE, 'a'])).toBe('[{"toString":0},"a"]');
});
it('matches String() exactly for every value String() can convert', () => {
for (const v of ['open', '', 0, 5, false, true, ['a', 'b'], [], { a: 1 }, [1, [2, 3]]]) {
expect(safeText(v), JSON.stringify(v)).toBe(String(v));
}
});
it('reads null and undefined as no text', () => {
expect(safeText(null)).toBe('');
expect(safeText(undefined)).toBe('');
});
});
describe('safeString', () => {
it('is String() for every value String() converts, null and undefined included', () => {
for (const v of [null, undefined, 'x', 0, true, ['a', 'b'], { a: 1 }]) {
expect(safeString(v)).toBe(String(v));
}
});
it('does not throw on the hostile value', () => {
expect(safeString(HOSTILE)).toBe('{"toString":0}');
});
});
+66
View File
@@ -0,0 +1,66 @@
// The read-side shape layer for item field values (BUG-3052).
//
// An item's `fields` is a JSON blob, and a field's DECLARED type is not a
// promise about what is STORED: the schema editor sets any type on any key and
// nothing rewrites existing values when a type changes. So a reader of
// `fields[key]` can be handed a string, number, boolean, null, array or object,
// and a JSON object can even carry a non-callable `toString`
// (`{"toString":0}`), on which `String(v)`, `${v}` and `new Date(v)` THROW
// ("Cannot convert object to primitive value") and take the whole view down.
//
// This module is where a raw field value is turned into something a surface
// can use. It is the checklist as well as the code: the population below is
// what the lead ruled should route through here (one layer, not per-surface
// guards; no source scanner — the table IS the enforcement).
//
// ## Population (BUG-3052 census on main 4d4ab1d6; full table on the trail)
//
// Unit 1 — THROWS (routed through `safeText` in this change):
// - laneValue (boardColumns.ts) → list grouping, board buckets + drops,
// relation lanes, shareView groupItems
// - TableView column sort
// - ChildItems group heading + its lane key
// - shareView matchesFilter; the /s/[token] field formatter
// - QuickActionsMenu + quick-action-preview prompt variables
// - playbooks page trigger sort
// - ItemDetail: children signature, computed-field text, imported-at title
// - ChildChart start/end dates
// Unit 2 — renders garbage / writes back a changed type (FieldEditor,
// TableView cells): `readAs` + a mismatch marker; waits on a UX ruling.
// Unit 3 — web misfilters (strict `===` / `includes` on raw values).
// Unit 4 — server SQL, split out as BUG-3221.
/**
* Exactly `String(raw)`, except it never throws. For a call site that used a
* bare `String(v)` and whose output for `null` / `undefined` (`'null'`,
* `'undefined'`) is part of what it compares — so swapping in `safeText` would
* change an answer rather than only remove a crash.
*
* Identical to `String` whenever `String` would not throw: `'a,b'` for
* `['a','b']`, `'[object Object]'` for a plain object, `'0'`, `'false'`. Only the
* values `String` cannot convert fall back to their JSON text, so they stay
* visible and distinguishable instead of taking the surface down.
*/
export function safeString(raw: unknown): string {
try {
return String(raw);
} catch {
try {
return JSON.stringify(raw) ?? '';
} catch {
return '[unreadable value]';
}
}
}
/**
* A field value as display/compare TEXT, for any value JSON can hold. TOTAL:
* it never throws. `null` / `undefined` read as `''` (no text) — what every
* caller that wrote `String(v ?? '')` or its own null guard wanted — and every
* other value reads exactly as `safeString` does.
*/
export function safeText(raw: unknown): string {
if (typeof raw === 'string') return raw;
if (raw == null) return '';
return safeString(raw);
}
@@ -0,0 +1,20 @@
// BUG-3052 unit 1: the prompt-variable context tolerates stored values String()
// cannot convert. `categoricalTemplateValue`, the `fields` line and plan/phase
// all converted raw values and threw on `{"toString":0}`.
import { describe, expect, it } from 'vitest';
import { contextFromItem } from './quick-action-preview';
describe('contextFromItem with a hostile stored value (BUG-3052)', () => {
it('does not throw, and keeps the value visible as its JSON', () => {
const item = {
id: 'i1', title: 'T', content: '', item_number: 1,
fields: '{"status":{"toString":0},"plan":{"toString":0},"note":"x"}',
} as never;
const collection = { slug: 'tasks', name: 'Tasks', prefix: 'TASK', schema: '{"fields":[]}' } as never;
const ctx = contextFromItem(item, collection);
expect(ctx.status).toBe('{"toString":0}');
expect(ctx.plan).toBe('{"toString":0}');
expect(ctx.fields).toContain('status: {"toString":0}');
expect(ctx.fields).toContain('note: x');
});
});
+7 -4
View File
@@ -1,4 +1,5 @@
import type { Collection, Item, ItemIndexRow } from '$lib/types';
import { safeString, safeText } from '$lib/fields/fieldShape';
import { formatItemRef, parseFields } from '$lib/types';
import { fieldDefFor } from '$lib/collections/categoricalFieldValue';
import { isRelationType, relationValuesOf } from '$lib/items/relationFieldTypes';
@@ -60,7 +61,7 @@ export function categoricalTemplateValue(
): string {
const field = collection ? fieldDefFor([collection], collection.slug, key) : undefined;
if (!field || !isRelationType(field.type)) {
return raw === null || raw === undefined ? '' : String(raw);
return safeText(raw);
}
const ids = relationValuesOf(field.type, raw);
const labels: string[] = [];
@@ -135,11 +136,13 @@ export function contextFromItem(
priority: categoricalTemplateValue(collection, 'priority', fields['priority'], resolve),
collection: collection.name,
content: item.content ? item.content.slice(0, 200) : '',
// safeString / safeText, not `${v}` / String (BUG-3052): a stored
// `{"toString":0}` threw here. Same text for every value String handled.
fields: Object.entries(fields)
.map(([k, v]) => `${k}: ${v}`)
.map(([k, v]) => `${k}: ${safeString(v)}`)
.join(', '),
plan: String(fields['plan'] ?? ''),
phase: String(fields['phase'] ?? fields['plan'] ?? '')
plan: safeText(fields['plan']),
phase: safeText(fields['phase'] ?? fields['plan'])
};
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { safeText } from '$lib/fields/fieldShape';
import { page } from '$app/state';
import { ownValue } from '$lib/utils/ownValue';
import { goto } from '$app/navigation';
@@ -220,7 +221,9 @@
// what it was sorted BY, which is the same value wearing a different hat.
const sa = ownValue(STATUS_ORDER, declaredStatusOf(fa)) ?? 1, sb = ownValue(STATUS_ORDER, declaredStatusOf(fb)) ?? 1;
if (sa !== sb) return sa - sb;
const ta = fa.trigger ?? '', tb = fb.trigger ?? '';
// safeText (BUG-3052): a non-string trigger has no localeCompare, and a
// `{"toString":0}` one threw inside the sort.
const ta = safeText(fa.trigger), tb = safeText(fb.trigger);
if (ta !== tb) return ta.localeCompare(tb);
return a.title.localeCompare(b.title);
});
@@ -584,8 +587,8 @@
schema lands. The card still renders, with its title, trigger
and steps — the affordance is withheld, not the row. -->
{@const status = playbooksCollection ? declaredStatus : ''}
{@const trigger = fields.trigger ?? 'manual'}
{@const scope = fields.scope ?? 'all'}
{@const trigger = fields.trigger == null ? 'manual' : safeText(fields.trigger)}
{@const scope = fields.scope == null ? 'all' : safeText(fields.scope)}
{@const steps = countSteps(item.content)}
{@const isExpanded = expandedId === item.id}
<div class="card" class:card-draft={status === 'draft'} class:card-deprecated={status === 'deprecated'}>
+5 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { safeText } from '$lib/fields/fieldShape';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { api, withRequestDeadline } from '$lib/api/client';
@@ -444,9 +445,10 @@
});
function formatFieldValue(value: unknown): string {
if (value === null || value === undefined) return '';
if (Array.isArray(value)) return value.join(', ');
return String(value);
// `safeText` (BUG-3052): `join` and `String` threw on a stored
// `{"toString":0}`, element or whole, and took the shared page down.
if (Array.isArray(value)) return value.map((v) => safeText(v)).join(', ');
return safeText(value);
}
function formatFieldLabel(key: string): string {