From dcd956db21c1874199d56481f3ae1441f08c0844 Mon Sep 17 00:00:00 2001 From: akira69 Date: Tue, 24 Feb 2026 18:31:44 -0600 Subject: [PATCH 1/4] fix(print): prevent white page from invalid persisted JSON --- .../pages/printing/spoolQrCodePrintingDialog.tsx | 13 +++++++++---- client/src/utils/saveload.ts | 13 ++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx index 865c597cf..ed855d6e7 100644 --- a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx +++ b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx @@ -26,10 +26,15 @@ interface SpoolQRCodePrintingDialog { const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => { const t = useTranslate(); const baseUrlSetting = useGetSetting("base_url"); - const baseUrlRoot = - baseUrlSetting.data?.value !== undefined && JSON.parse(baseUrlSetting.data?.value) !== "" - ? JSON.parse(baseUrlSetting.data?.value) - : window.location.origin; + let parsedBaseUrl = ""; + if (baseUrlSetting.data?.value !== undefined) { + try { + parsedBaseUrl = JSON.parse(baseUrlSetting.data.value) ?? ""; + } catch { + parsedBaseUrl = baseUrlSetting.data.value; + } + } + const baseUrlRoot = parsedBaseUrl !== "" ? parsedBaseUrl : window.location.origin; const [messageApi, contextHolder] = message.useMessage(); const [useHTTPUrl, setUseHTTPUrl] = useSavedState("print-useHTTPUrl", false); diff --git a/client/src/utils/saveload.ts b/client/src/utils/saveload.ts index f3ba0fc6f..66ff7120f 100644 --- a/client/src/utils/saveload.ts +++ b/client/src/utils/saveload.ts @@ -6,6 +6,17 @@ interface Pagination { pageSize: number; } +function parseSavedJSON(value: string | null, fallback: T): T { + if (!value) { + return fallback; + } + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} + export interface TableState { sorters: CrudSort[]; filters: CrudFilter[]; @@ -93,7 +104,7 @@ export function useStoreInitialState(tableId: string, state: TableState) { export function useSavedState(id: string, defaultValue: T) { const [state, setState] = useState(() => { const savedState = isLocalStorageAvailable ? localStorage.getItem(`savedStates-${id}`) : null; - return savedState ? JSON.parse(savedState) : defaultValue; + return parseSavedJSON(savedState, defaultValue); }); useEffect(() => { From 1c95224bf1f130b2b13ba39b266f3b29577abb9b Mon Sep 17 00:00:00 2001 From: akira69 Date: Tue, 3 Mar 2026 14:20:16 -0600 Subject: [PATCH 2/4] docs(print): clarify persisted state parse guards --- client/src/pages/printing/spoolQrCodePrintingDialog.tsx | 2 ++ client/src/utils/saveload.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx index ed855d6e7..3e6515f6e 100644 --- a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx +++ b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx @@ -31,6 +31,8 @@ const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => { try { parsedBaseUrl = JSON.parse(baseUrlSetting.data.value) ?? ""; } catch { + // Older or manually edited settings may already be stored as a raw string, so + // accept that form instead of treating it as a fatal parse error. parsedBaseUrl = baseUrlSetting.data.value; } } diff --git a/client/src/utils/saveload.ts b/client/src/utils/saveload.ts index 66ff7120f..fb2bff6d7 100644 --- a/client/src/utils/saveload.ts +++ b/client/src/utils/saveload.ts @@ -13,6 +13,8 @@ function parseSavedJSON(value: string | null, fallback: T): T { try { return JSON.parse(value) as T; } catch { + // Persisted UI state can outlive schema changes or manual URL edits; fall back + // silently so one bad value does not blank the whole page. return fallback; } } From 82ecf631ab016f8547f6159ddff4e27435ca77bb Mon Sep 17 00:00:00 2001 From: akira69 Date: Sat, 14 Mar 2026 01:20:29 -0500 Subject: [PATCH 3/4] Harden persisted UI state parsing --- client/src/utils/saveload.ts | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/client/src/utils/saveload.ts b/client/src/utils/saveload.ts index fb2bff6d7..e42bfa79f 100644 --- a/client/src/utils/saveload.ts +++ b/client/src/utils/saveload.ts @@ -6,6 +6,10 @@ interface Pagination { pageSize: number; } +const DEFAULT_SORTERS: CrudSort[] = [{ field: "id", order: "asc" }]; +const DEFAULT_FILTERS: CrudFilter[] = []; +const DEFAULT_PAGINATION: Pagination = { currentPage: 1, pageSize: 20 }; + function parseSavedJSON(value: string | null, fallback: T): T { if (!value) { return fallback; @@ -19,6 +23,17 @@ function parseSavedJSON(value: string | null, fallback: T): T { } } +function parseSavedPagination(value: string | null): Pagination { + const parsed = parseSavedJSON & { current?: number }>(value, DEFAULT_PAGINATION); + + // Older persisted state used `current`; normalize it so lists keep loading even when + // localStorage or URL hash values were saved by an older UI shape. + return { + currentPage: parsed.currentPage ?? parsed.current ?? DEFAULT_PAGINATION.currentPage, + pageSize: parsed.pageSize ?? DEFAULT_PAGINATION.pageSize, + }; +} + export interface TableState { sorters: CrudSort[]; filters: CrudFilter[]; @@ -45,10 +60,12 @@ export function useInitialTableState(tableId: string): TableState { : null; const savedShowColumns = isLocalStorageAvailable ? localStorage.getItem(`${tableId}-showColumns`) : null; - const sorters = savedSorters ? JSON.parse(savedSorters) : [{ field: "id", order: "asc" }]; - const filters = savedFilters ? JSON.parse(savedFilters) : []; - const pagination = savedPagination ? JSON.parse(savedPagination) : { page: 1, pageSize: 20 }; - const showColumns = savedShowColumns ? JSON.parse(savedShowColumns) : undefined; + // Guard every persisted table-state read so stale localStorage or hand-edited hash values + // cannot throw during initial render and blank the list page. + const sorters = parseSavedJSON(savedSorters, DEFAULT_SORTERS); + const filters = parseSavedJSON(savedFilters, DEFAULT_FILTERS); + const pagination = parseSavedPagination(savedPagination); + const showColumns = parseSavedJSON(savedShowColumns, undefined); return { sorters, filters, pagination, showColumns }; }); return initialState; @@ -56,7 +73,7 @@ export function useInitialTableState(tableId: string): TableState { export function useStoreInitialState(tableId: string, state: TableState) { useEffect(() => { - if (state.sorters.length > 0 && JSON.stringify(state.sorters) != JSON.stringify([{ field: "id", order: "asc" }])) { + if (state.sorters.length > 0 && JSON.stringify(state.sorters) != JSON.stringify(DEFAULT_SORTERS)) { if (isLocalStorageAvailable) { localStorage.setItem(`${tableId}-sorters`, JSON.stringify(state.sorters)); } @@ -81,7 +98,7 @@ export function useStoreInitialState(tableId: string, state: TableState) { }, [tableId, state.filters]); useEffect(() => { - if (JSON.stringify(state.pagination) != JSON.stringify({ current: 1, pageSize: 20 })) { + if (JSON.stringify(state.pagination) != JSON.stringify(DEFAULT_PAGINATION)) { if (isLocalStorageAvailable) { localStorage.setItem(`${tableId}-pagination`, JSON.stringify(state.pagination)); } From 6489fca2d9de1c3531b424e3fc05a9615052b248 Mon Sep 17 00:00:00 2001 From: akira69 Date: Sat, 28 Mar 2026 21:21:58 -0500 Subject: [PATCH 4/4] fix(print): clean malformed persisted state --- .../printing/spoolQrCodePrintingDialog.tsx | 31 +-- client/src/utils/saveload.ts | 178 ++++++++++++++++-- 2 files changed, 177 insertions(+), 32 deletions(-) diff --git a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx index 3e6515f6e..3f0ed4723 100644 --- a/client/src/pages/printing/spoolQrCodePrintingDialog.tsx +++ b/client/src/pages/printing/spoolQrCodePrintingDialog.tsx @@ -23,20 +23,23 @@ interface SpoolQRCodePrintingDialog { spoolIds: number[]; } +function getConfiguredBaseUrl(settingValue: string | undefined): string | undefined { + if (settingValue === undefined) { + return undefined; + } + + try { + const parsed = JSON.parse(settingValue) as unknown; + return typeof parsed === "string" && parsed.trim() !== "" ? parsed.trim() : undefined; + } catch { + return settingValue.trim() !== "" ? settingValue.trim() : undefined; + } +} + const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => { const t = useTranslate(); const baseUrlSetting = useGetSetting("base_url"); - let parsedBaseUrl = ""; - if (baseUrlSetting.data?.value !== undefined) { - try { - parsedBaseUrl = JSON.parse(baseUrlSetting.data.value) ?? ""; - } catch { - // Older or manually edited settings may already be stored as a raw string, so - // accept that form instead of treating it as a fatal parse error. - parsedBaseUrl = baseUrlSetting.data.value; - } - } - const baseUrlRoot = parsedBaseUrl !== "" ? parsedBaseUrl : window.location.origin; + const baseUrlRoot = getConfiguredBaseUrl(baseUrlSetting.data?.value) ?? window.location.origin; const [messageApi, contextHolder] = message.useMessage(); const [useHTTPUrl, setUseHTTPUrl] = useSavedState("print-useHTTPUrl", false); @@ -48,7 +51,11 @@ const SpoolQRCodePrintingDialog = ({ spoolIds }: SpoolQRCodePrintingDialog) => { .filter((item) => item !== null) as ISpool[]; // Selected preset state - const [selectedPresetState, setSelectedPresetState] = useSavedState("selectedPreset", undefined); + const [selectedPresetState, setSelectedPresetState] = useSavedState( + "selectedPreset", + undefined, + (value): value is string | undefined => value === undefined || typeof value === "string", + ); // Keep a local copy of the settings which is what's actually displayed. Use the remote state only for saving. // This decouples the debounce stuff from the UI diff --git a/client/src/utils/saveload.ts b/client/src/utils/saveload.ts index e42bfa79f..ddcb10541 100644 --- a/client/src/utils/saveload.ts +++ b/client/src/utils/saveload.ts @@ -10,21 +10,94 @@ const DEFAULT_SORTERS: CrudSort[] = [{ field: "id", order: "asc" }]; const DEFAULT_FILTERS: CrudFilter[] = []; const DEFAULT_PAGINATION: Pagination = { currentPage: 1, pageSize: 20 }; -function parseSavedJSON(value: string | null, fallback: T): T { - if (!value) { +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function matchesDefaultValueShape(value: unknown, defaultValue: T): value is T { + if (defaultValue === undefined || defaultValue === null) { + return value === defaultValue; + } + + if (Array.isArray(defaultValue)) { + return Array.isArray(value); + } + + if (typeof defaultValue === "number") { + return typeof value === "number" && Number.isFinite(value); + } + + if (typeof defaultValue === "object") { + return isRecord(value); + } + + return typeof value === typeof defaultValue; +} + +function parseSavedJSON( + label: string, + value: string | null, + fallback: T, + isValid: (parsed: unknown) => parsed is T, + onError?: () => void, +): T { + if (!value || value === "undefined") { return fallback; } + try { - return JSON.parse(value) as T; + const parsed = JSON.parse(value) as unknown; + if (isValid(parsed)) { + return parsed; + } } catch { - // Persisted UI state can outlive schema changes or manual URL edits; fall back - // silently so one bad value does not blank the whole page. - return fallback; + // Ignore parse failures below so malformed persisted values are handled the same way + // as wrong-shape values. } + + console.warn(`Ignoring malformed saved state for ${label}`); + onError?.(); + return fallback; +} + +function isSavedSorter(value: unknown): value is CrudSort { + return isRecord(value) && typeof value.field === "string" && (value.order === "asc" || value.order === "desc"); +} + +function isSavedSorters(value: unknown): value is CrudSort[] { + return Array.isArray(value) && value.every(isSavedSorter); +} + +function isSavedFilter(value: unknown): value is CrudFilter { + return isRecord(value) && typeof value.field === "string" && typeof value.operator === "string" && "value" in value; +} + +function isSavedFilters(value: unknown): value is CrudFilter[] { + return Array.isArray(value) && value.every(isSavedFilter); } -function parseSavedPagination(value: string | null): Pagination { - const parsed = parseSavedJSON & { current?: number }>(value, DEFAULT_PAGINATION); +function isSavedShowColumns(value: unknown): value is string[] { + return Array.isArray(value) && value.every((column) => typeof column === "string"); +} + +function isSavedPagination(value: unknown): value is Partial & { current?: number } { + return ( + isRecord(value) && + (value.currentPage === undefined || + (typeof value.currentPage === "number" && Number.isFinite(value.currentPage))) && + (value.current === undefined || (typeof value.current === "number" && Number.isFinite(value.current))) && + (value.pageSize === undefined || (typeof value.pageSize === "number" && Number.isFinite(value.pageSize))) + ); +} + +function parseSavedPagination(label: string, value: string | null, onError?: () => void): Pagination { + const parsed = parseSavedJSON & { current?: number }>( + label, + value, + DEFAULT_PAGINATION, + isSavedPagination, + onError, + ); // Older persisted state used `current`; normalize it so lists keep loading even when // localStorage or URL hash values were saved by an older UI shape. @@ -34,6 +107,19 @@ function parseSavedPagination(value: string | null): Pagination { }; } +function hasSavedFilterValue(filter: CrudFilter): boolean { + if (!("value" in filter)) { + return false; + } + + const value = filter.value; + if (Array.isArray(value) || typeof value === "string") { + return value.length !== 0; + } + + return value !== undefined && value !== null; +} + export interface TableState { sorters: CrudSort[]; filters: CrudFilter[]; @@ -43,29 +129,68 @@ export interface TableState { export function useInitialTableState(tableId: string): TableState { const [initialState] = useState(() => { - const savedSorters = hasHashProperty("sorters") + const hasHashSorters = hasHashProperty("sorters"); + const hasHashFilters = hasHashProperty("filters"); + const hasHashPagination = hasHashProperty("pagination"); + + const savedSorters = hasHashSorters ? getHashProperty("sorters") : isLocalStorageAvailable ? localStorage.getItem(`${tableId}-sorters`) : null; - const savedFilters = hasHashProperty("filters") + const savedFilters = hasHashFilters ? getHashProperty("filters") : isLocalStorageAvailable ? localStorage.getItem(`${tableId}-filters`) : null; - const savedPagination = hasHashProperty("pagination") + const savedPagination = hasHashPagination ? getHashProperty("pagination") : isLocalStorageAvailable ? localStorage.getItem(`${tableId}-pagination`) : null; const savedShowColumns = isLocalStorageAvailable ? localStorage.getItem(`${tableId}-showColumns`) : null; + const sorters = parseSavedJSON( + hasHashSorters ? "hash#sorters" : `${tableId}-sorters`, + savedSorters, + DEFAULT_SORTERS, + isSavedSorters, + hasHashSorters + ? () => removeURLHash("sorters") + : isLocalStorageAvailable + ? () => localStorage.removeItem(`${tableId}-sorters`) + : undefined, + ); + const filters = parseSavedJSON( + hasHashFilters ? "hash#filters" : `${tableId}-filters`, + savedFilters, + DEFAULT_FILTERS, + isSavedFilters, + hasHashFilters + ? () => removeURLHash("filters") + : isLocalStorageAvailable + ? () => localStorage.removeItem(`${tableId}-filters`) + : undefined, + ); + const pagination = parseSavedPagination( + hasHashPagination ? "hash#pagination" : `${tableId}-pagination`, + savedPagination, + hasHashPagination + ? () => removeURLHash("pagination") + : isLocalStorageAvailable + ? () => localStorage.removeItem(`${tableId}-pagination`) + : undefined, + ); + const showColumns = parseSavedJSON( + `${tableId}-showColumns`, + savedShowColumns, + undefined, + (value): value is string[] | undefined => value === undefined || isSavedShowColumns(value), + isLocalStorageAvailable ? () => localStorage.removeItem(`${tableId}-showColumns`) : undefined, + ); + // Guard every persisted table-state read so stale localStorage or hand-edited hash values // cannot throw during initial render and blank the list page. - const sorters = parseSavedJSON(savedSorters, DEFAULT_SORTERS); - const filters = parseSavedJSON(savedFilters, DEFAULT_FILTERS); - const pagination = parseSavedPagination(savedPagination); - const showColumns = parseSavedJSON(savedShowColumns, undefined); return { sorters, filters, pagination, showColumns }; }); return initialState; @@ -85,7 +210,7 @@ export function useStoreInitialState(tableId: string, state: TableState) { }, [tableId, state.sorters]); useEffect(() => { - const filters = state.filters.filter((f) => f.value.length != 0); + const filters = state.filters.filter(hasSavedFilterValue); if (filters.length > 0) { if (isLocalStorageAvailable) { localStorage.setItem(`${tableId}-filters`, JSON.stringify(filters)); @@ -120,15 +245,28 @@ export function useStoreInitialState(tableId: string, state: TableState) { }, [tableId, state.showColumns]); } -export function useSavedState(id: string, defaultValue: T) { +export function useSavedState(id: string, defaultValue: T, isValidState?: (value: unknown) => value is T) { const [state, setState] = useState(() => { - const savedState = isLocalStorageAvailable ? localStorage.getItem(`savedStates-${id}`) : null; - return parseSavedJSON(savedState, defaultValue); + const storageKey = `savedStates-${id}`; + const savedState = isLocalStorageAvailable ? localStorage.getItem(storageKey) : null; + return parseSavedJSON( + storageKey, + savedState, + defaultValue, + isValidState ?? ((value): value is T => matchesDefaultValueShape(value, defaultValue)), + isLocalStorageAvailable ? () => localStorage.removeItem(storageKey) : undefined, + ); }); useEffect(() => { if (isLocalStorageAvailable) { - localStorage.setItem(`savedStates-${id}`, JSON.stringify(state)); + const storageKey = `savedStates-${id}`; + const serializedState = JSON.stringify(state); + if (serializedState === undefined) { + localStorage.removeItem(storageKey); + } else { + localStorage.setItem(storageKey, serializedState); + } } }, [id, state]);