From 312cf06ce559fc4884100d1f0dcf1069ee79e910 Mon Sep 17 00:00:00 2001 From: xarmian Date: Fri, 15 May 2026 21:44:18 -0400 Subject: [PATCH] fix(web): merge defaults in parseSettings/parseSchema (IDEA-1487) (#564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): merge defaults in parseSettings/parseSchema on successful parse (IDEA-1487) parseSettings and parseSchema only merged defaults in the catch branch. Post-PR #562 migration backfilled NULL collections.settings to '{}', so JSON.parse succeeds and returns a bare object — downstream consumers read settings.layout as undefined (rendering 'layout-undefined') and schema.fields.find as a TypeError on any collection with bare '{}'. Merge SETTINGS_DEFAULTS / SCHEMA_DEFAULTS into the parsed object in both branches. Explicit user-supplied fields still override defaults. Note: QuickActionsMenu spreads parseSettings() back to the wire on edit, so first quick-action save on a previously-bare collection now persists {layout:'balanced', default_view:'list'} alongside quick_actions. Left as-is — defaults migrating to wire is harmless and matches what the UI was already rendering. Reviewer flag, not a regression. * fix(web): fresh defaults per parse call to avoid shared mutable state (IDEA-1487 R1) The module-level SCHEMA_DEFAULTS / SETTINGS_DEFAULTS consts introduced in 8c177d0 hold a `fields: []` array that is copied by reference under shallow spread. Any caller that mutates `.fields` in place (push/splice/sort) on a parsed result that fell through to the default would pollute the shared array for every subsequent parseSchema call. No current caller mutates, so this is latent — but defense-in-depth at the exact boundary IDEA-1487 exists to harden. Switch to factory functions that return a fresh object (with a fresh nested array) per call. * fix(web): fresh array on getTerminalOptions fallback (IDEA-1487 R2) getTerminalOptions returned the module-level DEFAULT_TERMINAL_STATUSES array by reference on the fallback path. Same shared-mutable-state hazard as R1's parseSchema fix — latent today (only consumer iterates), but a defense-in-depth gap at the same boundary. Spread on return so each caller gets a fresh array. --- web/src/lib/types/index.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index 63edacde..9f746353 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -1072,19 +1072,23 @@ export function parseFields(item: Item): Record { } } +const schemaDefaults = (): CollectionSchema => ({ fields: [] }); + export function parseSchema(collection: Collection): CollectionSchema { try { - return JSON.parse(collection.schema); + return { ...schemaDefaults(), ...JSON.parse(collection.schema) }; } catch { - return { fields: [] }; + return schemaDefaults(); } } +const settingsDefaults = (): CollectionSettings => ({ layout: 'balanced', default_view: 'list' }); + export function parseSettings(collection: Collection): CollectionSettings { try { - return JSON.parse(collection.settings); + return { ...settingsDefaults(), ...JSON.parse(collection.settings) }; } catch { - return { layout: 'balanced', default_view: 'list' }; + return settingsDefaults(); } } @@ -1111,7 +1115,7 @@ const DEFAULT_TERMINAL_STATUSES = [ export function getTerminalOptions(collection: Collection): string[] { const schema = parseSchema(collection); const statusField = schema.fields.find((f) => f.key === 'status'); - return statusField?.terminal_options ?? DEFAULT_TERMINAL_STATUSES; + return statusField?.terminal_options ?? [...DEFAULT_TERMINAL_STATUSES]; } /** Check if a status value is terminal (finalized) for a given collection. */