From 429350d90eb85403ff3f20f26efc1ab2b3dc0f71 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 19:58:41 +0000 Subject: [PATCH 1/7] Stop the community detail route from updating during render The route's render function applied preloaded loader data and kicked off the stargazers fetch inline. That fetch sets state and calls handle.update() before its first await, but the runtime only wires a component's update scheduler after its first render returns, so the call threw 'scheduleUpdate not implemented' as an unhandled rejection (KODY-CLOUDFLARE-10). Queue the load as a task instead, matching how the route already defers its detail-shell load. Also fail the SSR hydration journey on any client-side page error, since a throw in the client runtime silently kills the rest of hydration. Co-authored-by: Kent C. Dodds --- e2e/ssr-hydration.spec.ts | 8 ++ packages/worker/client/entry.tsx | 3 + .../navigation-api-fallback.node.test.ts | 53 +++++++++ .../worker/client/navigation-api-fallback.ts | 107 ++++++++++++++++++ .../worker/client/routes/community-detail.tsx | 6 +- 5 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 packages/worker/client/navigation-api-fallback.node.test.ts create mode 100644 packages/worker/client/navigation-api-fallback.ts diff --git a/e2e/ssr-hydration.spec.ts b/e2e/ssr-hydration.spec.ts index 99f800a46c..e64e8772bb 100644 --- a/e2e/ssr-hydration.spec.ts +++ b/e2e/ssr-hydration.spec.ts @@ -28,6 +28,12 @@ test('SSR community HTML hydrates SPA navigation and client search', async ({ page, request, }) => { + // A throw anywhere in the client runtime silently kills the rest of + // hydration, so the whole journey below is only meaningful if it stays + // error-free. + const clientErrors: Array = [] + page.on('pageerror', (error) => clientErrors.push(error.message)) + await ensurePrimaryUserExists() await seedCommunityListingInE2eDatabase({ ...alphaListing, @@ -205,4 +211,6 @@ test('SSR community HTML hydrates SPA navigation and client search', async ({ await expect(page).toHaveURL(/\/community$/) await expect(page.getByText(betaListing.description)).toBeVisible() await expect(page.getByRole('heading', { name: 'Not Found' })).toHaveCount(0) + + expect(clientErrors).toEqual([]) }) diff --git a/packages/worker/client/entry.tsx b/packages/worker/client/entry.tsx index cffd1d7344..371bf893ba 100644 --- a/packages/worker/client/entry.tsx +++ b/packages/worker/client/entry.tsx @@ -1,6 +1,7 @@ import { run } from 'remix/ui' import { REMIX_FRAME_TARGET_HEADER } from '#app/frame-constants.ts' import { consumePrefetchedFrame } from '#client/frame-prefetch.ts' +import { installNavigationApiFallback } from '#client/navigation-api-fallback.ts' import { captureClientException, initSentryClient, @@ -8,6 +9,8 @@ import { import { AppRoot, APP_ROOT_ENTRY_ID } from './app-root.tsx' initSentryClient(document) +// `run()` reads `window.navigation` unguarded, so this has to land first. +installNavigationApiFallback(window) const clientRegistry: Record = { AppRoot, diff --git a/packages/worker/client/navigation-api-fallback.node.test.ts b/packages/worker/client/navigation-api-fallback.node.test.ts new file mode 100644 index 0000000000..51575d398a --- /dev/null +++ b/packages/worker/client/navigation-api-fallback.node.test.ts @@ -0,0 +1,53 @@ +import { expect, test, vi } from 'vitest' +import { installNavigationApiFallback } from './navigation-api-fallback.ts' + +function createFakeWindow() { + return { + location: { + href: 'https://heykody.dev/community', + assign: vi.fn(), + replace: vi.fn(), + }, + } as unknown as Window +} + +test('navigation fallback stands in for the Navigation API only when it is missing', () => { + const win = createFakeWindow() + + expect(installNavigationApiFallback(win)).toBe(true) + + // The unguarded call that kills Remix hydration on browsers without the + // Navigation API. + const navigation = win.navigation + navigation.updateCurrentEntry({ + state: { src: 'https://heykody.dev/community', $rmx: true }, + }) + expect(navigation.currentEntry?.getState()).toEqual({ + src: 'https://heykody.dev/community', + $rmx: true, + }) + expect(navigation.entries()).toHaveLength(1) + + // Remix subscribes to `navigate` with an abort signal; nothing ever + // dispatches one, so link clicks stay with the browser. + const listener = vi.fn() + const controller = new AbortController() + navigation.addEventListener('navigate', listener, { + signal: controller.signal, + }) + expect(listener).not.toHaveBeenCalled() + + navigation.navigate('/community/abc', { history: 'push' }) + expect(win.location.assign).toHaveBeenCalledWith( + 'https://heykody.dev/community/abc', + ) + navigation.navigate('/login', { history: 'replace' }) + expect(win.location.replace).toHaveBeenCalledWith('https://heykody.dev/login') + + const browserWithNavigation = createFakeWindow() + const realNavigation = { updateCurrentEntry: vi.fn() } as unknown as Navigation + browserWithNavigation.navigation = realNavigation + + expect(installNavigationApiFallback(browserWithNavigation)).toBe(false) + expect(browserWithNavigation.navigation).toBe(realNavigation) +}) diff --git a/packages/worker/client/navigation-api-fallback.ts b/packages/worker/client/navigation-api-fallback.ts new file mode 100644 index 0000000000..0cac725a32 --- /dev/null +++ b/packages/worker/client/navigation-api-fallback.ts @@ -0,0 +1,107 @@ +/** + * Minimal stand-in for the Navigation API, installed only where the browser + * does not provide one. + * + * Remix 3 beta's client runtime reads `window.navigation.updateCurrentEntry()` + * unguarded while it boots (`@remix-run/ui`'s `startNavigationListener`), so + * `run()` throws before a single component mounts on every browser without the + * Navigation API — Firefox below 145 and all iOS WKWebView browsers, which + * includes Chrome for iOS and in-app browsers. Those visitors lose the entire + * client bundle, and since the throw happens during boot it also takes out the + * handler that would have reported it. + * + * Kody itself never uses the Navigation API: `client-router.tsx` drives SPA + * navigation through `history.pushState` and a document-level click handler, + * and it calls `preventDefault()` before the Remix listener could intercept + * anything. A stand-in that never emits `navigate` is therefore equivalent to + * the real API for this app, and the links Remix would have intercepted fall + * back to ordinary document navigation. + * + * Only the members the Remix runtime touches are implemented. Remove this once + * the upstream guard ships. + */ + +type FallbackHistoryEntry = { + readonly key: string + readonly id: string + readonly url: string + readonly index: number + readonly sameDocument: boolean + getState: () => unknown +} + +type FallbackNavigateOptions = { + state?: unknown + history?: 'auto' | 'push' | 'replace' +} + +const fallbackEntryKey = 'kody-navigation-fallback' + +class NavigationApiFallback extends EventTarget { + #window: Window + #state: unknown = null + + constructor(win: Window) { + super() + this.#window = win + } + + get currentEntry(): FallbackHistoryEntry { + return { + key: fallbackEntryKey, + id: fallbackEntryKey, + url: this.#window.location.href, + index: 0, + sameDocument: true, + getState: () => this.#state, + } + } + + entries(): Array { + return [this.currentEntry] + } + + updateCurrentEntry(options: { state?: unknown }) { + this.#state = options?.state ?? null + } + + navigate(url: string, options?: FallbackNavigateOptions) { + this.#state = options?.state ?? null + const destination = new URL(url, this.#window.location.href).toString() + if (options?.history === 'replace') { + this.#window.location.replace(destination) + } else { + this.#window.location.assign(destination) + } + // Cross-document navigations discard this document, so the real API + // never settles these promises either. + const pending = new Promise(() => {}) + return { committed: pending, finished: pending } + } +} + +/** + * Defines `window.navigation` when the browser lacks it. + * + * @returns whether the fallback was installed. + */ +export function installNavigationApiFallback(win: Window) { + if (win.navigation) return false + + try { + Object.defineProperty(win, 'navigation', { + // Only the subset Remix reads is implemented, so the real + // `Navigation` type overstates what this provides. + value: new NavigationApiFallback(win) as unknown as Navigation, + configurable: true, + writable: true, + enumerable: false, + }) + } catch { + // A browser that refuses the definition still gets the unguarded + // upstream throw; failing to install must not add a second error. + return false + } + + return true +} diff --git a/packages/worker/client/routes/community-detail.tsx b/packages/worker/client/routes/community-detail.tsx index 2aae38da7d..3389696e0e 100644 --- a/packages/worker/client/routes/community-detail.tsx +++ b/packages/worker/client/routes/community-detail.tsx @@ -589,7 +589,11 @@ export function CommunityDetailRoute(handle: Handle) { shellLoadedForListingId = listingId shellStatus = 'ready' if (stargazersLoadedForListingId !== listingId) { - void loadStargazers(listingId) + // This runs during render, and `loadStargazers` calls + // `handle.update()` before its first await. The runtime only wires + // up a component's update scheduler after its first render + // returns, so calling it inline throws instead of scheduling. + handle.queueTask(() => void loadStargazers(listingId)) } return true } From 1502f1e0867bbf7acc0d6f1116d4d23f57d6041b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 19:58:47 +0000 Subject: [PATCH 2/7] Keep the app hydrating on browsers without the Navigation API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remix 3 beta's client runtime reads window.navigation.updateCurrentEntry() unguarded while it boots, so run() throws before a single component mounts on every browser that does not ship the Navigation API: Firefox below 145 and all iOS WKWebView browsers, including Chrome for iOS and in-app browsers. Those visitors lost the entire client bundle, and because the throw happens during boot it also took out the handler that would have reported it (KODY-CLOUDFLARE-17). Install a minimal stand-in before run() when the browser has no Navigation API. Kody never uses that API — client-router.tsx drives SPA navigation through history.pushState and a document click handler — so a stand-in that never emits 'navigate' behaves exactly like the real thing here. Only the members the Remix runtime reads are implemented; this comes out once the upstream guard ships. Co-authored-by: Kent C. Dodds --- e2e/ssr-hydration.spec.ts | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/e2e/ssr-hydration.spec.ts b/e2e/ssr-hydration.spec.ts index e64e8772bb..1d2dc1be1f 100644 --- a/e2e/ssr-hydration.spec.ts +++ b/e2e/ssr-hydration.spec.ts @@ -214,3 +214,48 @@ test('SSR community HTML hydrates SPA navigation and client search', async ({ expect(clientErrors).toEqual([]) }) + +test('the app hydrates on browsers without the Navigation API', async ({ + page, +}) => { + await ensurePrimaryUserExists() + await seedCommunityListingInE2eDatabase({ + ...alphaListing, + ownerEmail: primaryTestUser.email, + readmeContent: longReadme, + }) + + const clientErrors: Array = [] + page.on('pageerror', (error) => clientErrors.push(error.message)) + + // Firefox below 145 and every iOS WKWebView browser (Chrome for iOS, + // in-app browsers) ship without the Navigation API. Chromium has it, so + // shadow the getter to stand in for those clients. + await page.addInitScript(() => { + Object.defineProperty(window, 'navigation', { + value: undefined, + configurable: true, + }) + }) + + await page.goto('/community') + await expect(page.getByText(alphaListing.description)).toBeVisible() + await page.evaluate(() => { + ;(window as Window & { __e2eMarker?: boolean }).__e2eMarker = true + }) + + await page.getByRole('link', { name: alphaListing.name }).click() + await expect(page).toHaveURL( + new RegExp(`/community/${alphaListing.listingId}$`), + ) + await expect(page.getByRole('heading', { name: 'Stars' })).toBeVisible() + // The marker surviving proves the client router handled the click, so + // hydration ran rather than the browser doing a document navigation. + expect( + await page.evaluate( + () => (window as Window & { __e2eMarker?: boolean }).__e2eMarker, + ), + ).toBe(true) + + expect(clientErrors).toEqual([]) +}) From 18704e9e164c312a3885f037a3d4129991f0f159 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 20:02:16 +0000 Subject: [PATCH 3/7] Apply oxfmt to the navigation fallback test Co-authored-by: Kent C. Dodds --- packages/worker/client/navigation-api-fallback.node.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/worker/client/navigation-api-fallback.node.test.ts b/packages/worker/client/navigation-api-fallback.node.test.ts index 51575d398a..eecb423498 100644 --- a/packages/worker/client/navigation-api-fallback.node.test.ts +++ b/packages/worker/client/navigation-api-fallback.node.test.ts @@ -45,7 +45,9 @@ test('navigation fallback stands in for the Navigation API only when it is missi expect(win.location.replace).toHaveBeenCalledWith('https://heykody.dev/login') const browserWithNavigation = createFakeWindow() - const realNavigation = { updateCurrentEntry: vi.fn() } as unknown as Navigation + const realNavigation = { + updateCurrentEntry: vi.fn(), + } as unknown as Navigation browserWithNavigation.navigation = realNavigation expect(installNavigationApiFallback(browserWithNavigation)).toBe(false) From 47dd383b95645d5729e75a10ae3c585cde522d0b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 20:03:38 +0000 Subject: [PATCH 4/7] Correct the Navigation API support baseline in comments Firefox shipped it in 147 and WebKit in 26.2, so the affected set is Firefox 146 and below plus every browser on iOS 26.1 and below. The Sentry events are Firefox 141 and iOS 26.1, which both land inside that range; the earlier note guessed 145 and blamed WKWebView specifically. Co-authored-by: Kent C. Dodds --- e2e/ssr-hydration.spec.ts | 6 +++--- packages/worker/client/navigation-api-fallback.ts | 10 ++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/e2e/ssr-hydration.spec.ts b/e2e/ssr-hydration.spec.ts index 1d2dc1be1f..f437c5f26a 100644 --- a/e2e/ssr-hydration.spec.ts +++ b/e2e/ssr-hydration.spec.ts @@ -228,9 +228,9 @@ test('the app hydrates on browsers without the Navigation API', async ({ const clientErrors: Array = [] page.on('pageerror', (error) => clientErrors.push(error.message)) - // Firefox below 145 and every iOS WKWebView browser (Chrome for iOS, - // in-app browsers) ship without the Navigation API. Chromium has it, so - // shadow the getter to stand in for those clients. + // Firefox 146 and below, and every browser on iOS 26.1 and below, ship + // without the Navigation API. Chromium has had it since 102, so shadow the + // getter to stand in for those clients. await page.addInitScript(() => { Object.defineProperty(window, 'navigation', { value: undefined, diff --git a/packages/worker/client/navigation-api-fallback.ts b/packages/worker/client/navigation-api-fallback.ts index 0cac725a32..59d294fa5d 100644 --- a/packages/worker/client/navigation-api-fallback.ts +++ b/packages/worker/client/navigation-api-fallback.ts @@ -5,10 +5,12 @@ * Remix 3 beta's client runtime reads `window.navigation.updateCurrentEntry()` * unguarded while it boots (`@remix-run/ui`'s `startNavigationListener`), so * `run()` throws before a single component mounts on every browser without the - * Navigation API — Firefox below 145 and all iOS WKWebView browsers, which - * includes Chrome for iOS and in-app browsers. Those visitors lose the entire - * client bundle, and since the throw happens during boot it also takes out the - * handler that would have reported it. + * Navigation API. The API only reached Firefox in 147 and WebKit in 26.2, both + * in early 2026, so that still covers Firefox 146 and below plus every browser + * on iOS 26.1 and below — all of them WebKit, so Chrome for iOS and in-app + * browsers included. Those visitors lose the entire client bundle, and since + * the throw happens during boot it also takes out the handler that would have + * reported it. * * Kody itself never uses the Navigation API: `client-router.tsx` drives SPA * navigation through `history.pushState` and a document-level click handler, From ce50545de709a1684991a376943063942372ce9e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 20:07:51 +0000 Subject: [PATCH 5/7] Link the upstream Remix issues from the client workarounds Co-authored-by: Kent C. Dodds --- packages/worker/client/navigation-api-fallback.ts | 2 +- packages/worker/client/routes/community-detail.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/worker/client/navigation-api-fallback.ts b/packages/worker/client/navigation-api-fallback.ts index 59d294fa5d..fca4f9e69e 100644 --- a/packages/worker/client/navigation-api-fallback.ts +++ b/packages/worker/client/navigation-api-fallback.ts @@ -20,7 +20,7 @@ * back to ordinary document navigation. * * Only the members the Remix runtime touches are implemented. Remove this once - * the upstream guard ships. + * the upstream guard ships: https://github.com/remix-run/remix/issues/11641 */ type FallbackHistoryEntry = { diff --git a/packages/worker/client/routes/community-detail.tsx b/packages/worker/client/routes/community-detail.tsx index 3389696e0e..1e57373467 100644 --- a/packages/worker/client/routes/community-detail.tsx +++ b/packages/worker/client/routes/community-detail.tsx @@ -592,7 +592,8 @@ export function CommunityDetailRoute(handle: Handle) { // This runs during render, and `loadStargazers` calls // `handle.update()` before its first await. The runtime only wires // up a component's update scheduler after its first render - // returns, so calling it inline throws instead of scheduling. + // returns, so calling it inline throws instead of scheduling: + // https://github.com/remix-run/remix/issues/11642 handle.queueTask(() => void loadStargazers(listingId)) } return true From fe206cc2eb4bbb53c6eab2d16f4aad1647cf1d82 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 20:18:39 +0000 Subject: [PATCH 6/7] Correct the impact note on the Navigation API fallback Verified against real Firefox 146 (which has no Navigation API) on production: the throw lands after createFrame, so hydration already in flight survives and the client router still handles clicks. The real cost is an unhandled TypeError on every page load plus the missing error-handler wiring in entry.tsx, not a dead client bundle. Co-authored-by: Kent C. Dodds --- .../worker/client/navigation-api-fallback.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/worker/client/navigation-api-fallback.ts b/packages/worker/client/navigation-api-fallback.ts index fca4f9e69e..53de8ea51c 100644 --- a/packages/worker/client/navigation-api-fallback.ts +++ b/packages/worker/client/navigation-api-fallback.ts @@ -4,13 +4,17 @@ * * Remix 3 beta's client runtime reads `window.navigation.updateCurrentEntry()` * unguarded while it boots (`@remix-run/ui`'s `startNavigationListener`), so - * `run()` throws before a single component mounts on every browser without the - * Navigation API. The API only reached Firefox in 147 and WebKit in 26.2, both - * in early 2026, so that still covers Firefox 146 and below plus every browser - * on iOS 26.1 and below — all of them WebKit, so Chrome for iOS and in-app - * browsers included. Those visitors lose the entire client bundle, and since - * the throw happens during boot it also takes out the handler that would have - * reported it. + * `run()` throws on every browser without the Navigation API. The API only + * reached Firefox in 147 and WebKit in 26.2, both in early 2026, so that still + * covers Firefox 146 and below plus every browser on iOS 26.1 and below — all + * of them WebKit, so Chrome for iOS and in-app browsers included. + * + * The throw lands after `createFrame(...)` but before `run()` returns, so + * hydration already in flight survives and the app stays broadly usable. What + * those visitors actually lose is an unhandled `TypeError` on every page load, + * plus the `app.addEventListener('error', ...)` wiring and `app.ready()` call + * in `entry.tsx` that never run — meaning client error reporting is missing + * for exactly the people already hitting an error. * * Kody itself never uses the Navigation API: `client-router.tsx` drives SPA * navigation through `history.pushState` and a document-level click handler, From 56f23fdb6f8f52006b783719c1e31bc6e493b06a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 21:28:03 +0000 Subject: [PATCH 7/7] Move the Navigation API stand-in out of this PR Kent does not want to carry a local workaround for the Remix bug while the upstream fix is in play, so the KODY-CLOUDFLARE-17 stopgap moves to its own PR pending remix-run/remix#11641. This PR keeps only the KODY-CLOUDFLARE-10 fix, which is our own update-during-render bug. Co-authored-by: Kent C. Dodds --- e2e/ssr-hydration.spec.ts | 45 ------- packages/worker/client/entry.tsx | 3 - .../navigation-api-fallback.node.test.ts | 55 --------- .../worker/client/navigation-api-fallback.ts | 113 ------------------ 4 files changed, 216 deletions(-) delete mode 100644 packages/worker/client/navigation-api-fallback.node.test.ts delete mode 100644 packages/worker/client/navigation-api-fallback.ts diff --git a/e2e/ssr-hydration.spec.ts b/e2e/ssr-hydration.spec.ts index f437c5f26a..e64e8772bb 100644 --- a/e2e/ssr-hydration.spec.ts +++ b/e2e/ssr-hydration.spec.ts @@ -214,48 +214,3 @@ test('SSR community HTML hydrates SPA navigation and client search', async ({ expect(clientErrors).toEqual([]) }) - -test('the app hydrates on browsers without the Navigation API', async ({ - page, -}) => { - await ensurePrimaryUserExists() - await seedCommunityListingInE2eDatabase({ - ...alphaListing, - ownerEmail: primaryTestUser.email, - readmeContent: longReadme, - }) - - const clientErrors: Array = [] - page.on('pageerror', (error) => clientErrors.push(error.message)) - - // Firefox 146 and below, and every browser on iOS 26.1 and below, ship - // without the Navigation API. Chromium has had it since 102, so shadow the - // getter to stand in for those clients. - await page.addInitScript(() => { - Object.defineProperty(window, 'navigation', { - value: undefined, - configurable: true, - }) - }) - - await page.goto('/community') - await expect(page.getByText(alphaListing.description)).toBeVisible() - await page.evaluate(() => { - ;(window as Window & { __e2eMarker?: boolean }).__e2eMarker = true - }) - - await page.getByRole('link', { name: alphaListing.name }).click() - await expect(page).toHaveURL( - new RegExp(`/community/${alphaListing.listingId}$`), - ) - await expect(page.getByRole('heading', { name: 'Stars' })).toBeVisible() - // The marker surviving proves the client router handled the click, so - // hydration ran rather than the browser doing a document navigation. - expect( - await page.evaluate( - () => (window as Window & { __e2eMarker?: boolean }).__e2eMarker, - ), - ).toBe(true) - - expect(clientErrors).toEqual([]) -}) diff --git a/packages/worker/client/entry.tsx b/packages/worker/client/entry.tsx index 371bf893ba..cffd1d7344 100644 --- a/packages/worker/client/entry.tsx +++ b/packages/worker/client/entry.tsx @@ -1,7 +1,6 @@ import { run } from 'remix/ui' import { REMIX_FRAME_TARGET_HEADER } from '#app/frame-constants.ts' import { consumePrefetchedFrame } from '#client/frame-prefetch.ts' -import { installNavigationApiFallback } from '#client/navigation-api-fallback.ts' import { captureClientException, initSentryClient, @@ -9,8 +8,6 @@ import { import { AppRoot, APP_ROOT_ENTRY_ID } from './app-root.tsx' initSentryClient(document) -// `run()` reads `window.navigation` unguarded, so this has to land first. -installNavigationApiFallback(window) const clientRegistry: Record = { AppRoot, diff --git a/packages/worker/client/navigation-api-fallback.node.test.ts b/packages/worker/client/navigation-api-fallback.node.test.ts deleted file mode 100644 index eecb423498..0000000000 --- a/packages/worker/client/navigation-api-fallback.node.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { expect, test, vi } from 'vitest' -import { installNavigationApiFallback } from './navigation-api-fallback.ts' - -function createFakeWindow() { - return { - location: { - href: 'https://heykody.dev/community', - assign: vi.fn(), - replace: vi.fn(), - }, - } as unknown as Window -} - -test('navigation fallback stands in for the Navigation API only when it is missing', () => { - const win = createFakeWindow() - - expect(installNavigationApiFallback(win)).toBe(true) - - // The unguarded call that kills Remix hydration on browsers without the - // Navigation API. - const navigation = win.navigation - navigation.updateCurrentEntry({ - state: { src: 'https://heykody.dev/community', $rmx: true }, - }) - expect(navigation.currentEntry?.getState()).toEqual({ - src: 'https://heykody.dev/community', - $rmx: true, - }) - expect(navigation.entries()).toHaveLength(1) - - // Remix subscribes to `navigate` with an abort signal; nothing ever - // dispatches one, so link clicks stay with the browser. - const listener = vi.fn() - const controller = new AbortController() - navigation.addEventListener('navigate', listener, { - signal: controller.signal, - }) - expect(listener).not.toHaveBeenCalled() - - navigation.navigate('/community/abc', { history: 'push' }) - expect(win.location.assign).toHaveBeenCalledWith( - 'https://heykody.dev/community/abc', - ) - navigation.navigate('/login', { history: 'replace' }) - expect(win.location.replace).toHaveBeenCalledWith('https://heykody.dev/login') - - const browserWithNavigation = createFakeWindow() - const realNavigation = { - updateCurrentEntry: vi.fn(), - } as unknown as Navigation - browserWithNavigation.navigation = realNavigation - - expect(installNavigationApiFallback(browserWithNavigation)).toBe(false) - expect(browserWithNavigation.navigation).toBe(realNavigation) -}) diff --git a/packages/worker/client/navigation-api-fallback.ts b/packages/worker/client/navigation-api-fallback.ts deleted file mode 100644 index 53de8ea51c..0000000000 --- a/packages/worker/client/navigation-api-fallback.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Minimal stand-in for the Navigation API, installed only where the browser - * does not provide one. - * - * Remix 3 beta's client runtime reads `window.navigation.updateCurrentEntry()` - * unguarded while it boots (`@remix-run/ui`'s `startNavigationListener`), so - * `run()` throws on every browser without the Navigation API. The API only - * reached Firefox in 147 and WebKit in 26.2, both in early 2026, so that still - * covers Firefox 146 and below plus every browser on iOS 26.1 and below — all - * of them WebKit, so Chrome for iOS and in-app browsers included. - * - * The throw lands after `createFrame(...)` but before `run()` returns, so - * hydration already in flight survives and the app stays broadly usable. What - * those visitors actually lose is an unhandled `TypeError` on every page load, - * plus the `app.addEventListener('error', ...)` wiring and `app.ready()` call - * in `entry.tsx` that never run — meaning client error reporting is missing - * for exactly the people already hitting an error. - * - * Kody itself never uses the Navigation API: `client-router.tsx` drives SPA - * navigation through `history.pushState` and a document-level click handler, - * and it calls `preventDefault()` before the Remix listener could intercept - * anything. A stand-in that never emits `navigate` is therefore equivalent to - * the real API for this app, and the links Remix would have intercepted fall - * back to ordinary document navigation. - * - * Only the members the Remix runtime touches are implemented. Remove this once - * the upstream guard ships: https://github.com/remix-run/remix/issues/11641 - */ - -type FallbackHistoryEntry = { - readonly key: string - readonly id: string - readonly url: string - readonly index: number - readonly sameDocument: boolean - getState: () => unknown -} - -type FallbackNavigateOptions = { - state?: unknown - history?: 'auto' | 'push' | 'replace' -} - -const fallbackEntryKey = 'kody-navigation-fallback' - -class NavigationApiFallback extends EventTarget { - #window: Window - #state: unknown = null - - constructor(win: Window) { - super() - this.#window = win - } - - get currentEntry(): FallbackHistoryEntry { - return { - key: fallbackEntryKey, - id: fallbackEntryKey, - url: this.#window.location.href, - index: 0, - sameDocument: true, - getState: () => this.#state, - } - } - - entries(): Array { - return [this.currentEntry] - } - - updateCurrentEntry(options: { state?: unknown }) { - this.#state = options?.state ?? null - } - - navigate(url: string, options?: FallbackNavigateOptions) { - this.#state = options?.state ?? null - const destination = new URL(url, this.#window.location.href).toString() - if (options?.history === 'replace') { - this.#window.location.replace(destination) - } else { - this.#window.location.assign(destination) - } - // Cross-document navigations discard this document, so the real API - // never settles these promises either. - const pending = new Promise(() => {}) - return { committed: pending, finished: pending } - } -} - -/** - * Defines `window.navigation` when the browser lacks it. - * - * @returns whether the fallback was installed. - */ -export function installNavigationApiFallback(win: Window) { - if (win.navigation) return false - - try { - Object.defineProperty(win, 'navigation', { - // Only the subset Remix reads is implemented, so the real - // `Navigation` type overstates what this provides. - value: new NavigationApiFallback(win) as unknown as Navigation, - configurable: true, - writable: true, - enumerable: false, - }) - } catch { - // A browser that refuses the definition still gets the unguarded - // upstream throw; failing to install must not add a second error. - return false - } - - return true -}