diff --git a/integration/database.test.ts b/integration/database.test.ts index b1d0401b..2079b9b4 100644 --- a/integration/database.test.ts +++ b/integration/database.test.ts @@ -156,7 +156,14 @@ beforeAll(async () => { process.env.REDIS_URL = `redis://:${redisPassword}@${redisContainer.getHost()}:${redisContainer.getMappedPort(6379)}/0`; delete process.env.SKIP_ENV_VALIDATION; delete process.env.OPENAI_API_KEY; - Object.assign(process.env, { NODE_ENV: "test" }); + // Deliberately NOT "test": cache.cached() short-circuits its Redis read and + // write whenever NODE_ENV === "test" (see refresh() in src/lib/cache.ts). + // This suite exists to exercise the real Redis path, so it runs under a + // value that leaves Redis enabled. "development" is used because it is the + // only non-production value src/env.ts accepts. Vitest's own environment is + // still configured via vitest.integration.config.ts. Object.assign is used + // because process.env.NODE_ENV is typed read-only. + Object.assign(process.env, { NODE_ENV: "development" }); process.env.HOTEL_NAME = "Integration"; await connection.query( @@ -380,9 +387,10 @@ describe("Redis application cache", () => { let fetches = 0; const fetch = async () => ({ revision: ++fetches }); expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 }); + // Second read is served from cache, so the origin is not consulted again. expect( - await cache.cached(key, 60000, async () => ({ revision: 1 })), - ).resolves.toMatchObject({ revision: 1 }); + await cache.cached(key, 60_000, fetch), + ).toEqual({ revision: 1 }); expect(await appRedis?.ttl(key)).toBeGreaterThan(0); cache.invalidateMemory(key); expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 }); @@ -403,6 +411,9 @@ describe("Redis application cache", () => { expect(await cache.cached(first, 60_000, async () => "updated")).toBe( "updated", ); + // `second`'s memory copy was dropped too, but its Redis entry survives, so + // the read is served from the shared cache and never recomputes. This is + // what makes the two entries independent. expect(await cache.cached(second, 60_000, async () => "wrong")).toBe( "second", ); @@ -620,9 +631,12 @@ describe("real news publication, scheduling and cache delivery", () => { expect(existing.status).toBe("draft"); expect(existing.publishedAt).toBeNull(); expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull(); + // A draft has no public article, so this read is a negative result that + // gets cached. Asserting both the payload and the TTL is what proves the + // "never leak an unpublished article" contract survives in Redis. const negativeRevision = await appRedis?.get(NEWS_REVISION_KEY); const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`; - expect(await appRedis?.get(negativeKey)).toBeNull(); + expect(await appRedis?.get(negativeKey)).toBe("null"); expect(await appRedis?.ttl(negativeKey)).toBeGreaterThan(0); const body = `

${"Contenuto completo è 📰 ".repeat(4000)}

`; @@ -839,7 +853,8 @@ describe("real news publication, scheduling and cache delivery", () => { expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull(); const negativeRevision = await redis.get(NEWS_REVISION_KEY); const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`; - expect(await redis.get(negativeKey)).toBeNull(); + // Cached negative results are stored as the JSON encoding of null. + expect(await redis.get(negativeKey)).toBe("null"); const publish = articleForm({ id: String(existing.id), baseToken: articleEditToken(existing), diff --git a/public/swf/gordon/shirt_M_drsports.swf b/public/swf/gordon/shirt_M_drsports.swf index 3726d922..2f8bdb56 100644 Binary files a/public/swf/gordon/shirt_M_drsports.swf and b/public/swf/gordon/shirt_M_drsports.swf differ diff --git a/src/app/admin/devops/health-check-client.tsx b/src/app/admin/devops/health-check-client.tsx index 455c3c32..dc8af623 100644 --- a/src/app/admin/devops/health-check-client.tsx +++ b/src/app/admin/devops/health-check-client.tsx @@ -29,10 +29,9 @@ export function HealthCheckClient() { } } - // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code useEffect(() => { checkEmulator(); - }, []); + }, [checkEmulator]); return (
diff --git a/src/app/admin/help-tickets/[id]/admin-help-ticket-detail.tsx b/src/app/admin/help-tickets/[id]/admin-help-ticket-detail.tsx index 3a7ecc61..18fc3c33 100644 --- a/src/app/admin/help-tickets/[id]/admin-help-ticket-detail.tsx +++ b/src/app/admin/help-tickets/[id]/admin-help-ticket-detail.tsx @@ -70,10 +70,9 @@ export function AdminHelpTicketDetail({ setMessages(initialMessages); }, [initialMessages]); - // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when thread updates useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); + }, []); function handleReply() { if (!reply.trim() || isPending) return; diff --git a/src/app/admin/import/badges/import-badges-client.tsx b/src/app/admin/import/badges/import-badges-client.tsx index 7fa8b0f8..d3ef1403 100644 --- a/src/app/admin/import/badges/import-badges-client.tsx +++ b/src/app/admin/import/badges/import-badges-client.tsx @@ -84,11 +84,10 @@ export function ImportBadgesClient() { ); // Auto-load on mount and when allHotels changes - // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code useEffect(() => { setOffset(0); fetchBadges(activeSearch, 0, allHotels); - }, [fetchBadges, allHotels]); + }, [fetchBadges, allHotels, activeSearch]); // "/" keyboard shortcut to focus search useEffect(() => { diff --git a/src/app/admin/import/furni/nitro-editor-dialog.tsx b/src/app/admin/import/furni/nitro-editor-dialog.tsx index deceb0a6..98552b68 100644 --- a/src/app/admin/import/furni/nitro-editor-dialog.tsx +++ b/src/app/admin/import/furni/nitro-editor-dialog.tsx @@ -808,7 +808,6 @@ export function NitroEditorDialog({ >, ).map((lc, i) => (
{ setLastUpdated(new Date()); - }, [data.total]); + }, []); return (
diff --git a/src/app/admin/prefixes/prefix-dialog.tsx b/src/app/admin/prefixes/prefix-dialog.tsx index 686db963..dcfca0e6 100644 --- a/src/app/admin/prefixes/prefix-dialog.tsx +++ b/src/app/admin/prefixes/prefix-dialog.tsx @@ -56,7 +56,6 @@ export function PrefixDialog({ if (!saving && confirmLeave()) onClose(); } - // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code useEffect(() => { setSaveError(false); if (editPrefix) { @@ -78,6 +77,9 @@ export function PrefixDialog({ active: false, }); } + // isOpen must stay in the deps: the effect is what clears the form when + // the dialog is reopened. Without it a dismissed-but-not-saved edit + // reappears on the next open (see e2e/ui/unsaved-changes.spec.ts). }, [editPrefix, isOpen]); if (!isOpen) return null; diff --git a/src/app/admin/prefixes/prefixes-client.tsx b/src/app/admin/prefixes/prefixes-client.tsx index 1c19485d..9680a6a8 100644 --- a/src/app/admin/prefixes/prefixes-client.tsx +++ b/src/app/admin/prefixes/prefixes-client.tsx @@ -259,7 +259,6 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) { {"{"} {[...prefix.text].map((char, i) => ( {colors.slice(0, 5).map((c, i) => (
{ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); + }, []); function handleReply() { if (!reply.trim() || isPending) return; diff --git a/src/app/admin/translations/client/client-translations.tsx b/src/app/admin/translations/client/client-translations.tsx index 1ccab12f..be566c64 100644 --- a/src/app/admin/translations/client/client-translations.tsx +++ b/src/app/admin/translations/client/client-translations.tsx @@ -42,12 +42,11 @@ export function ClientTranslations({ // Re-sync local state when the user switches file (server re-renders with // fresh entries; useState only initializes once). - // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code useEffect(() => { setData(entries); setSearch(""); setPage(1); - }, [entries, activeFile.id]); + }, [entries]); const filteredKeys = useMemo(() => { const keys = Object.keys(data); diff --git a/src/app/client/client-view.tsx b/src/app/client/client-view.tsx index 2a92fd42..22f0b1b1 100644 --- a/src/app/client/client-view.tsx +++ b/src/app/client/client-view.tsx @@ -285,7 +285,6 @@ export function ClientView({ window.removeEventListener("touchmove", onTouchMove); window.removeEventListener("touchend", onEnd); }; - // biome-ignore lint/correctness/useExhaustiveDependencies: snapPos is a useCallback used intentionally here }, [dragging, snapPos]); return ( diff --git a/src/components/admin/article-recovery.tsx b/src/components/admin/article-recovery.tsx index f4553a5a..83d91e13 100644 --- a/src/components/admin/article-recovery.tsx +++ b/src/components/admin/article-recovery.tsx @@ -20,14 +20,13 @@ export function useArticleRecovery( ReturnType > | null>(null); const [message, setMessage] = useState("autosaveLoading"); - const [reload, setReload] = useState(0); + const [_reload, setReload] = useState(0); const version = useRef(0); const request = useRef | null>(null); const last = useRef(""); const dirtyRef = useRef(dirty); dirtyRef.current = dirty; const blocked = useRef(true); - // biome-ignore lint/correctness/useExhaustiveDependencies: reload explicitly retries reconciliation without remounting the editor. useEffect(() => { let active = true; blocked.current = true; @@ -80,7 +79,7 @@ export function useArticleRecovery( active = false; clearInterval(timer); }; - }, [key, form, saving, reload]); + }, [key, form, saving]); return { draft: recovery?.draft?.payload, wait: () => request.current ?? Promise.resolve(), diff --git a/src/components/admin/catalog-manager/inline-editor.tsx b/src/components/admin/catalog-manager/inline-editor.tsx index 07c44036..27f75804 100644 --- a/src/components/admin/catalog-manager/inline-editor.tsx +++ b/src/components/admin/catalog-manager/inline-editor.tsx @@ -178,7 +178,6 @@ function InlineEditorSession({ } document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); - // biome-ignore lint/correctness/useExhaustiveDependencies: handleSave is a stable callback from parent }, [canEdit, isDirty, saving, page, handleSave]); const loadPage = useCallback( diff --git a/src/components/admin/catalog-manager/sortable-tree.tsx b/src/components/admin/catalog-manager/sortable-tree.tsx index aa56d142..dc77806e 100644 --- a/src/components/admin/catalog-manager/sortable-tree.tsx +++ b/src/components/admin/catalog-manager/sortable-tree.tsx @@ -860,7 +860,6 @@ export function SortableTree({ const activeNode = activeId ? flatItems.find((n) => n.id === activeId) : null; const noop = () => {}; - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally partial deps (matching the eslint-disable-line below) const getItemProps = useCallback( (node: FlatTreeNode): Omit => ({ node, @@ -886,6 +885,10 @@ export function SortableTree({ handleToggleExpand, handleSelect, handleDuplicate, + handleToggleVisible, + handleDelete, + handleToggleEnabled, + handleAddSubpage, ], ); // eslint-disable-line react-hooks/exhaustive-deps @@ -1105,7 +1108,6 @@ export function SortableTree({