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..0fdad91 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 {
@@ -50,86 +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 result = await go(loader);
- if (!result.success) {
- console.error(`Failed to load page "${name}":`, result.error);
- throw result.error;
- }
- return result.data.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((result) => {
- if (result.success) result.data.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/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.ts b/assets/js/errgo.test.ts
new file mode 100644
index 0000000..f7b32f4
--- /dev/null
+++ b/assets/js/errgo.test.ts
@@ -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/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;
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..9051bb1 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.ts",
"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..580ad70 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",
@@ -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"]
}
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