fix(dev): rewrite HMR WebSocket URL in Tauri mobile dev, closes #4150 (#4160)

In Tauri mobile dev the page origin doesn't match the dev server, so
Next.js's `getSocketUrl` builds an unreachable HMR URL (`wss://localhost`
on iOS, `ws://tauri.localhost` on Android), the HMR client never connects,
and the page stays blank.

Inject a tiny script in `<head>` (dev + Tauri only) that subclasses
`window.WebSocket` and rewrites the broken URL to the actual dev server.
`TAURI_DEV_HOST` is forwarded from the build env so `pnpm tauri {ios,android}
dev --host <ip>` also routes HMR through the LAN address.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-15 01:06:22 +08:00
committed by GitHub
parent f6b6281160
commit 244b3fd994
+55
View File
@@ -72,12 +72,67 @@ export const viewport: Viewport = {
// unrecognized key on every page load, so we keep it out of SSR.
};
// In Tauri mobile dev the page origin doesn't match the dev server, so
// Next.js's `getSocketUrl` builds an unreachable HMR URL (see
// `next/dist/client/dev/hot-reloader/get-socket-url.js`):
// - iOS sim: page at `tauri://localhost` → `wss://localhost/_next/...`
// (no port, non-http scheme falls through to `wss:`)
// - Android emul.: page at `http://tauri.localhost` → `ws://tauri.localhost/_next/...`
// (`tauri.localhost` is intercepted by Tauri's asset handler, but
// WebSocket frames bypass the interceptor and the dev server is on the
// host machine, reachable from the emulator as `10.0.2.2`)
// Rewrite the WebSocket constructor before the HMR client runs.
// When `--host <ip>` is passed, tauri-cli exports `TAURI_DEV_HOST=<ip>`
// before invoking `beforeDevCommand`, so we forward that as `devHost` and
// use it for the rewrite (the dev server must also bind to the same address
// — typically `next dev -H 0.0.0.0`).
function patchTauriHmrWebSocket(devHost?: string) {
const isIosTauriProxy = location.protocol === 'tauri:' && location.hostname === 'localhost';
const isAndroidTauriProxy =
location.protocol === 'http:' && location.hostname === 'tauri.localhost';
if (!isIosTauriProxy && !isAndroidTauriProxy) return;
// Priority: explicit --host > platform default loopback alias.
// iOS Simulator can reach the host's localhost directly.
// Android emulator reaches the host machine via 10.0.2.2.
const hmrHost = devHost
? `${devHost}:3000`
: isIosTauriProxy
? 'localhost:3000'
: '10.0.2.2:3000';
const brokenHostPattern = /^wss?:\/\/(localhost|tauri\.localhost)(?=\/_next\/)/;
const OriginalWebSocket = window.WebSocket;
class PatchedWebSocket extends OriginalWebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
const urlStr = url instanceof URL ? url.href : url;
const rewritten =
typeof urlStr === 'string' && brokenHostPattern.test(urlStr)
? urlStr.replace(brokenHostPattern, `ws://${hmrHost}`)
: url;
super(rewritten, protocols);
}
}
window.WebSocket = PatchedWebSocket;
}
const shouldInjectDevHmrPatch =
process.env['NODE_ENV'] === 'development' && process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'tauri';
const devHmrPatchScript = `(${patchTauriHmrWebSocket.toString()})(${JSON.stringify(
process.env['TAURI_DEV_HOST'],
)});`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html
lang='en'
className={process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'tauri' ? 'edge-to-edge' : ''}
>
{shouldInjectDevHmrPatch ? (
<head>
<script dangerouslySetInnerHTML={{ __html: devHmrPatchScript }} />
</head>
) : null}
<body>
<ViewTransitions>
<EnvProvider>