refactor(frontend): declare props read-only, and drop the deprecated antd prop (#100)
Seventeen components declared props the compiler was free to assume were mutable, and one antd prop had gone stale. Both mechanical, neither with any behaviour attached.
React never writes to props, and `Readonly<>` says so to the compiler rather than only to the reader. This finishes a pattern the codebase had already chosen rather than introducing one: AccountDetails and EmailTemplateEditor were already written as `type Props = Readonly<{…}>`, so the thirteen named prop interfaces are converted to that same shape and the four context providers, which annotate `{ children }` inline, get `Readonly<{ children: React.ReactNode }>`.
Cart.tsx was the last place passing `destroyOnClose`, deprecated in antd 5.20. Twelve other call sites across the admin screens, the filter drawer and four customer modals already use `destroyOnHidden`, so this one was simply stale. Deprecated props keep working until they do not, and the failure then arrives as an antd upgrade breaking something unrelated to the change being made.
Counted rather than assumed, which the issue specifically asks for, because a `Readonly<>` in the wrong position type-checks and fixes nothing: lint goes from 31 warnings to 13, a drop of exactly eighteen, and both rules disappear from the breakdown entirely rather than merely thinning out.
What that leaves is the point of doing it. The remaining thirteen are eleven `set-state-in-effect` and two `no-alphabetical-sort` — so the frontend's warnings are now only the ones that need a decision, which is what makes #99 tractable. It had grown from the eight in that issue's title to eleven, two of them added by #97's clock tick and lapsed-cart refetch.
No behaviour change intended, so the bar was the end-to-end suite. Full run: 121 passed, 8 failed; all eight pass in a 45/45 serial re-run, which is the shared-database and event-loop flakiness this suite has had throughout.
Closes #100
This commit is contained in:
@@ -40,7 +40,7 @@ import DevThrow from './components/DevThrow';
|
|||||||
const { Header, Content, Footer } = Layout;
|
const { Header, Content, Footer } = Layout;
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|
||||||
interface CatalogueProps {
|
type CatalogueProps = Readonly<{
|
||||||
failed: boolean;
|
failed: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
items: Item[];
|
items: Item[];
|
||||||
@@ -50,7 +50,7 @@ interface CatalogueProps {
|
|||||||
onSignIn: () => void;
|
onSignIn: () => void;
|
||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
// The body of the catalogue: an outage, a sign-in prompt, an empty state, or
|
// 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
|
// the grid. Extracted from App so the four cases read as early returns rather
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function toTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
type Props = Readonly<{
|
||||||
// Supplied by antd's Form.Item. `id` has to be forwarded or the field loses
|
// Supplied by antd's Form.Item. `id` has to be forwarded or the field loses
|
||||||
// its association with the rendered <label>, which breaks both screen readers
|
// its association with the rendered <label>, which breaks both screen readers
|
||||||
// and any lookup by label.
|
// and any lookup by label.
|
||||||
@@ -30,7 +30,7 @@ interface Props {
|
|||||||
id?: string;
|
id?: string;
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
onCategoriesChanged: (categories: Category[]) => void;
|
onCategoriesChanged: (categories: Category[]) => void;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
// Pulled out of the item form so that typing a new category name re-renders
|
// Pulled out of the item form so that typing a new category name re-renders
|
||||||
// only this control. Left inline, every keystroke re-rendered the whole
|
// only this control. Left inline, every keystroke re-rendered the whole
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ function toTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
type Props = Readonly<{
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
filters: ItemFilters;
|
filters: ItemFilters;
|
||||||
onChange: (filters: ItemFilters) => void;
|
onChange: (filters: ItemFilters) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
const centsToDollars = (cents: number | null): number | null => (cents === null ? null : cents / 100);
|
const centsToDollars = (cents: number | null): number | null => (cents === null ? null : cents / 100);
|
||||||
const dollarsToCents = (dollars: number | null): number | null =>
|
const dollarsToCents = (dollars: number | null): number | null =>
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ export default function Cart() {
|
|||||||
open={addAddressOpen}
|
open={addAddressOpen}
|
||||||
onOk={handleAddAddress}
|
onOk={handleAddAddress}
|
||||||
onCancel={() => setAddAddressOpen(false)}
|
onCancel={() => setAddAddressOpen(false)}
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Form.Item name="fullName" label="Full Name" rules={[{ required: true }]}>
|
<Form.Item name="fullName" label="Full Name" rules={[{ required: true }]}>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function useCart() {
|
|||||||
return useContext(CartContext);
|
return useContext(CartContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CartProvider({ children }: { children: React.ReactNode }) {
|
export function CartProvider({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
const [items, setItems] = useState<CartItem[]>([]);
|
const [items, setItems] = useState<CartItem[]>([]);
|
||||||
const { customer } = useCustomerAuth();
|
const { customer } = useCustomerAuth();
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import Button from 'antd/es/button';
|
|||||||
import type { FilterOptions } from '../api';
|
import type { FilterOptions } from '../api';
|
||||||
import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters } from '../filters';
|
import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters } from '../filters';
|
||||||
|
|
||||||
interface Props {
|
type Props = Readonly<{
|
||||||
options: FilterOptions | null;
|
options: FilterOptions | null;
|
||||||
filters: ItemFilters;
|
filters: ItemFilters;
|
||||||
onChange: (filters: ItemFilters) => void;
|
onChange: (filters: ItemFilters) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
export default function ActiveFilterChips({ options, filters, onChange, onClear }: Props) {
|
export default function ActiveFilterChips({ options, filters, onChange, onClear }: Props) {
|
||||||
if (!hasActiveFilters(filters)) return null;
|
if (!hasActiveFilters(filters)) return null;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
|
|
||||||
interface Props {
|
type Props = Readonly<{
|
||||||
size?: number;
|
size?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The RD monogram, as an inline SVG rather than an `<img src="/favicon.svg">`.
|
* The RD monogram, as an inline SVG rather than an `<img src="/favicon.svg">`.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { DataNode } from 'antd/es/tree';
|
|||||||
import type { FilterOptions } from '../api';
|
import type { FilterOptions } from '../api';
|
||||||
import { ItemFilters, buildCategoryTree, CategoryNode } from '../filters';
|
import { ItemFilters, buildCategoryTree, CategoryNode } from '../filters';
|
||||||
|
|
||||||
interface Props {
|
type Props = Readonly<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
options: FilterOptions | null;
|
options: FilterOptions | null;
|
||||||
@@ -19,7 +19,7 @@ interface Props {
|
|||||||
onChange: (filters: ItemFilters) => void;
|
onChange: (filters: ItemFilters) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
resultCount: number;
|
resultCount: number;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
function toTreeData(nodes: CategoryNode[]): DataNode[] {
|
function toTreeData(nodes: CategoryNode[]): DataNode[] {
|
||||||
return nodes.map((node) => ({
|
return nodes.map((node) => ({
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
interface Props {
|
type Props = Readonly<{
|
||||||
content: string | null;
|
content: string | null;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
export default function MarkdownView({ content }: Props) {
|
export default function MarkdownView({ content }: Props) {
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import AccountDetails from './AccountDetails';
|
|||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
interface Props {
|
type Props = Readonly<{
|
||||||
// Supplied by the route, which decides where closing lands: back to the page
|
// Supplied by the route, which decides where closing lands: back to the page
|
||||||
// the customer came from, or to the storefront when they arrived directly.
|
// the customer came from, or to the storefront when they arrived directly.
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
export default function Account({ onClose }: Props) {
|
export default function Account({ onClose }: Props) {
|
||||||
const { customer, loading, refresh, logout } = useCustomerAuth();
|
const { customer, loading, refresh, logout } = useCustomerAuth();
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export type AuthMode = 'register' | 'login';
|
|||||||
export const MARKETING_CONSENT_TEXT =
|
export const MARKETING_CONSENT_TEXT =
|
||||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
||||||
|
|
||||||
interface Props {
|
type Props = Readonly<{
|
||||||
mode: AuthMode;
|
mode: AuthMode;
|
||||||
onModeChange: (mode: AuthMode) => void;
|
onModeChange: (mode: AuthMode) => void;
|
||||||
onForgotPassword: () => void;
|
onForgotPassword: () => void;
|
||||||
@@ -31,7 +31,7 @@ interface Props {
|
|||||||
// route closes back to the page behind it, while the cart and favorite
|
// route closes back to the page behind it, while the cart and favorite
|
||||||
// prompts resume the action the customer was interrupted doing.
|
// prompts resume the action the customer was interrupted doing.
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
// The one implementation of signing in and registering. It was previously
|
// The one implementation of signing in and registering. It was previously
|
||||||
// written twice — once as the /login and /register pages, once inside the
|
// written twice — once as the /login and /register pages, once inside the
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import Modal from 'antd/es/modal';
|
|||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import AuthForm, { AuthMode } from './AuthForm';
|
import AuthForm, { AuthMode } from './AuthForm';
|
||||||
|
|
||||||
interface Props {
|
type Props = Readonly<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
// The prompt shown when a signed-out visitor does something that needs an
|
// The prompt shown when a signed-out visitor does something that needs an
|
||||||
// account — adding to the cart, favoriting, or filtering by favorites. It is
|
// account — adding to the cart, favoriting, or filtering by favorites. It is
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import Modal from 'antd/es/modal';
|
import Modal from 'antd/es/modal';
|
||||||
import AuthForm, { AuthMode } from './AuthForm';
|
import AuthForm, { AuthMode } from './AuthForm';
|
||||||
|
|
||||||
interface Props {
|
type Props = Readonly<{
|
||||||
mode: AuthMode;
|
mode: AuthMode;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
// Moving between the auth routes, supplied by the router so the rule about
|
// Moving between the auth routes, supplied by the router so the rule about
|
||||||
// keeping the whole detour to one history entry lives in one place.
|
// keeping the whole detour to one history entry lives in one place.
|
||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
const TITLES: Record<AuthMode, string> = {
|
const TITLES: Record<AuthMode, string> = {
|
||||||
register: 'Create an account',
|
register: 'Create an account',
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function useCustomerAuth() {
|
|||||||
return useContext(CustomerAuthContext);
|
return useContext(CustomerAuthContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CustomerAuthProvider({ children }: { children: React.ReactNode }) {
|
export function CustomerAuthProvider({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
const [customer, setCustomer] = useState<Customer | null>(null);
|
const [customer, setCustomer] = useState<Customer | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function useFavorites() {
|
|||||||
// Mirrors CartProvider: one fetch for the whole storefront rather than each
|
// Mirrors CartProvider: one fetch for the whole storefront rather than each
|
||||||
// card asking whether it is favorited, and it clears on sign-out so one
|
// card asking whether it is favorited, and it clears on sign-out so one
|
||||||
// customer's favorites never show to the next.
|
// customer's favorites never show to the next.
|
||||||
export function FavoritesProvider({ children }: { children: React.ReactNode }) {
|
export function FavoritesProvider({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
const [favorites, setFavorites] = useState<Favorite[]>([]);
|
const [favorites, setFavorites] = useState<Favorite[]>([]);
|
||||||
const { customer } = useCustomerAuth();
|
const { customer } = useCustomerAuth();
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import { requestPasswordReset } from './customerApi';
|
|||||||
|
|
||||||
const { Paragraph, Text } = Typography;
|
const { Paragraph, Text } = Typography;
|
||||||
|
|
||||||
interface Props {
|
type Props = Readonly<{
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
// Steps back to sign-in without leaving a history entry behind, the same way
|
// Steps back to sign-in without leaving a history entry behind, the same way
|
||||||
// the auth modal switches between its own tabs.
|
// the auth modal switches between its own tabs.
|
||||||
onBackToSignIn: () => void;
|
onBackToSignIn: () => void;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
export default function ForgotPassword({ onClose, onBackToSignIn }: Props) {
|
export default function ForgotPassword({ onClose, onBackToSignIn }: Props) {
|
||||||
const [sent, setSent] = useState(false);
|
const [sent, setSent] = useState(false);
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import { useCustomerAuth } from './CustomerAuthContext';
|
|||||||
|
|
||||||
const { Paragraph } = Typography;
|
const { Paragraph } = Typography;
|
||||||
|
|
||||||
interface Props {
|
type Props = Readonly<{
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRequestNewLink: () => void;
|
onRequestNewLink: () => void;
|
||||||
onBackToSignIn: () => void;
|
onBackToSignIn: () => void;
|
||||||
}
|
}>;
|
||||||
|
|
||||||
export default function ResetPassword({ onClose, onRequestNewLink, onBackToSignIn }: Props) {
|
export default function ResetPassword({ onClose, onRequestNewLink, onBackToSignIn }: Props) {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function getInitialMode(): ThemeMode {
|
|||||||
: 'light';
|
: 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeModeProvider({ children }: { children: React.ReactNode }) {
|
export function ThemeModeProvider({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
const [mode, setMode] = useState<ThemeMode>(getInitialMode);
|
const [mode, setMode] = useState<ThemeMode>(getInitialMode);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user