fix(frontend): harden inventory search — q= validation, error boundary, stable selection, URL throttle

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-15 06:41:54 +02:00
parent ae24cad458
commit edbf7086a6
6 changed files with 133 additions and 4948 deletions

View file

@ -23,7 +23,13 @@ export function ActiveChips({ search }: { search: InventorySearch }) {
if (f.spellContains) chips.push({ label: `Spell: ${f.spellContains}`, remove: () => update({ spellContains: '' }) });
for (const [param, v] of Object.entries(f.ratings)) if (v !== '') {
const def = RATING_DEFS.find(r => r.param === param);
chips.push({ label: `${def?.label ?? param}${v}`, remove: () => update({ ratings: { ...f.ratings, [param]: '' } }) });
chips.push({
label: `${def?.label ?? param}${v}`,
remove: () => {
const { [param]: _omit, ...rest } = f.ratings;
update({ ratings: rest });
},
});
}
if (f.itemSet) chips.push({ label: `Set: ${f.itemSet}`, remove: () => update({ itemSet: '' }) });
if (f.equippedOnly) chips.push({ label: 'Equipped', remove: () => update({ equippedOnly: false }) });

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { Component, useEffect, useState, type ErrorInfo, type ReactNode } from 'react';
import { useInventorySearch } from './useInventorySearch';
import { FilterSidebar } from './FilterSidebar';
import { ActiveChips } from './ActiveChips';
@ -6,10 +6,54 @@ import { ResultsTable } from './ResultsTable';
import { DetailPanel } from './DetailPanel';
import type { InvItem } from './types';
export function InventorySearchPage() {
/** Composite key used to re-match a selected item across refetches (objects are recreated each fetch). */
function itemKey(i: InvItem): string {
return `${i.name} ${i.character_name} ${i.last_updated ?? ''}`;
}
class InventorySearchErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): { hasError: boolean } {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('Inventory search crashed:', error, info);
}
render(): ReactNode {
if (this.state.hasError) {
return (
<div className="inv-page">
<div className="inv-error" style={{ margin: 'auto', textAlign: 'center', padding: 24 }}>
<p style={{ marginBottom: 12 }}>Something went wrong loading the inventory search page.</p>
<button className="inv-btn" onClick={() => { window.location.href = '/?view=inventory'; }}>
Reset filters
</button>
</div>
</div>
);
}
return this.props.children;
}
}
function InventorySearchPageInner() {
const search = useInventorySearch();
const [selected, setSelected] = useState<InvItem | null>(null);
// Selection is by object identity; every refetch produces new item objects, so re-match the
// previously selected item by composite key in the new result set (or clear it if it's gone).
useEffect(() => {
if (!selected) return;
const items = search.result?.items ?? [];
const key = itemKey(selected);
const match = items.find(i => itemKey(i) === key);
if (match && match !== selected) setSelected(match);
else if (!match) setSelected(null);
}, [search.result]);
return (
<div className="inv-page">
<div className="inv-topbar">
@ -38,3 +82,11 @@ export function InventorySearchPage() {
</div>
);
}
export function InventorySearchPage() {
return (
<InventorySearchErrorBoundary>
<InventorySearchPageInner />
</InventorySearchErrorBoundary>
);
}

View file

@ -41,7 +41,11 @@ export function ResultsTable({ search, selected, onSelect }: {
const items = result?.items ?? [];
useEffect(() => {
localStorage.setItem(COLUMNS_LS_KEY, JSON.stringify([...visible]));
try {
localStorage.setItem(COLUMNS_LS_KEY, JSON.stringify([...visible]));
} catch {
/* Safari private mode throws on quota — non-fatal, column prefs just won't persist. */
}
}, [visible]);
// Keyboard: ↑/↓ moves selection, Esc clears — ignore while typing in inputs.

View file

@ -12,14 +12,77 @@ function filtersToUrl(f: SearchFilters): void {
url.searchParams.set('view', 'inventory');
if (Object.keys(diff).length) url.searchParams.set('q', JSON.stringify(diff));
else url.searchParams.delete('q');
window.history.replaceState(null, '', url);
try {
window.history.replaceState(null, '', url);
} catch {
/* Safari throttles history.replaceState and throws when exceeded — non-fatal. */
}
}
const ITEM_TYPES = new Set(['all', 'armor', 'jewelry', 'weapon', 'clothing', 'shirt', 'pants']);
const SORT_DIRS = new Set(['asc', 'desc']);
function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every(x => typeof x === 'string');
}
/** Validate a single filter key's parsed value; return the valid value or undefined to drop it. */
function validateFilterValue(key: keyof SearchFilters, value: unknown): unknown {
switch (key) {
case 'characters':
if (value === 'all' || isStringArray(value)) return value;
return undefined;
case 'slots':
case 'cantrips':
return isStringArray(value) ? value : undefined;
case 'itemType':
return typeof value === 'string' && ITEM_TYPES.has(value) ? value : undefined;
case 'sortDir':
return typeof value === 'string' && SORT_DIRS.has(value) ? value : undefined;
case 'ratings': {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
const out: Record<string, number | ''> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (v === '' || typeof v === 'number') out[k] = v;
}
return out;
}
case 'text':
case 'weaponType':
case 'spellContains':
case 'itemSet':
case 'sortBy':
return typeof value === 'string' ? value : undefined;
case 'equippedOnly':
case 'bonded':
case 'attuned':
case 'rare':
return typeof value === 'boolean' ? value : undefined;
case 'maxLevel':
case 'minValue':
case 'minWorkmanship':
case 'maxBurden':
return value === '' || typeof value === 'number' ? value : undefined;
case 'page':
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined;
default:
return undefined;
}
}
function filtersFromUrl(): SearchFilters {
try {
const q = new URLSearchParams(window.location.search).get('q');
if (!q) return DEFAULT_FILTERS;
return { ...DEFAULT_FILTERS, ...JSON.parse(q) };
const parsed = JSON.parse(q);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return DEFAULT_FILTERS;
const merged: SearchFilters = { ...DEFAULT_FILTERS };
for (const k of Object.keys(DEFAULT_FILTERS) as Array<keyof SearchFilters>) {
if (!(k in parsed)) continue;
const validated = validateFilterValue(k, (parsed as any)[k]);
if (validated !== undefined) (merged as any)[k] = validated;
}
return merged;
} catch {
return DEFAULT_FILTERS;
}
@ -88,8 +151,8 @@ export function useInventorySearch(): InventorySearch {
const reset = useCallback(() => setFilters(DEFAULT_FILTERS), []);
useEffect(() => {
filtersToUrl(filters);
const timer = setTimeout(async () => {
filtersToUrl(filters);
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;