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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => ( <Foo /> )`) 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 (`<Link href>`, `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
Expand Down
10 changes: 5 additions & 5 deletions assets/js/a11y-audit.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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}`);
Expand Down
154 changes: 82 additions & 72 deletions assets/js/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// other module can throw. No-op unless a DSN was stamped into <head>.
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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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 = (
<StrictMode>
{/* AppProviders lives INSIDE Inertia's <App> because the providers
const tree = (
<StrictMode>
{/* AppProviders lives INSIDE Inertia's <App> 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 <Component> swaps — so anything
long-lived (sockets, caches, listeners) survives route changes. */}
<App {...props}>
{({ Component, props: pageProps, key }) => (
<AppProviders>
{/* Keyed on the page key so navigation remounts the boundary
<App {...props}>
{({ Component, props: pageProps, key }) => (
<AppProviders>
{/* Keyed on the page key so navigation remounts the boundary
and clears any caught error — long-lived providers above
stay mounted. */}
<ErrorBoundary key={key ?? undefined}>{createElement(Component, pageProps)}</ErrorBoundary>
</AppProviders>
)}
</App>
<InitialFlash flash={props.initialPage.props.flash as Flash | undefined} />
<Toaster />
</StrictMode>
);
<ErrorBoundary key={key ?? undefined}>{createElement(Component, pageProps)}</ErrorBoundary>
</AppProviders>
)}
</App>
<InitialFlash flash={props.initialPage.props.flash as Flash | undefined} />
<Toaster />
</StrictMode>
);

// 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 `<Toaster />`: 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 `<Toaster />`: 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);
});
5 changes: 4 additions & 1 deletion assets/js/components/LocaleSync.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { router } from '@inertiajs/react';
import { go } from '../errgo';
import i18n from '../i18n';

/**
Expand All @@ -22,7 +23,9 @@ function applyLocale(props: Record<string, unknown>) {
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) {
Expand Down
22 changes: 22 additions & 0 deletions assets/js/errgo.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading