diff --git a/AGENTS.md b/AGENTS.md index 283fcbd..cbe107d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,8 @@ Guidelines for AI coding agents working on the official Micro Club website. - `src/actions/`: Server Actions (`"use server"`), in `admin/` (staff only, guarded by `requireStaff()`) or `public/` (site visitors, rate limited), kept thin: validate input, rate limit, then call a `server-only` module in `src/lib/` (e.g. `src/lib/microbot-api.ts`, `src/lib/cloudinary.ts`), the only place that reads its secrets from `process.env`. Never expose a secret with `NEXT_PUBLIC_`; document every variable in `.env.example`. - `src/config/`: site metadata, section anchors, menu links, admin routes (`admin.ts`). - `src/const/`: static content constants, one file per section named like the section file (e.g. `src/const/statistics.ts` exports `STATISTICS` and its types). -- Error UI: `src/app/not-found.tsx` (global 404), `src/app/(site)/error.tsx` (error boundary with menu/footer), `src/app/global-error.tsx` (root layout failures, no global styles). +- Error UI: `src/app/not-found.tsx` (global 404), `src/app/(site)/error.tsx` and `src/app/(registrations)/error.tsx` (error boundaries inside their layout, both rendering `ErrorFallback` from `src/components/shared/`), `src/app/global-error.tsx` (root layout failures, no global styles). +- `src/instrumentation-client.ts` runs before the app is interactive: it installs `guardDomAgainstTranslation()` (`src/lib/translate-dom-guard.ts`), which keeps React from crashing on pages Chrome's Google Translate rewrote. Keep it; mark the applicant's own text `translate="no"` where it must stay as typed (the registration recap). ## Local services diff --git a/src/app/(registrations)/error.tsx b/src/app/(registrations)/error.tsx new file mode 100644 index 0000000..c256516 --- /dev/null +++ b/src/app/(registrations)/error.tsx @@ -0,0 +1,6 @@ +"use client" + +import { ErrorFallback } from "@/components/shared/error-fallback" + +// Without it a crash in the form reaches `global-error.tsx`, which replaces the whole root layout. +export default ErrorFallback diff --git a/src/app/(site)/error.tsx b/src/app/(site)/error.tsx index 7ec6497..edd76b9 100644 --- a/src/app/(site)/error.tsx +++ b/src/app/(site)/error.tsx @@ -1,32 +1,5 @@ "use client" -import { useEffect } from "react" +import { ErrorFallback } from "@/components/shared/error-fallback" -export default function Error({ - error, - retry, -}: { - error: Error & { digest?: string } - retry: () => void -}) { - useEffect(() => { - // TODO: report to an error tracking service. - console.error(error) - }, [error]) - - return ( -
-

Something went wrong

-

- An unexpected error occurred. Please try again. -

- -
- ) -} +export default ErrorFallback diff --git a/src/components/registrations/review-dialog.tsx b/src/components/registrations/review-dialog.tsx index 8283e24..fabe405 100644 --- a/src/components/registrations/review-dialog.tsx +++ b/src/components/registrations/review-dialog.tsx @@ -29,6 +29,12 @@ const LABEL = "text-xs leading-5 font-medium text-muted-foreground uppercase" const VALUE = "text-sm leading-6 break-words text-foreground" /** Optional answers left empty. */ const EMPTY = "text-sm leading-6 text-muted-foreground italic" +/** + * On the applicant's own answers: a browser translator (Chrome's Google Translate) leaves them as + * typed, so the recap shows what will be sent (it turned the skill "react" into "réagir"). The + * labels and questions around them still translate. + */ +const AS_TYPED = { translate: "no" } as const /** Preview of the chosen student ID (images only), read as a data URL like the upload field. */ function useImagePreview(file: File | null) { @@ -166,7 +172,9 @@ export function ReviewDialog() { ].map(({ label, value }) => (
{label}
-
{value}
+
+ {value} +
))}
@@ -189,7 +197,11 @@ export function ReviewDialog() { /> )} - + {studentId?.name} @@ -202,7 +214,9 @@ export function ReviewDialog() { {ABOUT_QUESTIONS.map(({ field, label }) => (
{label}
-
{values[field]}
+
+ {values[field]} +
))} @@ -222,7 +236,7 @@ export function ReviewDialog() { rel="noopener noreferrer" className="inline-flex max-w-full items-center gap-1 rounded-sm text-sm leading-6 text-primary underline underline-offset-2 outline-none hover:text-accent-foreground focus-visible:ring-4 focus-visible:ring-ring" > - + {values[field].replace(/^https?:\/\/(www\.)?/, "")} @@ -238,7 +252,7 @@ export function ReviewDialog() {
Skills
-
    +
      {values.skills.map((skill) => (
    • void +}) { + useEffect(() => { + // TODO: report to an error tracking service. + console.error(error) + }, [error]) + + return ( +
      +

      Something went wrong

      +

      + An unexpected error occurred. Please try again. +

      + +
      + ) +} diff --git a/src/instrumentation-client.ts b/src/instrumentation-client.ts new file mode 100644 index 0000000..93c31ee --- /dev/null +++ b/src/instrumentation-client.ts @@ -0,0 +1,4 @@ +import { guardDomAgainstTranslation } from "@/lib/translate-dom-guard" + +// Runs before the app becomes interactive, so React never touches the DOM unguarded. +guardDomAgainstTranslation() diff --git a/src/lib/translate-dom-guard.test.ts b/src/lib/translate-dom-guard.test.ts new file mode 100644 index 0000000..c1e60fa --- /dev/null +++ b/src/lib/translate-dom-guard.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest" + +import { guardDomAgainstTranslation } from "@/lib/translate-dom-guard" + +type FakeNode = { name: string; parentNode: FakeNode | null } + +const node = (name: string, parentNode: FakeNode | null = null): FakeNode => ({ name, parentNode }) + +/** A prototype whose native methods only record the calls they get, guarded. */ +function guardedProto() { + const calls: string[] = [] + const proto = { + insertBefore(this: FakeNode, added: FakeNode, child: FakeNode | null) { + calls.push(`insert ${added.name} into ${this.name} before ${child?.name ?? "end"}`) + return added + }, + removeChild(this: FakeNode, child: FakeNode) { + calls.push(`remove ${child.name} from ${this.name}`) + return child + }, + } + guardDomAgainstTranslation(proto as unknown as Pick) + return { calls, proto } +} + +describe("guardDomAgainstTranslation", () => { + it("leaves calls on nodes still in place unchanged", () => { + const { calls, proto } = guardedProto() + const button = node("button") + const label = node("label", button) + const spinner = node("spinner") + + proto.insertBefore.call(button, spinner, label) + proto.insertBefore.call(button, spinner, null) + proto.removeChild.call(button, label) + + expect(calls).toEqual([ + "insert spinner into button before label", + "insert spinner into button before end", + "remove label from button", + ]) + }) + + it("inserts before the wrapper that now holds the reference node", () => { + const { calls, proto } = guardedProto() + const button = node("button") + const font = node("font", button) + const label = node("label", node("inner font", font)) + + proto.insertBefore.call(button, node("spinner"), label) + + expect(calls).toEqual(["insert spinner into button before font"]) + }) + + it("appends when the reference node left the page (replaced by the translation)", () => { + const { calls, proto } = guardedProto() + + proto.insertBefore.call(node("button"), node("spinner"), node("label")) + + expect(calls).toEqual(["insert spinner into button before end"]) + }) + + it("removes the wrapper that now holds the node", () => { + const { calls, proto } = guardedProto() + const dd = node("dd") + const font = node("font", dd) + const text = node("text", font) + + expect(proto.removeChild.call(dd, text)).toBe(text) + expect(calls).toEqual(["remove font from dd"]) + }) + + it("does nothing when the node to remove already left the page", () => { + const { calls, proto } = guardedProto() + const text = node("text") + + expect(proto.removeChild.call(node("dd"), text)).toBe(text) + expect(calls).toEqual([]) + }) + + it("guards a prototype only once", () => { + const { calls, proto } = guardedProto() + guardDomAgainstTranslation(proto as unknown as Pick) + + proto.insertBefore.call(node("button"), node("spinner"), node("label")) + + expect(calls).toEqual(["insert spinner into button before end"]) + }) +}) diff --git a/src/lib/translate-dom-guard.ts b/src/lib/translate-dom-guard.ts new file mode 100644 index 0000000..25962a2 --- /dev/null +++ b/src/lib/translate-dom-guard.ts @@ -0,0 +1,42 @@ +type GuardedMethods = Pick + +const GUARDED = Symbol.for("mc.translate-dom-guard") + +/** The direct child of `parent` that holds `node` (`node` itself or an ancestor), or `null`. */ +function childHolding(parent: Node, node: Node): Node | null { + let current: Node | null = node + while (current && current.parentNode !== parent) current = current.parentNode + return current +} + +/** + * Keeps React from crashing on pages rewritten by a browser translator (Chrome's Google Translate). + * The translator replaces text nodes with its own `` elements, so React later inserts before, + * or removes, a node that is no longer where it left it, and the DOM throws `NotFoundError` + * (facebook/react#11538). The page then falls to the error boundary: on the registration form, + * right after SUBMIT, while the request still goes through. + * + * Instead of throwing, both calls fall back on the closest correct move: before (or remove) the + * child of the parent that still holds the node, else append (or do nothing) when the node left + * the page. Calls on nodes still in place run unchanged, so pages that are not translated never + * reach the fallback. A text React updates after the rewrite can stay in its translated form. + */ +export function guardDomAgainstTranslation(proto: GuardedMethods = Node.prototype) { + const target = proto as GuardedMethods & { [GUARDED]?: true } + if (target[GUARDED]) return + target[GUARDED] = true + + const { insertBefore, removeChild } = proto + + proto.insertBefore = function (this: Node, node: T, child: Node | null): T { + if (!child || child.parentNode === this) return insertBefore.call(this, node, child) as T + return insertBefore.call(this, node, childHolding(this, child)) as T + } + + proto.removeChild = function (this: Node, child: T): T { + if (child.parentNode === this) return removeChild.call(this, child) as T + const holder = childHolding(this, child) + if (holder) removeChild.call(this, holder) + return child + } +}