Files
Epicnabbo-Catalogus-Updated…/src/hooks/use-server-action.ts
T
openhands 759ae91745 perf: cache search and news archive, drop motion/react from public pages
Closes the four remaining LOW items.

Search and news archive caching
- A leading-wildcard LIKE cannot use an index, so every /search section cost a
  COUNT(*) scan plus an ordered page fetch, and /news did the same for its
  archive. Both now cache: search per section for 30s, the archive for 60s
  under the existing news revision so publishing an article drops it at once.
- Sections are cached independently, so one slow query cannot hold up the rest
  and a failure is not cached as a result.
- The cached value is passed through cacheSafe() so the Redis path and the
  in-process path return the same types; without it a cache hit would hand the
  events grid a string where a miss hands it a Date, and it calls toISOString()
  on that field. Dates are revived on the way out so the public signatures of
  loadNewsArchive and loadPublicSearch are unchanged.
- Archive entries are keyed on the REQUESTED page rather than the clamped one,
  so two requests that clamp onto the same page cannot alias each other.

motion/react out of the public bundle
- Converted the six public-facing users: the radio player, the typewriter text
  (a motion.span with no animation props at all), the photo lightbox, the
  animated counter, the footer CMS-info popup and the scroll reveal. That was
  the actual entry points — the counter and the popup reach the public home page
  and footer through static imports, so removing only the three originally named
  would have left the library in the bundle anyway.
- Each animation moved to a CSS class, and the two that animate on exit now hold
  the element for the length of the fade, which is what AnimatePresence used to
  do.
- motion/react now only ships with /admin and the two already-lazy nav panels.
- Two safety fixes came out of this: the scroll reveal starts at opacity 0, so
  it is forced visible under prefers-reduced-motion and via a <noscript> rule in
  the root layout; and it now emits the .motion-reveal class, which the theme
  panel's "Scroll Reveal" toggle selects and which previously matched nothing.
- The CMS-info backdrop became a real button in a pointer-transparent layer
  instead of a handler on a static element, so click-outside-to-dismiss is
  reachable by keyboard.

Fewer duplicate router refreshes
- Next.js re-renders the current route as part of a server action's own response
  when that action revalidates, and applies it with a seeded navigation; the
  router only skips its own update when the action did NOT revalidate. So the
  refresh after such an action fetched the same tree twice.
- useServerAction takes an opt-in `revalidated` flag that skips it. It is opt-in
  per call rather than derived from an action name, since a rename would
  silently change behaviour. Applied to the two user-facing call sites whose
  actions were verified to revalidate their own route.

Touch targets
- .btn was the one shared control at 40px; it and the lightbox and CMS-info
  close buttons are now 44px, as is the password toggle (the auth input already
  reserved 44px for it). The remaining 32px icon buttons pass WCAG 2.2 AA, which
  only asks for 24px; enlarging those inside inputs and overlays was left alone
  because it risks visual breakage that cannot be checked from here.
2026-10-09 17:35:38 +02:00

111 lines
3.5 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import type { ActionResult } from "@/lib/safe-action-shared";
interface UseServerActionOptions {
/** Where to redirect after a successful action (optional) */
redirectOnSuccess?: string;
}
interface RunOptions {
successMessage?: string;
errorMessage?: string;
/** Override redirect path for this specific call */
redirectTo?: string;
/** Called on success with the result data. Overrides default router.refresh(). */
onSuccess?: (data: Record<string, unknown>) => void;
/** Called when the action fails or throws */
onError?: () => void;
/**
* Set only when the action calls `revalidatePath()` / `revalidateTag()` for
* the route being displayed. Next.js then re-renders that page as part of the
* action's own response and the router applies it with a seeded navigation,
* so the extra `router.refresh()` is a duplicate round-trip.
*
* The framework only skips its own update when the action did NOT revalidate
* and sent no flight data — see the bail-out in
* `next/dist/client/components/router-reducer/reducers/server-action-reducer.js`.
*
* Leaving this off is always safe. Setting it for an action that does not
* revalidate leaves the screen stale, so it is opt-in per call rather than
* derived from the action name.
*/
revalidated?: boolean;
}
/**
* Hook that wraps a server action with loading state, toast notifications,
* and optional redirect/refresh. Drop-in replacement for useAdminForm.
*
* @example
* const { run, isPending } = useServerAction()
*
* // Submit
* run(() => updateNews({ id, ...data }), { successMessage: 'Saved!' })
*
* // Delete with redirect
* run(() => deleteNews(id), { redirectTo: '/admin/news' })
*/
export function useServerAction(options: UseServerActionOptions = {}) {
const t = useTranslations("pages.admin.actions");
const locked = useRef(false);
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
const [isPending, startTransition] = useTransition();
const router = useRouter();
function run(action: () => Promise<unknown>, opts: RunOptions = {}) {
if (locked.current) return;
locked.current = true;
setFieldErrors({});
const {
successMessage = t("saved"),
errorMessage = t("error"),
redirectTo,
onSuccess,
onError,
revalidated = false,
} = opts;
startTransition(async () => {
try {
const result = (await action()) as ActionResult | undefined;
if (!result || result.ok) {
toast.success(successMessage);
if (onSuccess) {
onSuccess((result?.data ?? {}) as Record<string, unknown>);
} else {
const target = redirectTo || options.redirectOnSuccess;
if (target) router.push(target);
// The action already revalidated the current route, so its
// own response carried the new tree; refreshing again would
// just re-fetch it.
else if (!revalidated) router.refresh();
}
} else {
setFieldErrors(result.fieldErrors ?? {});
toast.error(result.error || errorMessage, {
description:
Object.values(result.fieldErrors ?? {})
.flat()
.join(" · ") || undefined,
});
onError?.();
}
} catch (err) {
console.error("[useServerAction] Unexpected error:", err);
toast.error(errorMessage);
onError?.();
} finally {
locked.current = false;
}
});
}
return { run, isPending, fieldErrors };
}