diff --git a/proof-of-work/app/auth/login/LoginForm.tsx b/proof-of-work/app/auth/login/LoginForm.tsx index 96ff8b7..d17ccd2 100644 --- a/proof-of-work/app/auth/login/LoginForm.tsx +++ b/proof-of-work/app/auth/login/LoginForm.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { loginAction } from './actions'; +import { retryOnTransportError } from '@/lib/retryAction'; export default function LoginForm() { const router = useRouter(); @@ -17,7 +18,9 @@ export default function LoginForm() { setLoading(true); try { - const result = await loginAction(email, password); + const result = await retryOnTransportError(() => + loginAction(email, password) + ); if (result.error) { setError(result.error); diff --git a/proof-of-work/app/auth/signup/SignupForm.tsx b/proof-of-work/app/auth/signup/SignupForm.tsx index 048f58f..12bd9ab 100644 --- a/proof-of-work/app/auth/signup/SignupForm.tsx +++ b/proof-of-work/app/auth/signup/SignupForm.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { signupAction } from './actions'; +import { retryOnTransportError } from '@/lib/retryAction'; export default function SignupForm() { const router = useRouter(); @@ -19,7 +20,9 @@ export default function SignupForm() { setLoading(true); try { - const result = await signupAction(email, password, passwordConfirm, name); + const result = await retryOnTransportError(() => + signupAction(email, password, passwordConfirm, name) + ); if (result.error) { setError(result.error); setLoading(false); diff --git a/proof-of-work/lib/retryAction.ts b/proof-of-work/lib/retryAction.ts new file mode 100644 index 0000000..4ee6c82 --- /dev/null +++ b/proof-of-work/lib/retryAction.ts @@ -0,0 +1,25 @@ +/** + * Run a server action, retrying it ONCE if the call rejects at the + * transport layer. + * + * iOS Safari (and Safari generally) frequently drops the first POST sent + * on a keep-alive socket that the server closed while the connection sat + * idle — e.g. while the user typed their credentials. The request fails + * instantly with `NSURLErrorNetworkConnectionLost` ("The network + * connection was lost", -1005); a retry lands on a fresh connection and + * succeeds. This is why a first login/signup tap shows "An unexpected + * error occurred" and the second tap works. + * + * Only a *thrown* rejection is retried. A server action that returns a + * value — including an application-level `{ error }` ("Invalid email or + * password", a rate-limit message) — is a real result and passes + * straight through untouched. A lost-on-a-stale-socket POST never + * reached the server, so retrying it once is safe. + */ +export async function retryOnTransportError(fn: () => Promise): Promise { + try { + return await fn(); + } catch { + return await fn(); + } +} diff --git a/proof-of-work/tests/retryAction.test.ts b/proof-of-work/tests/retryAction.test.ts new file mode 100644 index 0000000..977ccaf --- /dev/null +++ b/proof-of-work/tests/retryAction.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi } from 'vitest'; +import { retryOnTransportError } from '@/lib/retryAction'; + +describe('retryOnTransportError', () => { + it('returns the result without retrying when the call succeeds', async () => { + const fn = vi.fn().mockResolvedValue({ success: true }); + const result = await retryOnTransportError(fn); + expect(result).toEqual({ success: true }); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('passes through an application-level { error } without retrying', async () => { + // A returned error (bad password, rate limit) is a real result, not a + // transport failure — it must not trigger a retry. + const fn = vi.fn().mockResolvedValue({ error: 'Invalid email or password' }); + const result = await retryOnTransportError(fn); + expect(result).toEqual({ error: 'Invalid email or password' }); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('retries once on a thrown transport error and returns the second result', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new TypeError('The network connection was lost')) + .mockResolvedValueOnce({ success: true }); + const result = await retryOnTransportError(fn); + expect(result).toEqual({ success: true }); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('rejects after a single retry when both attempts throw', async () => { + const fn = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); + await expect(retryOnTransportError(fn)).rejects.toThrow('Failed to fetch'); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/start9/0.4/startos/versions/index.ts b/start9/0.4/startos/versions/index.ts index e7c5092..7251e68 100644 --- a/start9/0.4/startos/versions/index.ts +++ b/start9/0.4/startos/versions/index.ts @@ -16,6 +16,7 @@ import { v_1_1_0_7 } from './v1.1.0.7' import { v_1_1_0_8 } from './v1.1.0.8' import { v_1_1_0_9 } from './v1.1.0.9' import { v_1_2_0_1 } from './v1.2.0.1' +import { v_1_2_0_2 } from './v1.2.0.2' /** * Version graph for the `proof-of-work` package. @@ -61,9 +62,13 @@ import { v_1_2_0_1 } from './v1.2.0.1' * v1.2.0:1 — Next.js 14 -> 15 / React 18 -> 19 upgrade. Closes the Next * framework RSC + middleware-bypass CVEs; async-params migration * across all [id] routes + server pages. No schema/data change. + * v1.2.0:2 — Login/signup first-tap retry: iOS Safari drops the first + * server-action POST on a stale keep-alive socket + * (NSURLErrorNetworkConnectionLost); retry once on transport + * failure. Client-only, no schema/data change. */ export const versionGraph = VersionGraph.of({ - current: v_1_2_0_1, + current: v_1_2_0_2, other: [ v_1_0_0_1, v_1_0_0_2, @@ -81,5 +86,6 @@ export const versionGraph = VersionGraph.of({ v_1_1_0_7, v_1_1_0_8, v_1_1_0_9, + v_1_2_0_1, ], }) diff --git a/start9/0.4/startos/versions/v1.2.0.2.ts b/start9/0.4/startos/versions/v1.2.0.2.ts new file mode 100644 index 0000000..1b995de --- /dev/null +++ b/start9/0.4/startos/versions/v1.2.0.2.ts @@ -0,0 +1,32 @@ +import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk' + +/** + * v1.2.0:2 — Login/signup first-tap retry on Safari (2026-06-15). + * + * Fixes a long-standing "first Sign In fails with 'An unexpected error + * occurred', second works" report from iOS Safari. The error string is + * the client-side catch in LoginForm/SignupForm — i.e. the server-action + * POST itself rejected at the transport layer, not any login-logic path + * (those return a clean { error }). Cause: iOS Safari reuses a keep-alive + * socket the server closed while the form sat idle during typing, so the + * first POST dies instantly with NSURLErrorNetworkConnectionLost ("The + * network connection was lost"); a retry lands on a fresh connection. + * + * Fix is client-only: a shared retryOnTransportError() helper retries the + * action ONCE when the call throws (a returned { error } is a real result + * and passes straight through). A stale-socket POST never reached the + * server, so the retry is safe. + * + * App-code only — no schema, no API contract change, no data migration. + */ +export const v_1_2_0_2 = VersionInfo.of({ + version: '1.2.0:2', + releaseNotes: { + en_US: + 'Fixes the occasional "An unexpected error occurred" on the first Sign In / Create account tap (most common in Safari on iPhone/iPad) — the form now retries automatically, so logging in works on the first try. No data changes.', + }, + migrations: { + up: async () => {}, + down: IMPOSSIBLE, + }, +})