v1.2.0:2 — retry login/signup server action once on transport failure
iOS Safari reuses a keep-alive socket the server closed while the login
form sat idle during typing, so the first Sign In / Create account POST
dies instantly with NSURLErrorNetworkConnectionLost ("The network
connection was lost"). That rejects the server-action call, hitting the
client-side catch in LoginForm/SignupForm and showing "An unexpected
error occurred"; the second tap lands on a fresh connection and works.
Add lib/retryAction.ts: retryOnTransportError() retries the action once
only when the call throws. A returned { error } (bad password, rate
limit) is a real result and passes straight through. A lost-on-a-stale-
socket POST never reached the server, so retrying it once is safe.
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { loginAction } from './actions';
|
import { loginAction } from './actions';
|
||||||
|
import { retryOnTransportError } from '@/lib/retryAction';
|
||||||
|
|
||||||
export default function LoginForm() {
|
export default function LoginForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -17,7 +18,9 @@ export default function LoginForm() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await loginAction(email, password);
|
const result = await retryOnTransportError(() =>
|
||||||
|
loginAction(email, password)
|
||||||
|
);
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { signupAction } from './actions';
|
import { signupAction } from './actions';
|
||||||
|
import { retryOnTransportError } from '@/lib/retryAction';
|
||||||
|
|
||||||
export default function SignupForm() {
|
export default function SignupForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -19,7 +20,9 @@ export default function SignupForm() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await signupAction(email, password, passwordConfirm, name);
|
const result = await retryOnTransportError(() =>
|
||||||
|
signupAction(email, password, passwordConfirm, name)
|
||||||
|
);
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch {
|
||||||
|
return await fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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_8 } from './v1.1.0.8'
|
||||||
import { v_1_1_0_9 } from './v1.1.0.9'
|
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_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.
|
* 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
|
* v1.2.0:1 — Next.js 14 -> 15 / React 18 -> 19 upgrade. Closes the Next
|
||||||
* framework RSC + middleware-bypass CVEs; async-params migration
|
* framework RSC + middleware-bypass CVEs; async-params migration
|
||||||
* across all [id] routes + server pages. No schema/data change.
|
* 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({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_1_2_0_1,
|
current: v_1_2_0_2,
|
||||||
other: [
|
other: [
|
||||||
v_1_0_0_1,
|
v_1_0_0_1,
|
||||||
v_1_0_0_2,
|
v_1_0_0_2,
|
||||||
@@ -81,5 +86,6 @@ export const versionGraph = VersionGraph.of({
|
|||||||
v_1_1_0_7,
|
v_1_1_0_7,
|
||||||
v_1_1_0_8,
|
v_1_1_0_8,
|
||||||
v_1_1_0_9,
|
v_1_1_0_9,
|
||||||
|
v_1_2_0_1,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user