Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions src/app/(registrations)/error.tsx
Original file line number Diff line number Diff line change
@@ -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
31 changes: 2 additions & 29 deletions src/app/(site)/error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-svh flex-col items-center justify-center gap-6 px-4 text-center">
<h1 className="header-md font-semibold">Something went wrong</h1>
<p className="paragraph-md text-muted-foreground">
An unexpected error occurred. Please try again.
</p>
<button
type="button"
onClick={() => retry()}
className="inline-flex h-10 items-center rounded-lg bg-primary px-4 font-medium text-primary-foreground transition-opacity hover:opacity-90"
>
Try again
</button>
</div>
)
}
export default ErrorFallback
24 changes: 19 additions & 5 deletions src/components/registrations/review-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -166,7 +172,9 @@ export function ReviewDialog() {
].map(({ label, value }) => (
<div key={label} className="flex min-w-0 flex-col">
<dt className={LABEL}>{label}</dt>
<dd className={VALUE}>{value}</dd>
<dd {...AS_TYPED} className={VALUE}>
{value}
</dd>
</div>
))}
<div className="flex min-w-0 flex-col gap-1.5 sm:col-span-2">
Expand All @@ -189,7 +197,11 @@ export function ReviewDialog() {
/>
</span>
)}
<span className={cn(VALUE, "min-w-0 truncate")} title={studentId?.name}>
<span
{...AS_TYPED}
className={cn(VALUE, "min-w-0 truncate")}
title={studentId?.name}
>
{studentId?.name}
</span>
</dd>
Expand All @@ -202,7 +214,9 @@ export function ReviewDialog() {
{ABOUT_QUESTIONS.map(({ field, label }) => (
<div key={field} className="flex flex-col gap-1 p-4">
<dt className="text-sm leading-5 font-medium text-muted-foreground">{label}</dt>
<dd className={cn(VALUE, "whitespace-pre-wrap")}>{values[field]}</dd>
<dd {...AS_TYPED} className={cn(VALUE, "whitespace-pre-wrap")}>
{values[field]}
</dd>
</div>
))}
</dl>
Expand All @@ -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"
>
<span className="truncate">
<span {...AS_TYPED} className="truncate">
{values[field].replace(/^https?:\/\/(www\.)?/, "")}
</span>
<ExternalLinkIcon className="size-3.5 shrink-0" aria-hidden />
Expand All @@ -238,7 +252,7 @@ export function ReviewDialog() {
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
<dt className={LABEL}>Skills</dt>
<dd>
<ul className="flex flex-wrap gap-1.5">
<ul {...AS_TYPED} className="flex flex-wrap gap-1.5">
{values.skills.map((skill) => (
<li
key={skill}
Expand Down
36 changes: 36 additions & 0 deletions src/components/shared/error-fallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use client"

import { useEffect } from "react"

/**
* Body of a route group's `error.tsx`: shown in place of the page, inside the group's layout (the
* menu stays), with a button that renders the page again.
*/
export function ErrorFallback({
error,
retry,
}: {
error: Error & { digest?: string }
retry: () => void
}) {
useEffect(() => {
// TODO: report to an error tracking service.
console.error(error)
}, [error])

return (
<div className="flex min-h-svh flex-col items-center justify-center gap-6 px-4 text-center">
<h1 className="header-md font-semibold">Something went wrong</h1>
<p className="paragraph-md text-muted-foreground">
An unexpected error occurred. Please try again.
</p>
<button
type="button"
onClick={() => retry()}
className="inline-flex h-10 items-center rounded-lg bg-primary px-4 font-medium text-primary-foreground transition-opacity hover:opacity-90"
>
Try again
</button>
</div>
)
}
4 changes: 4 additions & 0 deletions src/instrumentation-client.ts
Original file line number Diff line number Diff line change
@@ -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()
89 changes: 89 additions & 0 deletions src/lib/translate-dom-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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<Node, "insertBefore" | "removeChild">)
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<Node, "insertBefore" | "removeChild">)

proto.insertBefore.call(node("button"), node("spinner"), node("label"))

expect(calls).toEqual(["insert spinner into button before end"])
})
})
42 changes: 42 additions & 0 deletions src/lib/translate-dom-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
type GuardedMethods = Pick<Node, "insertBefore" | "removeChild">

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 `<font>` 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 <T extends Node>(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 <T extends Node>(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
}
}
Loading