Files
redefined-designs/frontend/src/App.tsx
T
bermudalambandClaude Opus 5 71cbd142c3 fix: address the final review of the error boundaries (#62)
Five findings from a whole-branch review, two of which mattered.

The catalogue boundary could not be recovered by the very controls it exists to keep alive. When the item grid threw, the header, filter chips and Clear filters stayed mounted — that was the point — but clicking Clear filters only changed the URL. A boundary does not reset on a client-side state change, so the fallback went on rendering over a catalogue that would by then have loaded perfectly well, and the only offered action reloaded the same failing URL. The shop read as permanently broken, which is the failure this whole change exists to prevent, reintroduced one level down. ErrorBoundary now takes an optional resetKey and clears itself when it changes; the catalogue boundary passes the filter key. The page and modal boundaries deliberately do not take one, because their escapes are hard navigations that remount the tree already — recorded on the prop so nobody completes the pattern by symmetry.

The client-error endpoint could fill the disk. It is unauthenticated, each accepted report wrote about 8.7 KB, and Docker's default json-file driver has no size cap — so the rate limiter bounded a render loop, as its comment claims, but not a few hundred cheap source addresses. Stack and component stack now truncate at 1000 rather than 4000, which is still around fifteen frames and cuts the worst case to under 3 KB, and the QA compose file caps and rotates the log. Production is a Portainer stack outside this repository and needs the same option applied there; noted in the design doc rather than left implied.

Three smaller things. A falsy thrown value defeated the boundary entirely: throw null is legal, and branching on the error object alone treated it as no error, re-rendered the children, threw again, and would have taken the root down — a blank page, the one outcome this is all here to avoid. The boundary now tracks hasError separately and synthesises a real Error for non-Error throws. The modal fallback announced its title twice to a screen reader, once as the dialog's name and once as the heading inside it, so the redundant dialog title is gone. And the design doc claimed the development-only detail shows the component stack when it only ever showed the message; corrected, with a note that the stack still reaches the server log, which is where it is useful.

Verified after all five: backend lint 0 errors, 144 integration tests, frontend lint 0 errors and 31 warnings, 87 end-to-end tests, all against a freshly created database.

Refs #62
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:32:27 -05:00

317 lines
11 KiB
TypeScript
Executable File

import { useEffect, useState, useCallback, useMemo } from 'react';
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge, Empty, Alert } from 'antd';
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
import ItemCard from './components/ItemCard';
import BrandMark from './components/BrandMark';
import FilterDrawer from './components/FilterDrawer';
import ActiveFilterChips from './components/ActiveFilterChips';
import {
ItemFilters,
activeFilterCount,
filtersFromSearchParams,
filtersToSearchParams,
hasActiveFilters
} from './filters';
import AuthPromptModal from './customer/AuthPromptModal';
import { useThemeMode } from './theme/ThemeContext';
import { useCustomerAuth } from './customer/CustomerAuthContext';
import { useCart } from './cart/CartContext';
import ErrorBoundary from './components/ErrorBoundary';
import ErrorFallback from './components/ErrorFallback';
import DevThrow from './components/DevThrow';
const { Header, Content, Footer } = Layout;
const { Title } = Typography;
// Dragging the price slider fires a change per pixel; without this every one
// would become its own request.
const FILTER_DEBOUNCE_MS = 250;
interface CatalogueProps {
failed: boolean;
loading: boolean;
items: Item[];
filters: ItemFilters;
needsFavoritesAuth: boolean;
onRetry: () => void;
onSignIn: () => void;
onClearFilters: () => void;
onChanged: () => void;
}
// The body of the catalogue: an outage, a sign-in prompt, an empty state, or
// the grid. Extracted from App so the four cases read as early returns rather
// than a ternary chain, and because a function's cognitive complexity counts
// everything nested inside it — leaving this inline is what put App over the
// limit.
function Catalogue({
failed,
loading,
items,
filters,
needsFavoritesAuth,
onRetry,
onSignIn,
onClearFilters,
onChanged
}: CatalogueProps) {
if (failed) {
return (
<Alert
type="error"
showIcon
message="Couldn't load items"
description="The server didn't return the catalogue. This is usually temporary."
action={<Button size="small" onClick={onRetry}>Retry</Button>}
/>
);
}
// Prompted rather than requested: see the effect in App that sets this.
if (needsFavoritesAuth) {
return (
<Empty description="Sign in to see the items you have favorited">
<Button type="primary" onClick={onSignIn}>Sign in</Button>
<Button style={{ marginInlineStart: 8 }} onClick={onClearFilters}>Browse everything</Button>
</Empty>
);
}
if (!loading && !items.length) {
// Distinguished so "no items match these filters" never reads as an empty
// shop, and so the way out is offered only when there is one.
const filtered = hasActiveFilters(filters);
return (
<Empty description={filtered ? 'No items match these filters' : 'No items yet — check back soon'}>
{filtered ? <Button onClick={onClearFilters}>Clear filters</Button> : null}
</Empty>
);
}
return (
<Row gutter={[20, 20]}>
{items.map(item => (
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
<ItemCard item={item} onChanged={onChanged} />
</Col>
))}
</Row>
);
}
export default function App() {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [options, setOptions] = useState<FilterOptions | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const { mode, toggle } = useThemeMode();
const { customer, loading: authLoading } = useCustomerAuth();
const { items: cartItems } = useCart();
const { token } = theme.useToken();
// The URL is the single source of truth for filter state, so a reload, a
// shared link, and the back button all restore the same view.
const filters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]);
const filterKey = filtersToSearchParams(filters).toString();
// "Only my favorites" needs to know who is asking. Until the session has
// resolved we hold rather than guess: firing the request early would 401 and
// show the outage banner to someone who is in fact signed in.
const awaitingAuth = filters.favoritesOnly && authLoading;
const needsFavoritesAuth = filters.favoritesOnly && !authLoading && !customer;
const applyFilters = useCallback(
(next: ItemFilters) => {
// replace, not push: dragging a slider shouldn't bury the previous page
// under dozens of history entries.
setSearchParams(filtersToSearchParams(next), { replace: true });
},
[setSearchParams]
);
const clearFilters = useCallback(() => {
setSearchParams(new URLSearchParams(), { replace: true });
}, [setSearchParams]);
const load = useCallback(() => {
return fetchItems(filtersFromSearchParams(new URLSearchParams(filterKey)))
.then((loaded) => {
setItems(loaded);
setFailed(false);
})
// A failed request must never fall through to the empty state: telling a
// customer "no items yet" when the server is broken hides the outage and
// reads as an empty shop.
.catch(() => setFailed(true))
.finally(() => setLoading(false));
}, [filterKey]);
useEffect(() => {
if (awaitingAuth) {
setLoading(true);
return;
}
// Prompt instead of requesting. The server would answer 401, and rendering
// that as "no items match these filters" would tell a signed-out visitor
// they have no favorites rather than that we do not know who they are.
if (needsFavoritesAuth) {
setItems([]);
setFailed(false);
setLoading(false);
setAuthModalOpen(true);
return;
}
setLoading(true);
const timer = setTimeout(() => void load(), FILTER_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [load, awaitingAuth, needsFavoritesAuth]);
useEffect(() => {
fetchFilterOptions().then(setOptions).catch(() => setOptions(null));
}, []);
// Adding to cart flips an item to reserved, and the filter options' price
// bounds shift as inventory changes.
const reload = useCallback(() => {
void load();
fetchFilterOptions().then(setOptions).catch(() => undefined);
}, [load]);
const handleRetry = useCallback(() => {
setLoading(true);
void load();
}, [load]);
const openAuthModal = useCallback(() => setAuthModalOpen(true), []);
const activeCount = activeFilterCount(filters);
return (
<Layout style={{ minHeight: '100vh' }}>
<Header
className="site-header"
style={{
background: token.colorBgContainer,
borderBottom: `1px solid ${token.colorBorderSecondary}`
}}
>
{/* Wrapped so the mark and the wordmark travel together — the title
keeps its own ellipsis behaviour when the header gets narrow, and the
mark is never what gets truncated. */}
<div className="site-header-brand" style={{ color: token.colorText }}>
<BrandMark size={28} />
<Title level={3} className="site-header-title" style={{ color: token.colorText }}>
Redefined Designs
</Title>
</div>
<div className="site-header-actions">
<Switch checked={mode === 'dark'} onChange={toggle} checkedChildren="Dark" unCheckedChildren="Light" />
<Link to="/cart">
<Badge count={cartItems.length} size="small">
<Button icon={<ShoppingCartOutlined />} />
</Badge>
</Link>
{/* The account link names the page to render behind the modal, so
closing it comes back here — filters and all — rather than to a
default. */}
{customer ? (
<Link to="/account" state={{ background: location }}>
<Button>My Account</Button>
</Link>
) : (
<>
<Link to="/login" state={{ background: location }}>
<Button>Log in</Button>
</Link>
<Link to="/register" state={{ background: location }}>
<Button type="primary">Sign up</Button>
</Link>
</>
)}
</div>
</Header>
<Content style={{ padding: 24 }}>
<div className="filter-bar">
<Button
icon={<FilterOutlined />}
onClick={() => setDrawerOpen(true)}
type={activeCount ? 'primary' : 'default'}
>
Filters{activeCount ? ` (${activeCount})` : ''}
</Button>
<ActiveFilterChips
options={options}
filters={filters}
onChange={applyFilters}
onClear={clearFilters}
/>
</div>
{loading && !items.length && !failed ? <Spin /> : null}
<ErrorBoundary
context="catalogue"
// Lets Clear filters (and any other filter change) recover the grid
// without a reload: filterKey changes whenever the filters change,
// which resets the boundary the next time it renders. `?boom=catalogue`
// is not itself a known filter, so filterKey is unaffected by it and
// the existing end-to-end assertion that the fallback renders still
// holds.
resetKey={filterKey}
fallback={(error) => (
<ErrorFallback
error={error}
title="The item list didn't load"
actions={
<Button type="primary" onClick={() => window.location.reload()}>
Reload
</Button>
}
/>
)}
>
{import.meta.env.DEV && <DevThrow scope="catalogue" />}
<Catalogue
failed={failed}
loading={loading}
items={items}
filters={filters}
needsFavoritesAuth={needsFavoritesAuth}
onRetry={handleRetry}
onSignIn={openAuthModal}
onClearFilters={clearFilters}
onChanged={reload}
/>
</ErrorBoundary>
</Content>
<Footer style={{ textAlign: 'center', background: token.colorBgContainer }}>
<Link to="/privacy">Privacy Policy</Link>
</Footer>
<FilterDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
options={options}
filters={filters}
onChange={applyFilters}
onClear={clearFilters}
resultCount={items.length}
/>
{/* The same prompt the heart button and Add to Cart use. Signing in
resolves the gate above, and the filter then applies on its own — the
customer never has to set it a second time. */}
<AuthPromptModal
open={authModalOpen}
onClose={() => setAuthModalOpen(false)}
onSuccess={() => setAuthModalOpen(false)}
/>
</Layout>
);
}