Skip to content

[experiment] Add useOffline hook to expose offline state to userland#92012

Merged
acdlite merged 1 commit intovercel:canaryfrom
acdlite:use-offline-hook
Mar 28, 2026
Merged

[experiment] Add useOffline hook to expose offline state to userland#92012
acdlite merged 1 commit intovercel:canaryfrom
acdlite:use-offline-hook

Conversation

@acdlite
Copy link
Copy Markdown
Contributor

@acdlite acdlite commented Mar 27, 2026

Previous:

  1. [experiment] Add useOffline flag with offline retry behavior #92011

Current:

  1. (this PR)

Adds a useOffline() hook exported from next/navigation that returns true when the app is offline. The state is owned by a provider component rendered in the app router, using useState + useOptimistic so the value can update even during blocked transitions (e.g., a navigation that's waiting for connectivity).

Gated behind experimental.useOffline.

@nextjs-bot
Copy link
Copy Markdown
Collaborator

nextjs-bot commented Mar 27, 2026

Failing test suites

Commit: f19bfd6 | About building and testing Next.js

pnpm test-start-turbo test/production/graceful-shutdown/index.test.ts (turbopack) (job)

  • Graceful Shutdown > production (standalone mode) > should not accept new requests during shutdown cleanup > should stop accepting new requests when shutting down (DD)
Expand output

● Graceful Shutdown › production (standalone mode) › should not accept new requests during shutdown cleanup › should stop accepting new requests when shutting down

expect(received).toEqual(expected) // deep equality

- Expected  - 1
+ Received  + 1

  Array [
-   143,
    null,
+   "SIGTERM",
  ]

  227 |
  228 |         // App finally shuts down with signal-based exit code (128 + 15 for SIGTERM)
> 229 |         expect(await appKilledPromise).toEqual([143, null])
      |                                        ^
  230 |         expect(app.exitCode).toBe(143)
  231 |       })
  232 |     })

  at Object.toEqual (production/graceful-shutdown/index.test.ts:229:40)

pnpm test-dev test/e2e/app-dir/interception-dynamic-segment/interception-dynamic-segment.test.ts (job)

  • interception-dynamic-segment > should work when interception route is paired with a dynamic segment (DD)
  • interception-dynamic-segment > should intercept consistently with back/forward navigation (DD)
  • interception-dynamic-segment > should intercept multiple times from root (DD)
Expand output

● interception-dynamic-segment › should work when interception route is paired with a dynamic segment

expect(received).toContain(expected) // indexOf

Expected substring: "intercepted"
Received string:    "MODAL SLOT:
default"

  70 |
  71 |     await retry(async () => {
> 72 |       expect(await browser.elementById('modal').text()).toContain('intercepted')
     |                                                         ^
  73 |     })
  74 |
  75 |     await browser.refresh()

  at toContain (e2e/app-dir/interception-dynamic-segment/interception-dynamic-segment.test.ts:72:57)
  at retry (lib/next-test-utils.ts:861:14)
  at Object.<anonymous> (e2e/app-dir/interception-dynamic-segment/interception-dynamic-segment.test.ts:71:5)

● interception-dynamic-segment › should intercept consistently with back/forward navigation

page.waitForSelector: Timeout 5000ms exceeded.
Call log:
  - waiting for locator('[data-testid="link-accordion"][data-href="/foo/1"]') to be visible

  545 |
  546 |     return this.startChain(async () => {
> 547 |       const el = await page.waitForSelector(selector, {
      |                             ^
  548 |         timeout,
  549 |         state,
  550 |       })

  at waitForSelector (lib/browsers/playwright.ts:547:29)
  at Playwright._chain (lib/browsers/playwright.ts:677:23)
  at Playwright._chain [as startChain] (lib/browsers/playwright.ts:658:17)
  at Playwright.startChain [as waitForElementByCss] (lib/browsers/playwright.ts:546:17)
  at Playwright.waitForElementByCss [as elementByCss] (lib/browsers/playwright.ts:431:17)
  at elementByCss (e2e/app-dir/interception-dynamic-segment/interception-dynamic-segment.test.ts:33:37)
  at Object.navigate (e2e/app-dir/interception-dynamic-segment/interception-dynamic-segment.test.ts:93:11)

● interception-dynamic-segment › should intercept multiple times from root

expect(received).toContain(expected) // indexOf

Expected substring: "intercepted"
Received string:    "MODAL SLOT:
catch-all"

  125 |
  126 |       await retry(async () => {
> 127 |         expect(await browser.elementById('modal').text()).toContain(
      |                                                           ^
  128 |           'intercepted'
  129 |         )
  130 |       })

  at toContain (e2e/app-dir/interception-dynamic-segment/interception-dynamic-segment.test.ts:127:59)
  at retry (lib/next-test-utils.ts:861:14)
  at Object.<anonymous> (e2e/app-dir/interception-dynamic-segment/interception-dynamic-segment.test.ts:126:7)

pnpm test-start-turbo test/e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts (turbopack) (job)

  • prefetch inlining > independent head: metadata is prefetched even when no runtime segment request is needed (DD)
Expand output

● prefetch inlining › independent head: metadata is prefetched even when no runtime segment request is needed

thrown: "Exceeded timeout of 60000 ms for a test.
Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."

  50 |       }
  51 |
> 52 |       const result = Reflect.apply(target, thisArg, args)
     |                              ^
  53 |       return typeof result === 'function' ? wrapJestTestFn(result) : result
  54 |     },
  55 |     get(target, prop, receiver) {

  at Object.apply (lib/e2e-utils/index.ts:52:30)
  at it (e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts:688:3)
  at Object.describe (e2e/app-dir/segment-cache/prefetch-inlining/prefetch-inlining.test.ts:143:1)

pnpm test test/integration/telemetry/test/index.test.ts (job)

  • Telemetry CLI > production mode > cli session: custom babel config (preset) (DD)
  • Telemetry CLI > production mode > cli session: next config with webpack (DD)
  • Telemetry CLI > production mode > detect static 404 correctly for next build (DD)
Expand output

● Telemetry CLI › production mode › cli session: custom babel config (preset)

thrown: "Exceeded timeout of 60000 ms for a test.
Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."

  266 |       })
  267 |
> 268 |       it('cli session: custom babel config (preset)', async () => {
      |       ^
  269 |         await fs.rename(
  270 |           path.join(appDir, '.babelrc.preset'),
  271 |           path.join(appDir, '.babelrc')

  at it (integration/telemetry/test/index.test.ts:268:7)
  at integration/telemetry/test/index.test.ts:98:56
  at Object.describe (integration/telemetry/test/index.test.ts:13:1)

● Telemetry CLI › production mode › cli session: next config with webpack

TypeError: Cannot read properties of null (reading 'pop')

  309 |
  310 |         const event = /NEXT_CLI_SESSION_STARTED[\s\S]+?{([\s\S]+?)}/
> 311 |           .exec(stderr)
      |                 ^
  312 |           .pop()
  313 |
  314 |         expect(event).toMatch(/"hasNextConfig": true/)

  at Object.stderr (integration/telemetry/test/index.test.ts:311:17)

● Telemetry CLI › production mode › detect static 404 correctly for next build

TypeError: Cannot read properties of null (reading 'pop')

  342 |
  343 |         const event1 = /NEXT_BUILD_OPTIMIZED[\s\S]+?{([\s\S]+?)}/
> 344 |           .exec(stderr)
      |                 ^
  345 |           .pop()
  346 |         expect(event1).toMatch(/hasStatic404.*?true/)
  347 |       })

  at Object.stderr (integration/telemetry/test/index.test.ts:344:17)

pnpm test test/integration/create-next-app/templates/app.test.ts (job)

  • create-next-app --app (App Router) > should create project inside "src" directory with --src-dir flag (DD)
Expand output

● create-next-app --app (App Router) › should create project inside "src" directory with --src-dir flag

thrown: "Exceeded timeout of 60000 ms for a test.
Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."

  82 |   })
  83 |
> 84 |   it('should create project inside "src" directory with --src-dir flag', async () => {
     |   ^
  85 |     await useTempDir(async (cwd) => {
  86 |       const projectName = 'app-src-dir'
  87 |       const { exitCode } = await run(

  at it (integration/create-next-app/templates/app.test.ts:84:3)
  at Object.describe (integration/create-next-app/templates/app.test.ts:9:1)

pnpm test-start test/e2e/app-dir/actions/app-action-progressive-enhancement.test.ts (job)

  • proxy-missing-export > should NOT error when proxy file has a default function export (DD)
  • proxy-missing-export > should NOT error when proxy file has a default arrow function export (DD)
  • proxy-missing-export > should NOT error when proxy file has a named declaration function export (DD)
  • proxy-missing-export > should NOT error when proxy file has a named declaration arrow function export (DD)
  • proxy-missing-export > should error when proxy file has a named export with different name alias (DD)
Expand output

● proxy-missing-export › should NOT error when proxy file has a default function export

next already started

  63 |   public async start(options: { skipBuild?: boolean } = {}) {
  64 |     if (this.childProcess) {
> 65 |       throw new Error('next already started')
     |             ^
  66 |     }
  67 |
  68 |     this._cliOutput = ''

  at NextStartInstance.start (lib/next-modes/next-start.ts:65:13)
  at Object.start (e2e/app-dir/proxy-missing-export/proxy-missing-export.test.ts:66:16)

● proxy-missing-export › should NOT error when proxy file has a default arrow function export

next already started

  63 |   public async start(options: { skipBuild?: boolean } = {}) {
  64 |     if (this.childProcess) {
> 65 |       throw new Error('next already started')
     |             ^
  66 |     }
  67 |
  68 |     this._cliOutput = ''

  at NextStartInstance.start (lib/next-modes/next-start.ts:65:13)
  at Object.start (e2e/app-dir/proxy-missing-export/proxy-missing-export.test.ts:77:16)

● proxy-missing-export › should NOT error when proxy file has a named declaration function export

next already started

  63 |   public async start(options: { skipBuild?: boolean } = {}) {
  64 |     if (this.childProcess) {
> 65 |       throw new Error('next already started')
     |             ^
  66 |     }
  67 |
  68 |     this._cliOutput = ''

  at NextStartInstance.start (lib/next-modes/next-start.ts:65:13)
  at Object.start (e2e/app-dir/proxy-missing-export/proxy-missing-export.test.ts:91:16)

● proxy-missing-export › should NOT error when proxy file has a named declaration arrow function export

next already started

  63 |   public async start(options: { skipBuild?: boolean } = {}) {
  64 |     if (this.childProcess) {
> 65 |       throw new Error('next already started')
     |             ^
  66 |     }
  67 |
  68 |     this._cliOutput = ''

  at NextStartInstance.start (lib/next-modes/next-start.ts:65:13)
  at Object.start (e2e/app-dir/proxy-missing-export/proxy-missing-export.test.ts:105:16)

● proxy-missing-export › should error when proxy file has a named export with different name alias

can not run export while server is running, use next.stop() first

  251 |   ) {
  252 |     if (this.childProcess) {
> 253 |       throw new Error(
      |             ^
  254 |         `can not run export while server is running, use next.stop() first`
  255 |       )
  256 |     }

  at NextStartInstance.build (lib/next-modes/next-start.ts:253:13)
  at Object.build (e2e/app-dir/proxy-missing-export/proxy-missing-export.test.ts:127:31)

@acdlite acdlite force-pushed the use-offline-retry branch from 4853774 to c5be188 Compare March 27, 2026 22:45
@acdlite acdlite force-pushed the use-offline-retry branch from c5be188 to 7a0a2e2 Compare March 28, 2026 00:04
@acdlite acdlite force-pushed the use-offline-retry branch from 7a0a2e2 to 4beaf4d Compare March 28, 2026 00:05
@acdlite acdlite force-pushed the use-offline-retry branch from 4beaf4d to 423131e Compare March 28, 2026 00:19
@acdlite acdlite force-pushed the use-offline-hook branch 2 times, most recently from d55b571 to 1350499 Compare March 28, 2026 00:26
acdlite added a commit that referenced this pull request Mar 28, 2026
**Current:**

1. #92011

**Up next:**

2. #92012

---

When a navigation, server action, or prefetch fails due to a network
error, instead of falling back to MPA navigation or surfacing an error,
the request blocks until connectivity is restored and then retries
automatically.

Offline detection works by treating any `fetch()` rejection as a network
error (server errors resolve with a status code, they don't reject).
Once offline, a polling loop does HEAD requests with exponential backoff
to detect when connectivity returns. Browser offline/online events also
feed into this loop. Successful fetches from other code paths can
short-circuit the loop if they happen to land first.

Follow-up work will add a `useOffline` hook so apps can show an offline
indicator to the user, and allow navigations to read from stale cache
entries while offline.

Gated behind `experimental.useOffline`.
@acdlite acdlite changed the base branch from use-offline-retry to canary March 28, 2026 00:48
Adds a useOffline() hook exported from next/navigation that returns
true when the app is offline. The state is owned by a provider
component rendered in the app router, using useState + useOptimistic
so the value can update even during blocked transitions (e.g., a
navigation that's waiting for connectivity).

Gated behind the experimental.useOffline flag.

Refresh of use-offline-hook
@acdlite acdlite marked this pull request as ready for review March 28, 2026 00:59
@acdlite acdlite merged commit 7bce97d into vercel:canary Mar 28, 2026
139 of 150 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants