Skip to content

feat(adapters): add React Router v6 HashRouter support#1303

Open
gsaandy wants to merge 3 commits into47ng:nextfrom
gsaandy:feat/react-router-v6-hash-adapter
Open

feat(adapters): add React Router v6 HashRouter support#1303
gsaandy wants to merge 3 commits into47ng:nextfrom
gsaandy:feat/react-router-v6-hash-adapter

Conversation

@gsaandy
Copy link
Contributor

@gsaandy gsaandy commented Jan 3, 2026

Summary

  • Adds a dedicated adapter for React Router v6 HashRouter (createHashRouter and <HashRouter>)
  • Correctly handles the HashRouter URL structure where search params are inside the hash fragment
    (/#/page?foo=bar instead of /?foo=bar)
  • Includes full e2e test suite and documentation updates

New Files

  • nuqs/adapters/react-router/v6-hash - Entry point
  • src/adapters/lib/hash-router.ts - Factory function
  • src/adapters/lib/hash-router-utils.ts - URL parsing utilities
  • packages/e2e/react-router/v6-hash/ - E2E test suite

Test plan

  • Unit tests pass (pnpm run test:unit)
  • Build succeeds (pnpm run build)
  • Manually tested in a real app using createHashRouter

Closes #810
Closes #1173

@vercel
Copy link

vercel bot commented Jan 3, 2026

@gsaandy is attempting to deploy a commit to the 47ng Team on Vercel.

A member of the Team first needs to authorize it.

@gsaandy gsaandy force-pushed the feat/react-router-v6-hash-adapter branch from 84603d5 to 6cbd6ad Compare January 3, 2026 22:35
@franky47 franky47 added the adapters/react-router Uses the React Router adapter label Jan 3, 2026
@franky47
Copy link
Member

franky47 commented Jan 3, 2026

Wow, that's quite impressive, thanks!

Since the hash part is never sent to the server, I don't think the shallow: false option makes sense for this adapter, it's only ever going to be used client-side (since RRv6 doesn't do SSR). It's mostly a way to control whether to call the loader or not (some sort of inverted logic for shouldRevalidate, if this API exists in v6, I'm mostly familiar with it in v7).

Feel free to add a job to the ci-cd.yml so we can have the test results in here.

@gsaandy gsaandy force-pushed the feat/react-router-v6-hash-adapter branch from 6cbd6ad to b0d0ada Compare January 3, 2026 22:58
@gsaandy gsaandy marked this pull request as ready for review January 3, 2026 22:58
@gsaandy
Copy link
Contributor Author

gsaandy commented Jan 3, 2026

Wow, that's quite impressive, thanks!

Since the hash part is never sent to the server, I don't think the shallow: false option makes sense for this adapter, it's only ever going to be used client-side (since RRv6 doesn't do SSR). It's mostly a way to control whether to call the loader or not (some sort of inverted logic for shouldRevalidate, if this API exists in v6, I'm mostly familiar with it in v7).

Feel free to add a job to the ci-cd.yml so we can have the test results in here.

@franky47 - Updated the PR, could you take another look.

@gsaandy gsaandy force-pushed the feat/react-router-v6-hash-adapter branch 4 times, most recently from 4a53fae to 73f1760 Compare January 4, 2026 08:54
Add a dedicated adapter for React Router v6 HashRouter (createHashRouter
and <HashRouter>) which stores search params inside the URL hash fragment.

This adapter correctly handles the HashRouter URL structure where params
are at /#/page?foo=bar instead of /?foo=bar.

New files:
- adapters/react-router/v6-hash entry point
- lib/hash-router.ts factory function
- lib/hash-router-utils.ts URL parsing utilities
- Full e2e test suite

Closes 47ng#810
Closes 47ng#1173

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@gsaandy gsaandy force-pushed the feat/react-router-v6-hash-adapter branch from 73f1760 to 78b24f3 Compare January 4, 2026 08:55
@franky47 franky47 added this to the 🪵 Backlog milestone Jan 4, 2026
@vercel
Copy link

vercel bot commented Jan 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
nuqs Ready Ready Preview, Comment Jan 4, 2026 9:12pm

Copy link
Member

@franky47 franky47 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I took a look and the main issue is the lack of actual test specs for the routes that are declared in the e2e-react-router-v6-hash package.

Once we actually have the basics (shared behaviour testing) covered, we could consider this a core adapter. If it works fine enough with manual testing, I'm also happy to add it as a community adapter as a shadcn registry item.

"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port 3016",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Good way to indicate we're still on RRv6, I like it.

suggestion: This should be added to the list of ports used in the CONTRIBUTING.md document.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: These utility functions should be unit-tested against various (passing & edge) cases.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: It's hard to see the difference without an actual diff, but it looks like there is a lot of overlap between this implementation and that of the location.search-based adapters for RRv6/v7/Remix.

Would it make sense to make the generic implementation configurable to route to either the query string or the hash? (which may even open the possibility of having a hash-based behaviour for RRv7, not sure if Remix supports it).

Comment on lines 16 to 18
await page.goto('./#/basic-io/useQueryState?test=init')
await page.waitForLoadState('networkidle')
await page.locator('#hydration-marker').waitFor({ state: 'hidden' })
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: This could use the updated navigateTo implementation.

return searchIndex >= 0 ? hashContent.slice(searchIndex) : ''
}

test.describe('HashRouter Basic I/O', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: Even though the URL structure is different than the BasicIO test, this should probably be placed in its own basic-io.spec.ts file to help sorting/filtering/maintaining test specs in the future.

Copy link
Member

@franky47 franky47 Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Ah so that explains why the tests are passing, a lot of the test fixtures (routes) are actually not covered with specs.

They would actually not benefit from using the shared *.spec.ts files in e2e-shared, as those are querystring-based, but the rule of thumb is if a route is defined in the app, it should be covered by a test.

For an adapter to be officially supported in core, the list of tests defined in runSharedTests in packages/e2e/shared/shared.spec.ts is the minimum required. Some might actually not make sense in this case (eg: hash preservation, I don't think we can have another layer of hash at the end of the "query string" in the hash router). The other specs would need rewriting to handle the different setup & assertions on the URL (unless you have an idea on how to make it generic, using the abstractions you have in place for navigateTo and .toHaveUrl matchers. Note that we also have custom ones in expect-url.ts).

@gsaandy
Copy link
Contributor Author

gsaandy commented Jan 5, 2026

Thanks, I took a look and the main issue is the lack of actual test specs for the routes that are declared in the e2e-react-router-v6-hash package.

Once we actually have the basics (shared behaviour testing) covered, we could consider this a core adapter. If it works fine enough with manual testing, I'm also happy to add it as a community adapter as a shadcn registry item.

Thanks @franky47 for reviewing this, I ran it through claude and here is the refactoring plan, could you review this?

Plan: Address Review Comments for React Router v6 HashRouter Adapter

Review Comments Summary

  1. Main issue: Lack of actual test specs for routes - shared tests are minimum required for core adapter
  2. Port documentation: Add port 3016 to CONTRIBUTING.md
  3. Unit tests: hash-router-utils.ts needs unit tests
  4. Code overlap: Refactor to make configurable implementation for hash vs query string routing
  5. Use navigateTo helper: hash-router.spec.ts should use shared navigateTo
  6. Split test files: Organize tests into separate spec files
  7. Shared tests requirement: runSharedTests is minimum for core adapter support

Part 1: Refactor Adapters (Strategy Pattern)

Goal

Merge hash-router.ts into react-router.ts using a configurable mode parameter ('standard' | 'hash').

New File: packages/nuqs/src/adapters/lib/router-strategies.ts

Create strategy interface and implementations:

type RouterMode = 'standard' | 'hash'

interface RouterModeStrategy {
  getSearchParams(): URLSearchParams
  getSearchString(): string
  constructUrl(search: URLSearchParams): URL
  navigationEvents: readonly ('popstate' | 'hashchange')[]
  supportsServerNavigation: boolean
  getQueueResetMutex(shallow: boolean): 1 | 2
  extractSearchParamsFromUrl(url: URL | string): URLSearchParams | null
  extractSearchStringFromUrl(url: URL | string): string | null
}
  • createStandardStrategy() - Uses location.search, popstate only
  • createHashStrategy() - Uses location.hash, popstate + hashchange

Modify: packages/nuqs/src/adapters/lib/react-router.ts

Update factory function signature:

type CreateReactRouterBasedAdapterArgs = {
  adapter: string
  useSearchParams: UseSearchParams
  mode?: RouterMode  // Defaults to 'standard'
  useNavigate?: UseNavigate  // Only required for 'standard' mode
}

Key changes:

  • Accept mode parameter
  • Use strategy for URL reading/construction
  • Conditionally call navigate() based on strategy.supportsServerNavigation
  • Subscribe to events from strategy.navigationEvents

Modify: packages/nuqs/src/adapters/lib/patch-history.ts

Update patchHistory to accept strategy:

export function patchHistory(
  emitter: Emitter<SearchParamsSyncEmitterEvents>,
  adapter: string,
  strategy: RouterModeStrategy
): void
  • Use strategy.getSearchString() for tracking
  • Subscribe to strategy.navigationEvents
  • Use strategy.extractSearchStringFromUrl() in sync function

Update Entry Points

v6.ts: Add mode: 'standard' (explicit)
v6-hash.ts: Use unified factory with mode: 'hash'
v7.ts: Add mode: 'standard' (explicit)

Delete: packages/nuqs/src/adapters/lib/hash-router.ts

Merged into unified implementation.


Part 2: Update Shared Test Infrastructure

Modify: packages/e2e/shared/playwright/expect-url.ts

Add isHashRouter parameter:

export function expectSearch(
  page: Page,
  expected: Record<string, string>,
  isHashRouter = false
) {
  return expectUrl(page, url => {
    const searchString = isHashRouter
      ? getSearchFromUrl(url, true)
      : url.search
    const params = new URLSearchParams(searchString)
    return Object.entries(expected).every(
      ([key, value]) => params.get(key) === value
    )
  }, ...)
}

Modify: All Shared Specs

Update URL assertions to use config.isHashRouter:

// Before:
await expect(page).toHaveURL(url => url.search === '?test=pass')

// After:
await expect(page).toHaveURL(
  createSearchMatcher('?test=pass', config.isHashRouter ?? false)
)

Pass isHashRouter to navigateTo:

await navigateTo(page, path, '?test=init', { isHashRouter: config.isHashRouter })

Specs to update:

  • basic-io.spec.ts
  • push.spec.ts
  • json.spec.ts
  • key-isolation.spec.ts
  • shallow.spec.ts
  • stitching.spec.ts
  • native-array.spec.ts
  • debounce.spec.ts
  • flush-after-navigate.spec.ts
  • repro-359.spec.ts
  • repro-982.spec.ts
  • repro-1099.spec.ts

Modify: packages/e2e/shared/shared.spec.ts

Skip hash-preservation for HashRouter (can't have hash within hash):

if (!config.isHashRouter) {
  testHashPreservation({ path: `${pathPrefix}/hash-preservation`, ...config })
}

Part 3: Enable Shared Tests for v6-hash

Modify: packages/e2e/react-router/v6-hash/specs/shared.spec.ts

import { runSharedTests } from 'e2e-shared/shared.spec.ts'

runSharedTests('', {
  router: 'react-router-v6-hash',
  isHashRouter: true
})

Modify: packages/e2e/react-router/v6-hash/specs/hash-router.spec.ts

  • Keep only HashRouter-specific URL structure tests
  • Remove Basic I/O tests (now covered by shared tests)
  • Use navigateTo helper instead of inline page.goto

Part 4: Unit Tests for hash-router-utils.ts

New File: packages/nuqs/src/adapters/lib/hash-router-utils.test.ts

Test cases:

getSearchFromHash:

  • '#/page?foo=bar''?foo=bar'
  • '#/page'''
  • '#?foo=bar''?foo=bar'
  • ''''
  • '#'''
  • '#/page?foo=bar?baz=qux''?foo=bar?baz=qux' (first ? starts search)

getPathnameFromHash:

  • '#/page?foo=bar''/page'
  • '#/page''/page'
  • '#/''/'
  • '#?foo=bar'''
  • ''''

constructHash:

  • ('/page', '?foo=bar')'#/page?foo=bar'
  • ('/page', '')'#/page'
  • ('', '?foo=bar')'#?foo=bar'

Part 5: Documentation

Modify: CONTRIBUTING.md

Add to port table:

| 3016 | React Router v6 (HashRouter) | `./packages/e2e/react-router/v6-hash` |

gsaandy and others added 2 commits January 5, 2026 18:16
- Refactor hash-router.ts into react-router.ts using strategy pattern
- Add RouterModeStrategy interface for mode-specific URL handling
- Add unit tests for hash-router-utils.ts (27 tests)
- Enable shared tests for v6-hash adapter with isHashRouter support
- Update shared specs to pass isHashRouter to navigateTo
- Skip Form tests (HTML forms use location.search, not hash)
- Skip shallow routing tests (requires useNavigate not used in hash mode)
- Add port 3016 to CONTRIBUTING.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@gsaandy gsaandy force-pushed the feat/react-router-v6-hash-adapter branch from 65b611d to 65a8e8e Compare January 5, 2026 20:22
@gsaandy
Copy link
Contributor Author

gsaandy commented Jan 7, 2026

@franky47 please review the new changes, and let me know if this approach works or not.

@gsaandy
Copy link
Contributor Author

gsaandy commented Jan 19, 2026

@franky47 gentle reminder

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

adapters/react-router Uses the React Router adapter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

react-router HashRouter support? nuqs generates incorrect URL format with React Router's createHashRouter - query params before hash fragment

2 participants