From 72a3306ec1352c913c659bcb6f97c59d86aa8ae7 Mon Sep 17 00:00:00 2001 From: Saviro Orin Date: Mon, 10 Aug 2026 23:40:16 +0200 Subject: [PATCH 1/3] refactor: vendor errgo utility --- CLAUDE.md | 5 +- assets/js/a11y-audit.ts | 10 +- assets/js/app.tsx | 20 +- assets/js/components/LocaleSync.tsx | 5 +- assets/js/errgo.test.mts | 22 ++ assets/js/errgo.ts | 329 ++++++++++++++++++++++++++++ assets/js/ssr.tsx | 18 +- assets/package-lock.json | 7 - assets/package.json | 2 +- assets/tsconfig.json | 2 +- lib/mix/tasks/lint.ex | 2 + 11 files changed, 390 insertions(+), 32 deletions(-) create mode 100644 assets/js/errgo.test.mts create mode 100644 assets/js/errgo.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8efc017..a2efe7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,8 +31,9 @@ These are the rules that must not be forgotten or looked up — they're the ones - Layout components live in `assets/js/layouts/` (e.g. `AppLayout`, `AuthLayout`) - Reusable UI primitives live flat in `assets/js/components/` (`Button`, `Spinner`, `Select`, `DropdownMenu`, `AlertDialog`, …) — no sub-folders - Forms use Inertia's `useForm` hook; errors come from `assign_errors(conn, changeset)` on the server -- **No raw `try/catch` for async work — wrap every Promise in `go()` from `@api3/promise-utils`** (sync work uses `goSync`). It returns `{ success, data, error }`, which forces every call site to acknowledge the failure path explicitly and prevents the "swallow the error and move on" pattern that hides real bugs. The same applies to dynamic `import()`, `fetch()`, JSON parsing, and any vendor SDK call. The only acceptable exception is at top-level error boundaries that genuinely *do* need to catch everything synchronously -- **Don't inline a multi-line function into `go(...)` / `goSync(...)`** — extract it to a named function above the call and pass it by name (`const result = await go(renderApp)`), so the call site stays a scannable one-liner. Small one-line callbacks (`go(() => i18n.changeLanguage(locale))`) are fine to inline. Mirrors the Elixir `with`-clause rule below +- **No raw `try/catch` for application async work — wrap every Promise in `go()` from `assets/js/errgo.ts`** (sync work uses `goSync`). It returns an error-first tuple, `[error, value]`, which forces every call site to acknowledge the failure path explicitly and prevents the "swallow the error and move on" pattern that hides real bugs. The same applies to dynamic `import()`, `fetch()`, JSON parsing, and vendor SDK calls. Raw catches are limited to Errgo's own implementation, React error boundaries, and CommonJS build-tool process boundaries that cannot import the TypeScript utility without a runtime transpiler +- `assets/js/errgo.ts` is vendored byte-for-byte from `andreogle/errgo` at commit `aaa1d5153a270cde4aa808369bd486ddbe263a38`. Update it by copying that source file, not by installing a package or editing the vendored implementation locally +- **Don't inline a multi-line function into `go(...)` / `goSync(...)`** — extract it to a named function above the call and pass it by name (`const [error, value] = await go(renderApp)`), so the call site stays a scannable one-liner. Small one-line callbacks (`go(() => i18n.changeLanguage(locale))`) are fine to inline. Mirrors the Elixir `with`-clause rule below - **Multi-line arrow/function bodies use explicit braces and `return`** — never a multi-line implicit return (it's too easy to lose track of what's returned, or drop the `return` when editing, and get weird behaviour). When you convert an implicit return to a block body, **keep the `return`** so the returned value is preserved — only drop it when the value is genuinely unused. And if the body fits on one line within the 120-col width, prefer collapsing to a single-line implicit return rather than a block. Single-line implicit returns (`(x) => x.id`) and idiomatic multi-line JSX render-props wrapped in parens (`({ Component }) => ( )`) are fine - Never edit `assets/js/_pages.ts`, `assets/js/_ssr_pages.ts`, or `assets/js/routes.ts` — they're auto-generated - **Frontend paths come from the `routes` helper, never string literals.** `assets/js/routes.ts` is generated from the Phoenix router by `mix routes.gen` (run in `assets.build`/`assets.deploy` + the dev watcher; `mix routes.gen --check` in `precommit` fails the build on drift). Use `routes.login()`, `routes.settingsEmail()`, etc. for every internal URL on the frontend (``, `useForm().post(...)`, `router.visit/delete(...)`) — the TS path-builder is the frontend counterpart to the server's `~p` sigil and keeps the two in sync. Names are the camelCased path (`/settings/email/apply-change` → `settingsEmailApplyChange`, `/` → `root`); `:param` segments become typed args and every builder takes an optional query object (`routes.confirmEmail({ token })`). Adding/removing a route + rebuilding regenerates the file; never hand-edit it diff --git a/assets/js/a11y-audit.ts b/assets/js/a11y-audit.ts index 370601b..c0ef4f9 100644 --- a/assets/js/a11y-audit.ts +++ b/assets/js/a11y-audit.ts @@ -1,6 +1,6 @@ -import { go } from '@api3/promise-utils'; import { router } from '@inertiajs/react'; import axe from 'axe-core'; +import { go } from './errgo'; /** * Development-only accessibility auditing with axe-core. @@ -18,13 +18,13 @@ import axe from 'axe-core'; */ export function startA11yAudit() { const scan = async () => { - const result = await go(() => axe.run(document)); - if (!result.success) { - console.error('[a11y] axe scan failed:', result.error); + const [error, result] = await go(() => axe.run(document)); + if (error) { + console.error('[a11y] axe scan failed:', error); return; } - const { violations } = result.data; + const { violations } = result; if (violations.length === 0) return; console.warn(`[a11y] ${violations.length} issue(s) on ${window.location.pathname}`); diff --git a/assets/js/app.tsx b/assets/js/app.tsx index 6495072..d450751 100644 --- a/assets/js/app.tsx +++ b/assets/js/app.tsx @@ -2,7 +2,6 @@ // other module can throw. No-op unless a DSN was stamped into . import './sentry'; import './i18n'; -import { go } from '@api3/promise-utils'; import { createInertiaApp, router } from '@inertiajs/react'; import { createElement, StrictMode, useEffect } from 'react'; import { createRoot, hydrateRoot } from 'react-dom/client'; @@ -12,6 +11,7 @@ import ErrorBoundary from './components/ErrorBoundary'; import { syncLocale } from './components/LocaleSync'; import Toaster from './components/Toaster'; import { toast } from './components/toast'; +import { go } from './errgo'; import { startThemeWatcher } from './theme'; interface Flash { @@ -59,12 +59,12 @@ createInertiaApp({ throw error; } - const result = await go(loader); - if (!result.success) { - console.error(`Failed to load page "${name}":`, result.error); - throw result.error; + const [error, page] = await go(loader); + if (error) { + console.error(`Failed to load page "${name}":`, error); + throw error; } - return result.data.default; + return page.default; }, setup({ App, el, props }) { syncLocale(props.initialPage.props); @@ -73,8 +73,12 @@ createInertiaApp({ // Dev-only accessibility auditing. The whole branch — and axe-core — // is tree-shaken from the production bundle via the NODE_ENV define. if (process.env.NODE_ENV !== 'production') { - void go(() => import('./a11y-audit')).then((result) => { - if (result.success) result.data.startA11yAudit(); + void go(() => import('./a11y-audit')).then(([error, audit]) => { + if (error) { + console.error('Failed to load the accessibility audit:', error); + return; + } + audit.startA11yAudit(); }); } diff --git a/assets/js/components/LocaleSync.tsx b/assets/js/components/LocaleSync.tsx index 9783961..c47dc49 100644 --- a/assets/js/components/LocaleSync.tsx +++ b/assets/js/components/LocaleSync.tsx @@ -1,4 +1,5 @@ import { router } from '@inertiajs/react'; +import { go } from '../errgo'; import i18n from '../i18n'; /** @@ -22,7 +23,9 @@ function applyLocale(props: Record) { if (!locale) return; if (locale !== i18n.language) { - i18n.changeLanguage(locale); + void go(() => i18n.changeLanguage(locale)).then(([error]) => { + if (error) console.error(`Failed to change language to "${locale}":`, error); + }); } if (locale !== document.documentElement.lang) { diff --git a/assets/js/errgo.test.mts b/assets/js/errgo.test.mts new file mode 100644 index 0000000..f7b32f4 --- /dev/null +++ b/assets/js/errgo.test.mts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { go, goSync } from './errgo.ts'; + +test('go returns an error-first tuple for resolved and rejected operations', async () => { + const success = await go(() => Promise.resolve('ready')); + const failure = await go(() => Promise.reject(new Error('offline'))); + + assert.deepEqual(success, [undefined, 'ready']); + assert.equal(failure[0]?.message, 'offline'); + assert.equal(failure[1], undefined); +}); + +test('goSync normalizes thrown non-Error values', () => { + const [error, value] = goSync(() => { + throw 'broken'; + }); + + assert.equal(error?.message, 'broken'); + assert.equal(error?.cause, 'broken'); + assert.equal(value, undefined); +}); diff --git a/assets/js/errgo.ts b/assets/js/errgo.ts new file mode 100644 index 0000000..89511bd --- /dev/null +++ b/assets/js/errgo.ts @@ -0,0 +1,329 @@ +/** A successful tuple with no error and a value. */ +export type Success = readonly [error: undefined, value: T]; + +/** A failed tuple with an error and no value. */ +export type Failure = readonly [error: E, value: undefined]; + +/** A discriminated error-first tuple result. */ +export type Result = Success | Failure; + +/** Context supplied to each execution attempt. Attempts are one-based. */ +export type AttemptContext = Readonly<{ + attempt: number; + signal: AbortSignal; +}>; + +/** Context supplied when deciding whether and when to retry. */ +export type RetryContext = Readonly<{ + attempt: number; + attempts: number; + error: Error; +}>; + +/** Optional execution policy for {@link go}. */ +export type GoOptions = Readonly<{ + /** Total safe-integer executions, including the first. Defaults to one. */ + attempts?: number; + /** Fixed or computed delay from 0 to 2,147,483,647ms. Defaults to zero. */ + delayMs?: number | ((context: RetryContext) => number); + /** Explicitly approves another attempt. Failures are not retried by default. */ + shouldRetry?: (context: RetryContext) => boolean | PromiseLike; + /** Caller-controlled cancellation signal. */ + signal?: AbortSignal; + /** Per-attempt timeout from 1 to 2,147,483,647ms. No timeout by default. */ + timeoutMs?: number; +}>; + +/** Creates a successful result. */ +export const ok = (value: T): Success => [undefined, value]; + +/** Creates a failed result. */ +export const err = (error: E): Failure => [error, undefined]; + +/** Narrows a result to its success branch, for expression positions such as {@link Array.filter}. */ +export const isOk = (result: Result): result is Success => result[0] === undefined; + +/** Narrows a result to its failure branch, for expression positions such as {@link Array.filter}. */ +export const isErr = (result: Result): result is Failure => result[0] !== undefined; + +/** Error returned when an individual execution attempt exceeds its timeout. */ +export class TimeoutError extends Error { + override readonly name = 'TimeoutError'; + readonly attempt: number; + readonly timeoutMs: number; + + constructor(timeoutMs: number, attempt: number) { + super(`Attempt ${attempt} timed out after ${timeoutMs}ms`); + this.attempt = attempt; + this.timeoutMs = timeoutMs; + } +} + +const normalizeError = (cause: unknown): Error => { + if (cause instanceof Error) { + return cause; + } + + let message = 'Non-Error value thrown'; + + try { + message = String(cause); + } catch { + // Keep the stable fallback while preserving the original value as the cause. + } + + return new Error(message, { cause }); +}; + +/** Converts a synchronous throw into a tuple result. */ +export const goSync = (operation: () => T): Result => { + try { + return ok(operation()); + } catch (cause) { + return err(normalizeError(cause)); + } +}; + +/** Converts synchronous throws and Promise rejections into a tuple result. */ +const capture = async (operation: () => T | PromiseLike): Promise>> => { + try { + return ok(await operation()); + } catch (cause) { + return err(normalizeError(cause)); + } +}; + +const MAX_TIMER_MS = 2_147_483_647; + +const validateTimerMs = (name: string, value: number, minimum: number): void => { + if (!Number.isFinite(value) || value < minimum || value > MAX_TIMER_MS) { + throw new RangeError(`${name} must be a finite number between ${minimum} and ${MAX_TIMER_MS}`); + } +}; + +const isAbortSignal = (value: unknown): value is AbortSignal => { + const candidate = value as AbortSignal | null; + + return ( + typeof candidate === 'object' && + candidate !== null && + typeof candidate.aborted === 'boolean' && + typeof candidate.addEventListener === 'function' && + typeof candidate.removeEventListener === 'function' + ); +}; + +/** Validates fixed options before any work starts and returns the attempt count. */ +const validateOptions = (options: GoOptions): number => { + if (typeof options !== 'object' || options === null || Array.isArray(options)) { + throw new TypeError('options must be an object'); + } + + const { attempts = 1, delayMs, shouldRetry, signal, timeoutMs } = options; + + if (!Number.isSafeInteger(attempts) || attempts < 1) { + throw new RangeError('attempts must be a positive safe integer'); + } + if (timeoutMs !== undefined) { + validateTimerMs('timeoutMs', timeoutMs, 1); + } + if (typeof delayMs === 'number') { + validateTimerMs('delayMs', delayMs, 0); + } else if (delayMs !== undefined && typeof delayMs !== 'function') { + throw new TypeError('delayMs must be a number or function'); + } + if (shouldRetry !== undefined && typeof shouldRetry !== 'function') { + throw new TypeError('shouldRetry must be a function'); + } + if (signal !== undefined && !isAbortSignal(signal)) { + throw new TypeError('signal must be an AbortSignal'); + } + + return attempts; +}; + +const abortReason = (signal: AbortSignal): Error => normalizeError(signal.reason); + +/** + * Invokes `listener` at most once when the signal aborts, including when it is + * already aborted or aborts while the listener is being registered. Returns a + * best-effort unsubscribe; a signal that rejects registration rethrows. + */ +const onAbort = (signal: AbortSignal, listener: () => void): (() => void) => { + let notified = false; + const fire = (): void => { + if (notified) { + return; + } + notified = true; + listener(); + }; + const detach = (): void => { + try { + signal.removeEventListener('abort', fire); + } catch { + // Cleanup is best-effort and must not replace the original result. + } + }; + + if (signal.aborted) { + fire(); + return detach; + } + + try { + signal.addEventListener('abort', fire, { once: true }); + } catch (cause) { + detach(); + throw cause; + } + + if (signal.aborted) { + fire(); + } + + return detach; +}; + +/** Settles with `value`, or rejects with the abort reason if the signal wins. */ +const abortable = async (value: T | PromiseLike, signal: AbortSignal | undefined): Promise => { + const settled = Promise.resolve(value); + void settled.catch(() => undefined); + + if (signal === undefined) { + return await settled; + } + if (signal.aborted) { + throw abortReason(signal); + } + + let detach: (() => void) | undefined; + + try { + return await new Promise((resolve, reject) => { + detach = onAbort(signal, () => reject(abortReason(signal))); + settled.then(resolve, reject); + }); + } finally { + detach?.(); + } +}; + +/** Waits for the delay, rejecting early with the abort reason if the signal wins. */ +const wait = async (delayMs: number, signal: AbortSignal | undefined): Promise => { + if (signal?.aborted) { + throw abortReason(signal); + } + if (delayMs === 0) { + return; + } + + let timeout: ReturnType | undefined; + + try { + await abortable( + new Promise((resolve) => { + timeout = setTimeout(resolve, delayMs); + }), + signal + ); + } finally { + clearTimeout(timeout); + } +}; + +/** Runs one attempt against a signal that this package aborts on timeout or cancellation. */ +const executeAttempt = async ( + operation: (context: AttemptContext) => T | PromiseLike, + attempt: number, + timeoutMs: number | undefined, + externalSignal: AbortSignal | undefined +): Promise> => { + const controller = new AbortController(); + let detach: (() => void) | undefined; + let timeout: ReturnType | undefined; + + try { + if (externalSignal !== undefined) { + detach = onAbort(externalSignal, () => controller.abort(abortReason(externalSignal))); + } + if (timeoutMs !== undefined) { + timeout = setTimeout(() => controller.abort(new TimeoutError(timeoutMs, attempt)), timeoutMs); + } + + return await abortable( + Promise.resolve().then(() => { + if (controller.signal.aborted) { + throw abortReason(controller.signal); + } + return operation({ attempt, signal: controller.signal }); + }), + controller.signal + ); + } finally { + clearTimeout(timeout); + detach?.(); + } +}; + +/** + * Converts synchronous throws and Promise rejections into a tuple result. + * + * Options add a per-attempt timeout, caller cancellation, and explicitly + * approved sequential retries. + */ +export const go = async ( + operation: (context: AttemptContext) => T | PromiseLike, + options: GoOptions = {} +): Promise>> => { + let attempts: number; + + try { + attempts = validateOptions(options); + } catch (cause) { + return err(normalizeError(cause)); + } + + const { delayMs, shouldRetry, signal, timeoutMs } = options; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + if (signal?.aborted) { + return err(abortReason(signal)); + } + + const [error, value] = await capture(() => executeAttempt(operation, attempt, timeoutMs, signal)); + + if (error === undefined) { + return ok(value); + } + if (signal?.aborted) { + return err(abortReason(signal)); + } + if (attempt === attempts || shouldRetry === undefined) { + return err(error); + } + + const context: RetryContext = { attempt, attempts, error }; + const [policyError, approved] = await capture(async () => { + if (!(await abortable(shouldRetry(context), signal))) { + return false; + } + + const delay = typeof delayMs === 'function' ? delayMs(context) : (delayMs ?? 0); + + validateTimerMs('delayMs', delay, 0); + await wait(delay, signal); + return true; + }); + + // Caller cancellation is terminal, so it outranks any policy failure. + if (policyError !== undefined) { + return err(signal?.aborted ? abortReason(signal) : policyError); + } + if (!approved) { + return err(error); + } + } + + throw new Error('Unreachable retry state'); +}; diff --git a/assets/js/ssr.tsx b/assets/js/ssr.tsx index ed5891f..4dbb73c 100644 --- a/assets/js/ssr.tsx +++ b/assets/js/ssr.tsx @@ -1,5 +1,4 @@ import './i18n'; -import { go } from '@api3/promise-utils'; import { createInertiaApp } from '@inertiajs/react'; import * as Sentry from '@sentry/node'; import { createElement } from 'react'; @@ -7,6 +6,7 @@ import ReactDOMServer from 'react-dom/server'; import pages, { ssrClientOnly } from './_ssr_pages.ts'; import { AppProviders } from './app-providers'; import Toaster from './components/Toaster'; +import { go } from './errgo'; import i18n from './i18n'; // Sentry for the SSR Node workers (errors only — no tracing). The DSN is @@ -29,7 +29,11 @@ export async function render(page: any) { // Sync locale before rendering so SSR output matches const locale = page.props?.locale as string | undefined; if (locale && locale !== i18n.language) { - void go(() => i18n.changeLanguage(locale)); + const [localeError] = await go(() => i18n.changeLanguage(locale)); + if (localeError) { + if (sentryDsn) Sentry.captureException(localeError); + throw localeError; + } } const renderApp = () => { @@ -69,15 +73,15 @@ export async function render(page: any) { }); }; - const result = await go(renderApp); + const [error, result] = await go(renderApp); // Report the failure, then re-raise so Inertia's own SSR-failure handling // runs unchanged (graceful client-side fallback in prod, raise in dev per // `raise_on_ssr_failure`). - if (!result.success) { - if (sentryDsn) Sentry.captureException(result.error); - throw result.error; + if (error) { + if (sentryDsn) Sentry.captureException(error); + throw error; } - return result.data; + return result; } diff --git a/assets/package-lock.json b/assets/package-lock.json index d3c8fc3..3219134 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -5,7 +5,6 @@ "packages": { "": { "dependencies": { - "@api3/promise-utils": "^0.4.0", "@inertiajs/react": "^3.6.1", "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-dropdown-menu": "^2.1.24", @@ -37,12 +36,6 @@ "npm": ">=11.10.0" } }, - "node_modules/@api3/promise-utils": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@api3/promise-utils/-/promise-utils-0.4.0.tgz", - "integrity": "sha512-+8fcNjjQeQAuuSXFwu8PMZcYzjwjDiGYcMUfAQ0lpREb1zHonwWZ2N0B9h/g1cvWzg9YhElbeb/SyhCrNm+b/A==", - "license": "MIT" - }, "node_modules/@apm-js-collab/code-transformer": { "version": "0.18.1", "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.18.1.tgz", diff --git a/assets/package.json b/assets/package.json index 6b52a41..89973fa 100644 --- a/assets/package.json +++ b/assets/package.json @@ -10,10 +10,10 @@ "fmt": "npm run lint:fix && biome format --write", "lint": "biome check build/ css/ e2e/ js/", "lint:fix": "biome check --write build/ css/ e2e/ js/", + "test:unit": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test js/errgo.test.mts", "typecheck": "node build/generate-ssr-pages.js && tsc -p tsconfig.json && tsc -p tsconfig.e2e.json" }, "dependencies": { - "@api3/promise-utils": "^0.4.0", "@inertiajs/react": "^3.6.1", "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-dropdown-menu": "^2.1.24", diff --git a/assets/tsconfig.json b/assets/tsconfig.json index 1f572ea..ffb3b51 100644 --- a/assets/tsconfig.json +++ b/assets/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, - "lib": ["ES2020", "ES2021.Intl", "DOM", "DOM.Iterable"], + "lib": ["ES2020", "ES2021.Intl", "ES2022.Error", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", diff --git a/lib/mix/tasks/lint.ex b/lib/mix/tasks/lint.ex index 2e16568..362c77d 100644 --- a/lib/mix/tasks/lint.ex +++ b/lib/mix/tasks/lint.ex @@ -8,6 +8,7 @@ defmodule Mix.Tasks.Lint do This runs: - `mix credo` for Elixir static analysis - `npm run lint` (biome) for TypeScript/CSS linting + - `npm run test:unit` for frontend utility contract tests - `npm run typecheck` (tsc) for TypeScript type checking, covering both the app (`tsconfig.json`) and the Playwright suite (`tsconfig.e2e.json`) @@ -24,6 +25,7 @@ defmodule Mix.Tasks.Lint do Mix.Task.run("credo") npm("run lint") + npm("run test:unit") npm("run typecheck") end From c487794ca4be2c49ebc0d2afe9c50dc3f4f0ebc2 Mon Sep 17 00:00:00 2001 From: Saviro Orin Date: Mon, 10 Aug 2026 23:43:07 +0200 Subject: [PATCH 2/3] fix: route startup promises through errgo --- assets/js/app.tsx | 156 +++++++++++++++++++++------------------- assets/js/i18n/index.ts | 27 ++++--- 2 files changed, 98 insertions(+), 85 deletions(-) diff --git a/assets/js/app.tsx b/assets/js/app.tsx index d450751..0fdad91 100644 --- a/assets/js/app.tsx +++ b/assets/js/app.tsx @@ -50,90 +50,96 @@ function InitialFlash({ flash }: { flash?: Flash }) { return null; } -createInertiaApp({ - resolve: async (name) => { - const loader = pages[name]; - if (!loader) { - const error = new Error(`Page not found: ${name}`); - console.error(error); - throw error; - } +const startApp = () => { + return createInertiaApp({ + resolve: async (name) => { + const loader = pages[name]; + if (!loader) { + const error = new Error(`Page not found: ${name}`); + console.error(error); + throw error; + } - const [error, page] = await go(loader); - if (error) { - console.error(`Failed to load page "${name}":`, error); - throw error; - } - return page.default; - }, - setup({ App, el, props }) { - syncLocale(props.initialPage.props); - startThemeWatcher(); + const [error, page] = await go(loader); + if (error) { + console.error(`Failed to load page "${name}":`, error); + throw error; + } + return page.default; + }, + setup({ App, el, props }) { + syncLocale(props.initialPage.props); + startThemeWatcher(); - // Dev-only accessibility auditing. The whole branch — and axe-core — - // is tree-shaken from the production bundle via the NODE_ENV define. - if (process.env.NODE_ENV !== 'production') { - void go(() => import('./a11y-audit')).then(([error, audit]) => { - if (error) { - console.error('Failed to load the accessibility audit:', error); - return; - } - audit.startA11yAudit(); - }); - } + // Dev-only accessibility auditing. The whole branch — and axe-core — + // is tree-shaken from the production bundle via the NODE_ENV define. + if (process.env.NODE_ENV !== 'production') { + void go(() => import('./a11y-audit')).then(([error, audit]) => { + if (error) { + console.error('Failed to load the accessibility audit:', error); + return; + } + audit.startA11yAudit(); + }); + } - // Client-initiated visits: `success` fires for every successful visit, - // including same-URL POST → redirect flows (where `navigate` is skipped - // because Inertia treats same-URL responses as history replace). - router.on('success', (event) => { - applyFlash(event.detail.page.props.flash as Flash | undefined); - }); + // Client-initiated visits: `success` fires for every successful visit, + // including same-URL POST → redirect flows (where `navigate` is skipped + // because Inertia treats same-URL responses as history replace). + router.on('success', (event) => { + applyFlash(event.detail.page.props.flash as Flash | undefined); + }); - const tree = ( - - {/* AppProviders lives INSIDE Inertia's because the providers + const tree = ( + + {/* AppProviders lives INSIDE Inertia's because the providers it wraps consume page context (usePage). Using App's children render prop keeps the provider tree mounted across page navigations — only the inner swaps — so anything long-lived (sockets, caches, listeners) survives route changes. */} - - {({ Component, props: pageProps, key }) => ( - - {/* Keyed on the page key so navigation remounts the boundary + + {({ Component, props: pageProps, key }) => ( + + {/* Keyed on the page key so navigation remounts the boundary and clears any caught error — long-lived providers above stay mounted. */} - {createElement(Component, pageProps)} - - )} - - - - - ); + {createElement(Component, pageProps)} + + )} + + + + + ); + + // Match the mount to how the page was actually produced. + // + // For a `pages/ssr/` page the server sent real markup, which the browser + // has already parsed and painted; `hydrateRoot` adopts it, where + // `createRoot` clears the container and builds the whole tree again. It + // also surfaces divergence between the two renders, which the silent + // rebuild never did. + // + // A `pages/client/` page is deliberately absent from the SSR bundle and + // renders as a server-side no-op, so there is nothing to adopt. Hydrating + // one fails the match on every load and pushes React through its recovery + // path to reach the same result `createRoot` reaches directly. + // + // Both branches render the identical tree — only the mount differs. This + // is also why `ssr.tsx` renders ``: anything at the root on one + // side but not the other is a mismatch. + if (serverRenderedPages.has(props.initialPage.component)) { + hydrateRoot(el, tree); + } else { + createRoot(el).render(tree); + } + }, + http: { + xsrfHeaderName: 'x-csrf-token', + }, + }); +}; - // Match the mount to how the page was actually produced. - // - // For a `pages/ssr/` page the server sent real markup, which the browser - // has already parsed and painted; `hydrateRoot` adopts it, where - // `createRoot` clears the container and builds the whole tree again. It - // also surfaces divergence between the two renders, which the silent - // rebuild never did. - // - // A `pages/client/` page is deliberately absent from the SSR bundle and - // renders as a server-side no-op, so there is nothing to adopt. Hydrating - // one fails the match on every load and pushes React through its recovery - // path to reach the same result `createRoot` reaches directly. - // - // Both branches render the identical tree — only the mount differs. This - // is also why `ssr.tsx` renders ``: anything at the root on one - // side but not the other is a mismatch. - if (serverRenderedPages.has(props.initialPage.component)) { - hydrateRoot(el, tree); - } else { - createRoot(el).render(tree); - } - }, - http: { - xsrfHeaderName: 'x-csrf-token', - }, +void go(startApp).then(([error]) => { + if (error) console.error('Failed to start the application:', error); }); diff --git a/assets/js/i18n/index.ts b/assets/js/i18n/index.ts index bf0fe37..da91887 100644 --- a/assets/js/i18n/index.ts +++ b/assets/js/i18n/index.ts @@ -1,18 +1,25 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; +import { go } from '../errgo'; import en from './locales/en'; import es from './locales/es'; -i18n.use(initReactI18next).init({ - resources: { - en: { translation: en }, - es: { translation: es }, - }, - lng: 'en', - fallbackLng: 'en', - interpolation: { - escapeValue: false, - }, +const initializeI18n = () => { + return i18n.use(initReactI18next).init({ + resources: { + en: { translation: en }, + es: { translation: es }, + }, + lng: 'en', + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, + }); +}; + +void go(initializeI18n).then(([error]) => { + if (error) console.error('Failed to initialize translations:', error); }); export default i18n; From 4a6e29b4deb3bda6b71cc751a42da2f4cee4f792 Mon Sep 17 00:00:00 2001 From: Saviro Orin Date: Mon, 10 Aug 2026 23:47:28 +0200 Subject: [PATCH 3/3] test: use conventional TypeScript filename --- assets/js/{errgo.test.mts => errgo.test.ts} | 0 assets/package.json | 2 +- assets/tsconfig.json | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename assets/js/{errgo.test.mts => errgo.test.ts} (100%) diff --git a/assets/js/errgo.test.mts b/assets/js/errgo.test.ts similarity index 100% rename from assets/js/errgo.test.mts rename to assets/js/errgo.test.ts diff --git a/assets/package.json b/assets/package.json index 89973fa..9051bb1 100644 --- a/assets/package.json +++ b/assets/package.json @@ -10,7 +10,7 @@ "fmt": "npm run lint:fix && biome format --write", "lint": "biome check build/ css/ e2e/ js/", "lint:fix": "biome check --write build/ css/ e2e/ js/", - "test:unit": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test js/errgo.test.mts", + "test:unit": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test js/errgo.test.ts", "typecheck": "node build/generate-ssr-pages.js && tsc -p tsconfig.json && tsc -p tsconfig.e2e.json" }, "dependencies": { diff --git a/assets/tsconfig.json b/assets/tsconfig.json index ffb3b51..580ad70 100644 --- a/assets/tsconfig.json +++ b/assets/tsconfig.json @@ -20,5 +20,5 @@ "esModuleInterop": true }, "include": ["js/**/*.ts", "js/**/*.tsx", "js/**/*.js", "js/**/*.jsx"], - "exclude": ["node_modules", "e2e", "playwright.config.ts"] + "exclude": ["node_modules", "e2e", "js/**/*.test.ts", "playwright.config.ts"] }