diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..8f4e24c72a0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,171 @@ +# AGENTS.md + +## Project overview + +TanStack Router is a type-safe router with built-in caching and URL state management for React and Solid applications. This monorepo contains two main products: + +- **TanStack Router** - Core routing library with type-safe navigation, search params, and path params +- **TanStack Start** - Full-stack framework built on top of TanStack Router + +## Setup commands + +- Install deps: `pnpm install` +- Setup e2e testing: `pnpm exec playwright install` +- Build packages: `pnpm build` (affected) or `pnpm build:all` (force all) +- Start dev server: `pnpm dev` +- Run tests: `pnpm test` + +## Code style + +- TypeScript strict mode with extensive type safety +- Framework-agnostic core logic separated from React/Solid bindings +- Type-safe routing with search params and path params +- Use workspace protocol for internal dependencies (`workspace:*`) + +## Dev environment tips + +- This is a pnpm workspace monorepo with packages organized by functionality +- Nx provides caching, affected testing, targeting, and parallel execution for efficiency +- Use `npx nx show projects` to list all available packages +- Target specific packages: `npx nx run @tanstack/react-router:test:unit` +- Target multiple packages: `npx nx run-many --target=test:eslint --projects=@tanstack/history,@tanstack/router-core` +- Run affected tests only: `npx nx affected --target=test:unit` +- Exclude patterns: `npx nx run-many --target=test:unit --exclude="examples/**,e2e/**"` +- Navigate to examples and run `pnpm dev` to test changes: `cd examples/react/basic && pnpm dev` +- **Granular Vitest testing within packages:** + - Navigate first: `cd packages/react-router` + - Specific files: `npx vitest run tests/link.test.tsx tests/Scripts.test.tsx` + - Test patterns: `npx vitest run tests/ClientOnly.test.tsx -t "should render fallback"` + - Name patterns: `npx vitest run -t "navigation"` (all tests with "navigation" in name) + - Exclude patterns: `npx vitest run --exclude="**/*link*" tests/` + - List tests: `npx vitest list tests/link.test.tsx` or `npx vitest list` (all) + - Through nx: `npx nx run @tanstack/react-router:test:unit -- tests/ClientOnly.test.tsx` +- **Available test targets per package:** `test:unit`, `test:types`, `test:eslint`, `test:build`, `test:perf`, `build` +- **Testing strategy:** Package level (nx) → File level (vitest) → Test level (-t flag) → Pattern level (exclude) + +## Testing instructions + +- **Critical**: Always run unit and type tests during development - do not proceed if they fail +- **Test types:** `pnpm test:unit`, `pnpm test:types`, `pnpm test:eslint`, `pnpm test:e2e`, `pnpm test:build` +- **Full CI suite:** `pnpm test:ci` +- **Fix formatting:** `pnpm prettier:write` +- **Efficient targeted testing workflow:** + 1. **Affected only:** `npx nx affected --target=test:unit` (compares to main branch) + 2. **Specific packages:** `npx nx run @tanstack/react-router:test:unit` + 3. **Specific files:** `cd packages/react-router && npx vitest run tests/link.test.tsx` + 4. **Specific patterns:** `npx vitest run tests/link.test.tsx -t "preloading"` +- **Pro tips:** + - Use `npx vitest list` to explore available tests before running + - Use `-t "pattern"` to focus on specific functionality during development + - Use `--exclude` patterns to skip unrelated tests + - Combine nx package targeting with vitest file targeting for maximum precision +- **Example workflow:** `npx nx run @tanstack/react-router:test:unit` → `cd packages/react-router && npx vitest run tests/link.test.tsx` → `npx vitest run tests/link.test.tsx -t "preloading"` + +## PR instructions + +- Always run `pnpm test:eslint`, `pnpm test:types`, and `pnpm test:unit` before committing +- Test changes in relevant example apps: `cd examples/react/basic && pnpm dev` +- Update corresponding documentation in `docs/` directory when adding features +- Add or update tests for any code changes +- Use internal docs links relative to `docs/` folder (e.g., `./guide/data-loading`) + +## Package structure + +**Core packages:** + +- `packages/router-core/` - Framework-agnostic core router logic +- `packages/react-router/`, `packages/solid-router/` - React/Solid bindings and components +- `packages/history/` - Browser history management + +**Tooling:** + +- `packages/router-cli/` - CLI tools for code generation +- `packages/router-generator/` - Route generation utilities +- `packages/router-plugin/` - Universal bundler plugins (Vite, Webpack, ESBuild, Rspack) +- `packages/virtual-file-routes/` - Virtual file routing system + +**Developer experience:** + +- `packages/router-devtools/`, `packages/*-router-devtools/` - Development tools +- `packages/eslint-plugin-router/` - ESLint rules for router + +**Validation adapters:** + +- `packages/zod-adapter/`, `packages/valibot-adapter/`, `packages/arktype-adapter/` + +**Start framework:** + +- `packages/*-start/`, `packages/start-*/` - Full-stack framework packages + +**Examples & testing:** + +- `examples/react/`, `examples/solid/` - Example applications (test changes here) +- `e2e/` - End-to-end tests (requires Playwright) +- `docs/router/`, `docs/start/` - Documentation with React/Solid subdirectories + +**Dependencies:** Uses workspace protocol (`workspace:*`) - core → framework → start packages + +## Common development tasks + +**Adding new routes:** + +- Use file-based routing in `src/routes/` directories +- Or use code-based routing with route definitions +- Run route generation with CLI tools + +**Testing changes:** + +- Build packages: `pnpm build` or `pnpm dev` (watch mode) +- Run example apps to test functionality +- Use devtools for debugging router state + +**Documentation updates:** + +- Update relevant docs in `docs/` directory +- Ensure examples reflect documentation changes +- Test documentation links and references +- Use relative links to `docs/` folder format + +## Framework-specific notes + +**React:** + +- Uses React Router components and hooks +- Supports React Server Components (RSC) +- Examples include React Query integration +- Package: `@tanstack/react-router` + +**Solid:** + +- Uses Solid Router components and primitives +- Supports Solid Start for full-stack applications +- Examples include Solid Query integration +- Package: `@tanstack/solid-router` + +## Environment requirements + +- **Node.js** - Required for development +- **pnpm** - Package manager (required for workspace features) +- **Playwright** - Required for e2e tests (`pnpm exec playwright install`) + +## Key architecture patterns + +- **Type Safety**: Extensive TypeScript for type-safe routing +- **Framework Agnostic**: Core logic separated from framework bindings +- **Plugin Architecture**: Universal bundler plugins using unplugin +- **File-based Routing**: Support for both code-based and file-based routing +- **Search Params**: First-class support for type-safe search parameters + +## Development workflow + +1. **Setup**: `pnpm install` and `pnpm exec playwright install` +2. **Build**: `pnpm build:all` or `pnpm dev` for watch mode +3. **Test**: Make changes and run relevant tests (use nx for targeted testing) +4. **Examples**: Navigate to examples and run `pnpm dev` to test changes +5. **Quality**: Run `pnpm test:eslint`, `pnpm test:types`, `pnpm test:unit` before committing + +## References + +- **Documentation**: https://tanstack.com/router +- **GitHub**: https://github.com/TanStack/router +- **Discord Community**: https://discord.com/invite/WrRKjPJ diff --git a/AI.md b/AI.md deleted file mode 100644 index 5860ac0188b..00000000000 --- a/AI.md +++ /dev/null @@ -1,348 +0,0 @@ -# AI.md - -This file provides guidance to AI assistants when working with the TanStack Router codebase. - -## Project Overview - -TanStack Router is a type-safe router with built-in caching and URL state management for React and Solid applications. This monorepo contains two main products: - -- **TanStack Router** - Core routing library with type-safe navigation, search params, and path params -- **TanStack Start** - Full-stack framework built on top of TanStack Router - -## Repository Structure - -### Core Packages - -**Router Core:** - -- `router-core/` - Framework-agnostic core router logic -- `react-router/` - React bindings and components -- `solid-router/` - Solid bindings and components -- `history/` - Browser history management - -**Tooling:** - -- `router-cli/` - CLI tools for code generation -- `router-generator/` - Route generation utilities -- `router-plugin/` - Universal bundler plugins (Vite, Webpack, ESBuild, Rspack) -- `router-vite-plugin/` - Vite-specific plugin wrapper -- `virtual-file-routes/` - Virtual file routing system - -**Developer Experience:** - -- `router-devtools/` - Router development tools -- `router-devtools-core/` - Core devtools functionality -- `react-router-devtools/` - React-specific devtools -- `solid-router-devtools/` - Solid-specific devtools -- `eslint-plugin-router/` - ESLint rules for router - -**Adapters:** - -- `zod-adapter/` - Zod validation adapter -- `valibot-adapter/` - Valibot validation adapter -- `arktype-adapter/` - ArkType validation adapter - -**Start Framework:** - -- `start/` - Core start framework -- `react-start/` - React Start framework -- `solid-start/` - Solid Start framework -- `start-*` packages - Various start framework utilities - -### Documentation - -Documentation is organized in `docs/`: - -- `docs/router/` - Router-specific documentation -- `docs/start/` - Start framework documentation -- Each has `framework/react/` and `framework/solid/` subdirectories - -### Examples - -Extensive examples in `examples/`: - -- `examples/react/` - React router examples -- `examples/solid/` - Solid router examples -- Examples range from basic usage to complex applications - -### Testing - -- `e2e/` - End-to-end tests organized by framework -- Individual packages have `tests/` directories -- Uses Playwright for e2e testing - -## Essential Commands - -### Development - -```bash -# Install dependencies -pnpm install - -# Build all packages (affected only) -pnpm build - -# Build all packages (force all) -pnpm build:all - -# Development mode with watch -pnpm dev - -# Run all tests -pnpm test - -# Run tests in CI mode -pnpm test:ci -``` - -### Testing - -```bash -# Run unit tests -pnpm test:unit - -# Run e2e tests -pnpm test:e2e - -# Run type checking -pnpm test:types - -# Run linting -pnpm test:eslint - -# Run formatting check -pnpm test:format - -# Fix formatting -pnpm prettier:write -``` - -### Targeted Testing with Nx - -```bash -# Target specific package -npx nx run @tanstack/react-router:test:unit -npx nx run @tanstack/router-core:test:types -npx nx run @tanstack/history:test:eslint - -# Target multiple packages -npx nx run-many --target=test:eslint --projects=@tanstack/history,@tanstack/router-core - -# Run affected tests only (compares to main branch) -npx nx affected --target=test:unit - -# Exclude certain patterns -npx nx run-many --target=test:unit --exclude="examples/**,e2e/**" - -# List all available projects -npx nx show projects -``` - -### Granular Vitest Testing - -For even more precise test targeting within packages: - -```bash -# Navigate to package directory first -cd packages/react-router - -# Run specific test files -npx vitest run tests/link.test.tsx -npx vitest run tests/ClientOnly.test.tsx tests/Scripts.test.tsx - -# Run tests by name pattern -npx vitest run tests/ClientOnly.test.tsx -t "should render fallback" -npx vitest run -t "navigation" # Run all tests with "navigation" in name - -# Exclude test patterns -npx vitest run --exclude="**/*link*" tests/ - -# List available tests -npx vitest list tests/link.test.tsx -npx vitest list # List all tests in package - -# Through nx (passes args to vitest) -npx nx run @tanstack/react-router:test:unit -- tests/ClientOnly.test.tsx -npx nx run @tanstack/react-router:test:unit -- tests/link.test.tsx tests/Scripts.test.tsx -``` - -### Example Development - -```bash -# Navigate to an example -cd examples/react/basic - -# Run the example -pnpm dev -``` - -## Development Workflow - -1. **Setup**: `pnpm install` and `pnpm exec playwright install` -2. **Build**: `pnpm build:all` or `pnpm dev` for watch mode -3. **Test**: Make changes and run relevant tests (use nx for targeted testing) -4. **Examples**: Navigate to examples and run `pnpm dev` to test changes - -### Nx-Powered Development - -This repository uses Nx for efficient task execution: - -- **Caching**: Nx caches task results - repeated commands are faster -- **Affected**: Only runs tasks for changed code (`nx affected`) -- **Targeting**: Run tasks for specific packages or combinations -- **Parallel Execution**: Multiple tasks run in parallel automatically -- **Dependency Management**: Nx handles build order and dependencies - -## Code Organization - -### Monorepo Structure - -This is a pnpm workspace with packages organized by functionality: - -- Core packages provide the fundamental router logic -- Framework packages provide React/Solid bindings -- Tool packages provide development utilities -- Start packages provide full-stack framework capabilities - -### Key Patterns - -- **Type Safety**: Extensive use of TypeScript for type-safe routing -- **Framework Agnostic**: Core logic separated from framework bindings -- **Plugin Architecture**: Universal bundler plugins using unplugin -- **File-based Routing**: Support for both code-based and file-based routing -- **Search Params**: First-class support for type-safe search parameters - -## Documentation Guidelines - -- **Internal Links**: Always write relative to `docs/` folder (e.g., `./guide/data-loading`) -- **Examples**: Each major feature should have corresponding examples -- **Type Safety**: Document TypeScript patterns and type inference -- **Framework Specific**: Separate docs for React and Solid when needed - -## Critical Quality Checks - -**During prompt-driven development, always run unit and type tests to ensure validity. If either of these fail, do not stop or proceed (unless you have repeatedly failed and need human intervention).** - -**You can run these (or the ones you are working on) after each big change:** - -```bash -pnpm test:eslint # Linting -pnpm test:types # TypeScript compilation -pnpm test:unit # Unit tests -pnpm test:build # Build verification -``` - -**For comprehensive testing:** - -```bash -pnpm test:ci # Full CI test suite -pnpm test:e2e # End-to-end tests -``` - -**For targeted testing (recommended for efficiency):** - -```bash -# Test only affected packages -npx nx affected --target=test:unit -npx nx affected --target=test:types -npx nx affected --target=test:eslint - -# Test specific packages you're working on -npx nx run @tanstack/react-router:test:unit -npx nx run @tanstack/router-core:test:types - -# Test specific files/functionality you're working on -cd packages/react-router -npx vitest run tests/link.test.tsx -t "preloading" -npx vitest run tests/useNavigate.test.tsx tests/useParams.test.tsx -``` - -**Pro Tips:** - -- Use `npx vitest list` to explore available tests before running -- Use `-t "pattern"` to focus on specific functionality during development -- Use `--exclude` patterns to skip unrelated tests -- Combine nx package targeting with vitest file targeting for maximum precision - -## Package Dependencies - -The monorepo uses workspace dependencies extensively: - -- Core packages are dependencies of framework packages -- Framework packages are dependencies of start packages -- All packages use workspace protocol (`workspace:*`) - -## Environment Setup - -- **Node.js**: Required for development -- **pnpm**: Package manager (required for workspace features) -- **Playwright**: Required for e2e tests (`pnpm exec playwright install`) - -## Common Tasks - -### Adding New Routes - -- Use file-based routing in `src/routes/` directories -- Or use code-based routing with route definitions -- Run route generation with CLI tools - -### Testing Changes - -- Build packages: `pnpm build` or `pnpm dev` -- Run example apps to test functionality -- Use devtools for debugging router state - -**Available Test Targets per Package:** - -- `test:unit` - Unit tests with Vitest -- `test:types` - TypeScript compilation across multiple TS versions -- `test:eslint` - Linting with ESLint -- `test:build` - Build verification (publint + attw) -- `test:perf` - Performance benchmarks -- `build` - Package building - -**Granular Test Targeting Strategies:** - -1. **Package Level**: Use nx to target specific packages -2. **File Level**: Use vitest directly to target specific test files -3. **Test Level**: Use vitest `-t` flag to target specific test names -4. **Pattern Level**: Use vitest exclude patterns to skip certain tests - -Example workflow: - -```bash -# 1. Test specific package -npx nx run @tanstack/react-router:test:unit - -# 2. Test specific files within package -cd packages/react-router && npx vitest run tests/link.test.tsx - -# 3. Test specific functionality -npx vitest run tests/link.test.tsx -t "preloading" -``` - -### Documentation Updates - -- Update relevant docs in `docs/` directory -- Ensure examples reflect documentation -- Test documentation links and references - -## Framework-Specific Notes - -### React - -- Uses React Router components and hooks -- Supports React Server Components (RSC) -- Examples include React Query integration - -### Solid - -- Uses Solid Router components and primitives -- Supports Solid Start for full-stack applications -- Examples include Solid Query integration - -## References - -- Main Documentation: https://tanstack.com/router -- GitHub Repository: https://github.com/TanStack/router -- Discord Community: https://discord.com/invite/WrRKjPJ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5650aafc088..c28d01241bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,58 @@ - `pnpm dev` - Make changes to the code - If you ran `pnpm dev` the dev watcher will automatically rebuild the code that has changed -- Changes to the docs - - Be sure that internal links are written always relative to `docs` folder. - E.g. `./guide/data-loading` +- Editing the docs locally and previewing the changes + - The documentations for all the TanStack projects are hosted on [tanstack.com](https://tanstack.com), which is a TanStack Start application (https://github.com/TanStack/tanstack.com). You need to run this app locally to preview your changes in the `TanStack/router` docs. + +> [!NOTE] +> The website fetches the doc pages from GitHub in production, and searches for them at `../router/docs` in development. Your local clone of `TanStack/router` needs to be in the same directory as the local clone of `TanStack/tanstack.com`. + +You can follow these steps to set up the docs for local development: + +1. Make a new directory called `tanstack`. + +```sh +mkdir tanstack +``` + +2. Enter that directory and clone the [`TanStack/router`](https://github.com/TanStack/router) and [`TanStack/tanstack.com`](https://github.com/TanStack/tanstack.com) repos. + +```sh +cd tanstack +git clone git@github.com:TanStack/router.git +# We probably don't need all the branches and commit history +# from the `tanstack.com` repo, so let's just create a shallow +# clone of the latest version of the `main` branch. +# Read more about shallow clones here: +# https://github.blog/2020-12-21-get-up-to-speed-with-partial-clone-and-shallow-clone/#user-content-shallow-clones +git clone git@github.com:TanStack/tanstack.com.git --depth=1 --single-branch --branch=main +``` + +> [!NOTE] +> Your `tanstack` directory should look like this: +> +> ``` +> tanstack/ +> | +> +-- router/ (<-- this directory cannot be called anything else!) +> | +> +-- tanstack.com/ +> ``` + +3. Enter the `tanstack/tanstack.com` directory, install the dependencies and run the app in dev mode: + +```sh +cd tanstack.com +pnpm i +# The app will run on https://localhost:3000 by default +pnpm dev +``` + +4. Now you can visit http://localhost:3000/router/latest/docs/framework/react/overview in the browser and see the changes you make in `tanstack/router/docs` there. + +> [!WARNING] +> You will need to update the `docs/(router or start)config.json` file (in `TanStack/router`) if you add a new documentation page! + +You can see the whole process in the screen capture below: + +https://github.com/fulopkovacs/form/assets/43729152/9d35a3c3-8153-4e74-9cb2-af275f7a269b diff --git a/docs/router/framework/react/guide/external-data-loading.md b/docs/router/framework/react/guide/external-data-loading.md index 2f7d632b77a..2bf31c19037 100644 --- a/docs/router/framework/react/guide/external-data-loading.md +++ b/docs/router/framework/react/guide/external-data-loading.md @@ -34,7 +34,7 @@ Literally any library that **can return a promise and read/write data** can be i ## Using Loaders to ensure data is loaded -The easiest way to use integrate and external caching/data library into Router is to use `route.loader`s to ensure that the data required inside of a route has been loaded and is ready to be displayed. +The easiest way to integrate external caching/data library into Router is to use `route.loader`s to ensure that the data required inside of a route has been loaded and is ready to be displayed. > ⚠️ BUT WHY? It's very important to preload your critical render data in the loader for a few reasons: > @@ -102,7 +102,7 @@ export const Route = createFileRoute('/posts')({ ### Error handling with TanStack Query -When an error occurs while using `suspense` with `TanStack Query`, you'll need to let queries know that you want to try again when re-rendering. This can be done by using the `reset` function provided by the `useQueryErrorResetBoundary` hook. We can invoke this function in an effect as soon as the error component mounts. This will make sure that the query is reset and will try to fetch data again when the route component is rendered again. This will also cover cases where users navigate away from our route instead of clicking the `retry` button. +When an error occurs while using `suspense` with `TanStack Query`, you need to let queries know that you want to try again when re-rendering. This can be done by using the `reset` function provided by the `useQueryErrorResetBoundary` hook. You can invoke this function in an effect as soon as the error component mounts. This will make sure that the query is reset and will try to fetch data again when the route component is rendered again. This will also cover cases where users navigate away from the route instead of clicking the `retry` button. ```tsx export const Route = createFileRoute('/')({ diff --git a/docs/router/framework/react/guide/navigation.md b/docs/router/framework/react/guide/navigation.md index 26b8a02ce3f..3305a61bf3c 100644 --- a/docs/router/framework/react/guide/navigation.md +++ b/docs/router/framework/react/guide/navigation.md @@ -28,7 +28,7 @@ type ToOptions< TTo extends string = '', > = { // `from` is an optional route ID or path. If it is not supplied, only absolute paths will be auto-completed and type-safe. It's common to supply the route.fullPath of the origin route you are rendering from for convenience. If you don't know the origin route, leave this empty and work with absolute paths or unsafe relative paths. - from: string + from?: string // `to` can be an absolute route path or a relative path from the `from` option to a valid route path. ⚠️ Do not interpolate path params, hash or search params into the `to` options. Use the `params`, `search`, and `hash` options instead. to: string // `params` is either an object of path params to interpolate into the `to` option or a function that supplies the previous params and allows you to return new ones. This is the only way to interpolate dynamic parameters into the final URL. Depending on the `from` and `to` route, you may need to supply none, some or all of the path params. TypeScript will notify you of the required params if there are any. @@ -183,7 +183,7 @@ Keep in mind that normally dynamic segment params are `string` values, but they By default, all links are absolute unless a `from` route path is provided. This means that the above link will always navigate to the `/about` route regardless of what route you are currently on. -If you want to make a link that is relative to the current route, you can provide a `from` route path: +Relative links can be combined with a `from` route path. If a from route path isn't provided, relative paths default to the current active location. ```tsx const postIdRoute = createRoute({ @@ -201,9 +201,9 @@ As seen above, it's common to provide the `route.fullPath` as the `from` route p ### Special relative paths: `"."` and `".."` -Quite often you might want to reload the current location, for example, to rerun the loaders on the current and/or parent routes, or maybe there was a change in search parameters. This can be achieved by specifying a `to` route path of `"."` which will reload the current location. This is only applicable to the current location, and hence any `from` route path specified is ignored. +Quite often you might want to reload the current location or another `from` path, for example, to rerun the loaders on the current and/or parent routes, or maybe navigate back to a parent route. This can be achieved by specifying a `to` route path of `"."` which will reload the current location or provided `from` path. -Another common need is to navigate one route back relative to the current location or some other matched route in the current tree. By specifying a `to` route path of `".."` navigation will be resolved to either the first parent route preceding the current location or, if specified, preceding the `"from"` route path. +Another common need is to navigate one route back relative to the current location or another path. By specifying a `to` route path of `".."` navigation will be resolved to the first parent route preceding the current location. ```tsx export const Route = createFileRoute('/posts/$postId')({ @@ -214,7 +214,14 @@ function PostComponent() { return (
Reload the current route of /posts/$postId - Navigate to /posts + Navigate back to /posts + // the below are all equivalent + Navigate back to /posts + + Navigate back to /posts + + // the below are all equivalent + Navigate to root Navigate to root diff --git a/packages/history/src/index.ts b/packages/history/src/index.ts index 3178cb90bbf..1005bff5a56 100644 --- a/packages/history/src/index.ts +++ b/packages/history/src/index.ts @@ -45,6 +45,7 @@ export interface HistoryLocation extends ParsedPath { export interface ParsedPath { href: string + fullPath: string pathname: string search: string hash: string @@ -84,7 +85,7 @@ type TryNavigateArgs = { } & ( | { type: 'PUSH' | 'REPLACE' - path: string + href: string state: any } | { @@ -142,7 +143,7 @@ export function createHistory(opts: { actionInfo.type === 'PUSH' || actionInfo.type === 'REPLACE' if (typeof document !== 'undefined' && blockers.length && isPushOrReplace) { for (const blocker of blockers) { - const nextLocation = parseHref(actionInfo.path, actionInfo.state) + const nextLocation = parseHref(actionInfo.href, actionInfo.state) const isBlocked = await blocker.blockerFn({ currentLocation: location, nextLocation, @@ -173,31 +174,31 @@ export function createHistory(opts: { subscribers.delete(cb) } }, - push: (path, state, navigateOpts) => { + push: (href, state, navigateOpts) => { const currentIndex = location.state[stateIndexKey] state = assignKeyAndIndex(currentIndex + 1, state) tryNavigation({ task: () => { - opts.pushState(path, state) + opts.pushState(href, state) notify({ type: 'PUSH' }) }, navigateOpts, type: 'PUSH', - path, + href, state, }) }, - replace: (path, state, navigateOpts) => { + replace: (href, state, navigateOpts) => { const currentIndex = location.state[stateIndexKey] state = assignKeyAndIndex(currentIndex, state) tryNavigation({ task: () => { - opts.replaceState(path, state) + opts.replaceState(href, state) notify({ type: 'REPLACE' }) }, navigateOpts, type: 'REPLACE', - path, + href, state, }) }, @@ -295,14 +296,10 @@ export function createBrowserHistory(opts?: { const _setBlockers = (newBlockers: Array) => (blockers = newBlockers) - const createHref = opts?.createHref ?? ((path) => path) + const createHref = opts?.createHref ?? ((href) => href) const parseLocation = opts?.parseLocation ?? - (() => - parseHref( - `${win.location.pathname}${win.location.search}${win.location.hash}`, - win.history.state, - )) + (() => parseHref(win.location.href, win.history.state)) // Ensure there is always a key to start if (!win.history.state?.__TSR_key && !win.history.state?.key) { @@ -355,7 +352,7 @@ export function createBrowserHistory(opts?: { ;(next.isPush ? win.history.pushState : win.history.replaceState)( next.state, '', - next.href, + next.href.replace(new URL(next.href).origin, ''), ) // Stop ignoring subscriber updates @@ -561,7 +558,10 @@ export function createHashHistory(opts?: { window?: any }): RouterHistory { return parseHref(hashHref, win.history.state) }, createHref: (href) => - `${win.location.pathname}${win.location.search}#${href}`, + new URL( + `${win.location.pathname}${win.location.search}#${href}`, + win.location.origin, + ).toString(), }) } @@ -617,13 +617,14 @@ export function parseHref( href: string, state: ParsedHistoryState | undefined, ): HistoryLocation { + href = withOrigin(href, 'http://localhost') const hashIndex = href.indexOf('#') const searchIndex = href.indexOf('?') - const addedKey = createRandomKey() return { href, + fullPath: href.replace(new URL(href).origin, ''), pathname: href.substring( 0, hashIndex > 0 @@ -643,6 +644,14 @@ export function parseHref( } } +export function withOrigin(href: string, fallbackOrigin: string) { + try { + return new URL(href).href + } catch { + return new URL(href, fallbackOrigin).href + } +} + // Thanks co-pilot! function createRandomKey() { return (Math.random() + 1).toString(36).substring(7) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 117299d4b0f..3956dc47559 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -167,7 +167,7 @@ function OnRendered() { if ( el && (prevLocationRef.current === undefined || - prevLocationRef.current.href !== router.latestLocation.href) + prevLocationRef.current.fullPath !== router.latestLocation.fullPath) ) { router.emit({ type: 'onRendered', diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx index 04e140acfcd..4c00521a2d7 100644 --- a/packages/react-router/src/Transitioner.tsx +++ b/packages/react-router/src/Transitioner.tsx @@ -53,8 +53,8 @@ export function Transitioner() { }) if ( - trimPathRight(router.latestLocation.href) !== - trimPathRight(nextLocation.href) + trimPathRight(router.latestLocation.fullPath) !== + trimPathRight(nextLocation.fullPath) ) { router.commitLocation({ ...nextLocation, replace: true }) } diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index d7b1c996db2..ce814972c35 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -11,7 +11,6 @@ export { parsePathname, interpolatePath, matchPathname, - removeBasepath, matchByPath, rootRouteId, defaultSerializeError, @@ -351,3 +350,4 @@ export { Asset } from './Asset' export { HeadContent } from './HeadContent' export { Scripts } from './Scripts' export type * from './ssr/serializer' +export { rewriteBasepath } from '@tanstack/router-core' diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index c72d0e85678..e9025a35fda 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -7,12 +7,12 @@ import { preloadWarning, removeTrailingSlash, } from '@tanstack/router-core' +import { useActiveLocation } from './useActiveLocation' import { useRouterState } from './useRouterState' import { useRouter } from './useRouter' import { useForwardedRef, useIntersectionObserver } from './utils' -import { useMatch } from './useMatch' import type { AnyRouter, Constrain, @@ -99,19 +99,27 @@ export function useLinkProps< structuralSharing: true as any, }) - const from = useMatch({ - strict: false, - select: (match) => options.from ?? match.fullPath, + // subscribe to location here to re-build fromPath if it changes + const routerLocation = useRouterState({ + select: (s) => s.location, + structuralSharing: true as any, }) - const next = React.useMemo( - () => router.buildLocation({ ...options, from } as any), + const { getFromPath } = useActiveLocation() + + const from = getFromPath(options.from) + + const _options = React.useMemo( + () => { + return { ...options, from } + }, // eslint-disable-next-line react-hooks/exhaustive-deps [ router, + routerLocation, currentSearch, - options._fromLocation, from, + options._fromLocation, options.hash, options.to, options.search, @@ -122,6 +130,11 @@ export function useLinkProps< ], ) + const next = React.useMemo( + () => router.buildLocation({ ..._options } as any), + [router, _options], + ) + const isExternal = type === 'external' const preload = @@ -180,34 +193,12 @@ export function useLinkProps< }, }) - const doPreload = React.useCallback( - () => { - router.preloadRoute({ ...options, from } as any).catch((err) => { - console.warn(err) - console.warn(preloadWarning) - }) - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - router, - options.to, - options._fromLocation, - from, - options.search, - options.hash, - options.params, - options.state, - options.mask, - options.unsafeRelative, - options.hashScrollIntoView, - options.href, - options.ignoreBlocker, - options.reloadDocument, - options.replace, - options.resetScroll, - options.viewTransition, - ], - ) + const doPreload = React.useCallback(() => { + router.preloadRoute({ ..._options } as any).catch((err) => { + console.warn(err) + console.warn(preloadWarning) + }) + }, [router, _options]) const preloadViewportIoCallback = React.useCallback( (entry: IntersectionObserverEntry | undefined) => { @@ -235,25 +226,6 @@ export function useLinkProps< } }, [disabled, doPreload, preload]) - if (isExternal) { - return { - ...propsSafeToSpread, - ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], - type, - href: to, - ...(children && { children }), - ...(target && { target }), - ...(disabled && { disabled }), - ...(style && { style }), - ...(className && { className }), - ...(onClick && { onClick }), - ...(onFocus && { onFocus }), - ...(onMouseEnter && { onMouseEnter }), - ...(onMouseLeave && { onMouseLeave }), - ...(onTouchStart && { onTouchStart }), - } - } - // The click handler const handleClick = (e: React.MouseEvent) => { if ( @@ -277,8 +249,7 @@ export function useLinkProps< // All is well? Navigate! // N.B. we don't call `router.commitLocation(next) here because we want to run `validateSearch` before committing router.navigate({ - ...options, - from, + ..._options, replace, resetScroll, hashScrollIntoView, @@ -289,6 +260,25 @@ export function useLinkProps< } } + if (isExternal) { + return { + ...propsSafeToSpread, + ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], + type, + href: to, + ...(children && { children }), + ...(target && { target }), + ...(disabled && { disabled }), + ...(style && { style }), + ...(className && { className }), + ...(onClick && { onClick }), + ...(onFocus && { onFocus }), + ...(onMouseEnter && { onMouseEnter }), + ...(onMouseLeave && { onMouseLeave }), + ...(onTouchStart && { onTouchStart }), + } + } + // The click handler const handleFocus = (_: React.MouseEvent) => { if (disabled) return @@ -361,8 +351,8 @@ export function useLinkProps< href: disabled ? undefined : next.maskedLocation - ? router.history.createHref(next.maskedLocation.href) - : router.history.createHref(next.href), + ? next.maskedLocation.publicHref + : next.publicHref, ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], onClick: composeHandlers([onClick, handleClick]), onFocus: composeHandlers([onFocus, handleFocus]), diff --git a/packages/react-router/src/useActiveLocation.ts b/packages/react-router/src/useActiveLocation.ts new file mode 100644 index 00000000000..c7220fdaca0 --- /dev/null +++ b/packages/react-router/src/useActiveLocation.ts @@ -0,0 +1,57 @@ +import { last } from '@tanstack/router-core' +import { useCallback, useEffect, useState } from 'react' +import { useRouter } from './useRouter' +import { useMatch } from './useMatch' +import { useRouterState } from './useRouterState' +import type { ParsedLocation } from '@tanstack/router-core' + +export type UseActiveLocationResult = { + activeLocation: ParsedLocation + getFromPath: (from?: string) => string + setActiveLocation: (location?: ParsedLocation) => void +} + +export const useActiveLocation = ( + location?: ParsedLocation, +): UseActiveLocationResult => { + const router = useRouter() + const routerLocation = useRouterState({ select: (state) => state.location }) + const [activeLocation, setActiveLocation] = useState( + location ?? routerLocation, + ) + const [customActiveLocation, setCustomActiveLocation] = useState< + ParsedLocation | undefined + >(location) + + useEffect(() => { + setActiveLocation(customActiveLocation ?? routerLocation) + }, [routerLocation, customActiveLocation]) + + const matchIndex = useMatch({ + strict: false, + select: (match) => match.index, + }) + + const getFromPath = useCallback( + (from?: string) => { + const activeLocationMatches = router.matchRoutes(activeLocation, { + _buildLocation: false, + }) + + const activeLocationMatch = last(activeLocationMatches) + + return ( + from ?? + activeLocationMatch?.fullPath ?? + router.state.matches[matchIndex]!.fullPath + ) + }, + [activeLocation, matchIndex, router], + ) + + return { + activeLocation, + getFromPath, + setActiveLocation: setCustomActiveLocation, + } +} diff --git a/packages/react-router/src/useNavigate.tsx b/packages/react-router/src/useNavigate.tsx index 1fcef979673..c12cffb9c93 100644 --- a/packages/react-router/src/useNavigate.tsx +++ b/packages/react-router/src/useNavigate.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { useRouter } from './useRouter' -import { useMatch } from './useMatch' +import { useActiveLocation } from './useActiveLocation' import type { AnyRouter, FromPathOption, @@ -15,29 +15,21 @@ export function useNavigate< >(_defaultOpts?: { from?: FromPathOption }): UseNavigateResult { - const { navigate, state } = useRouter() + const router = useRouter() - // Just get the index of the current match to avoid rerenders - // as much as possible - const matchIndex = useMatch({ - strict: false, - select: (match) => match.index, - }) + const { getFromPath, activeLocation } = useActiveLocation() return React.useCallback( (options: NavigateOptions) => { - const from = - options.from ?? - _defaultOpts?.from ?? - state.matches[matchIndex]!.fullPath + const from = getFromPath(options.from ?? _defaultOpts?.from) - return navigate({ + return router.navigate({ ...options, from, }) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [_defaultOpts?.from, navigate], + [_defaultOpts?.from, router, getFromPath, activeLocation], ) as UseNavigateResult } diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 80578b6f4a3..cb6124db79e 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -24,6 +24,7 @@ import { createRoute, createRouteMask, createRouter, + getRouteApi, redirect, retainSearchParams, stripSearchParams, @@ -5073,7 +5074,9 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param Route

- Link to ./a + + Link to ./a + Link to c @@ -5093,7 +5096,12 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param A Route

- Link to .. from /param/foo/a + + Link to .. from /param/foo/a + + + Link to .. from current active route + ) @@ -5280,6 +5288,27 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( expect(window.location.pathname).toBe(`${basepath}/param/foo`) }) + test('should navigate to a parent link based on active location', async () => { + const router = setupRouter() + + render() + + await act(async () => { + history.push(`${basepath}/param/foo/a/b`) + }) + + const relativeLink = await screen.findByTestId('link-to-previous') + + expect(relativeLink.getAttribute('href')).toBe(`${basepath}/param/foo/a`) + + // Click the link and ensure the new location + await act(async () => { + fireEvent.click(relativeLink) + }) + + expect(window.location.pathname).toBe(`${basepath}/param/foo/a`) + }) + test('should navigate to a child link based on pathname', async () => { const router = setupRouter() @@ -5407,3 +5436,712 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( }) }, ) + +describe('relative links to current route', () => { + test.each([true, false])( + 'should navigate to current route when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> + + Post + + + Search + + + Search2 + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () => ( + <> +
Post
+ + ), + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render() + + const postButton = await screen.findByTestId('posts-link') + const searchButton = await screen.findByTestId('search-link') + const searchButton2 = await screen.findByTestId('search2-link') + + await act(() => fireEvent.click(postButton)) + + expect(window.location.pathname).toBe(`/post${tail}`) + + await act(() => fireEvent.click(searchButton)) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + + await act(() => fireEvent.click(searchButton2)) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value2' }) + }, + ) + + test.each([true, false])( + 'should navigate to current route with search params when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> + + Post + + + Search + + + Search2 + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render() + + const postButton = await screen.findByTestId('posts-link') + + await act(() => fireEvent.click(postButton)) + + expect(window.location.pathname).toBe(`/post${tail}`) + + const searchButton = await screen.findByTestId('search-link') + + await act(() => fireEvent.click(searchButton)) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + + const searchButton2 = await screen.findByTestId('search2-link') + + await act(() => fireEvent.click(searchButton2)) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value2' }) + }, + ) + + test.each([true, false])( + 'should navigate to current route with changing path params when using "." in nested route structure with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> +

Index

+ + Posts + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + To first post + + + To second post + + + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + + Params: {params.postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([postsRoute.addChildren([postRoute])]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render() + + const postsButton = await screen.findByTestId('posts-link') + + await act(() => fireEvent.click(postsButton)) + + expect( + await screen.findByTestId('posts-index-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) + + const firstPostButton = await screen.findByTestId('first-post-link') + + await act(() => fireEvent.click(firstPostButton)) + + expect(await screen.findByTestId('post-id1')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/id1${tail}`) + + const secondPostButton = await screen.findByTestId('second-post-link') + + await act(() => fireEvent.click(secondPostButton)) + + expect(await screen.findByTestId('post-id2')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/id2${tail}`) + }, + ) +}) + +describe('relative links to from route', () => { + test.each([true, false])( + 'should navigate to from route when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> + + Post + + + Search + + + Go To Home + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render() + + const postButton = await screen.findByTestId('posts-link') + + await act(() => fireEvent.click(postButton)) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + + const searchButton = await screen.findByTestId('search-link') + + await act(() => fireEvent.click(searchButton)) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + + const homeBtn = await screen.findByTestId('home-link') + + await act(() => fireEvent.click(homeBtn)) + + expect(router.state.location.pathname).toBe(`/`) + expect(router.state.location.search).toEqual({}) + }, + ) + + test.each([true, false])( + 'should navigate to from route with path params when using "." in nested route structure with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> +

Index

+ + Posts + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + To first post + + + To second post + + + To posts list + + + + ) + } + + const PostDetailComponent = () => { + return ( + <> +

Post Detail

+ + To post info + + + To post notes + + + To index detail options + + + + ) + } + + const PostInfoComponent = () => { + return ( + <> +

Post Info

+ + ) + } + + const PostNotesComponent = () => { + return ( + <> +

Post Notes

+ + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const postDetailRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostDetailComponent, + }) + + const postInfoRoute = createRoute({ + getParentRoute: () => postDetailRoute, + path: 'info', + component: PostInfoComponent, + }) + + const postNotesRoute = createRoute({ + getParentRoute: () => postDetailRoute, + path: 'notes', + component: PostNotesComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postDetailRoute.addChildren([postInfoRoute, postNotesRoute]), + ]), + ]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render() + + const postsButton = await screen.findByTestId('posts-link') + + fireEvent.click(postsButton) + + expect( + await screen.findByTestId('posts-index-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) + + const firstPostButton = await screen.findByTestId('first-post-link') + + fireEvent.click(firstPostButton) + + expect( + await screen.findByTestId('post-detail-index-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1${tail}`) + + const postInfoButton = await screen.findByTestId('post-info-link') + + fireEvent.click(postInfoButton) + + expect(await screen.findByTestId('post-info-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1/info${tail}`) + + const toPostDetailIndexButton = await screen.findByTestId( + 'to-post-detail-index-link', + ) + + fireEvent.click(toPostDetailIndexButton) + + expect( + await screen.findByTestId('post-detail-index-heading'), + ).toBeInTheDocument() + expect(screen.queryByTestId('post-info-heading')).not.toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1${tail}`) + + const postNotesButton = await screen.findByTestId('post-notes-link') + + fireEvent.click(postNotesButton) + + expect( + await screen.findByTestId('post-notes-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1/notes${tail}`) + + const toPostsIndexButton = await screen.findByTestId( + 'to-posts-index-link', + ) + + fireEvent.click(toPostsIndexButton) + + expect( + await screen.findByTestId('posts-index-heading'), + ).toBeInTheDocument() + expect(screen.queryByTestId('post-notes-heading')).not.toBeInTheDocument() + expect( + screen.queryByTestId('post-detail-index-heading'), + ).not.toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) + + const secondPostButton = await screen.findByTestId('second-post-link') + + fireEvent.click(secondPostButton) + + expect( + await screen.findByTestId('post-detail-index-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/2${tail}`) + }, + ) +}) + +describe('when on /posts/$postId and navigating to ../ with default `from` /posts', () => { + async function runTest(navigateVia: 'Route' | 'RouteApi') { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> +

Index

+ + Posts + + + To first post + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + const LinkViaRoute = () => ( + + To Home + + ) + + const LinkViaRouteApi = () => { + const RouteApiLink = getRouteApi('/_layout/posts').Link + return ( + + To Home + + ) + } + + return ( + <> +

Posts

+ {navigateVia === 'Route' ? : } + + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params.postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const PostIndexComponent = () => { + return ( + <> +

Post Index

+ + ) + } + + const postIndexRoute = createRoute({ + getParentRoute: () => postRoute, + path: '/', + component: PostIndexComponent, + }) + + const DetailsComponent = () => { + return ( + <> +

Details!

+ + ) + } + + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postRoute.addChildren([postIndexRoute, detailsRoute]), + ]), + ]), + ]), + }) + + render() + + const postsButton = await screen.findByTestId('index-to-first-post-link') + + fireEvent.click(postsButton) + + expect(await screen.findByTestId('details-heading')).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/details') + + const homeButton = await screen.findByTestId('link-to-home') + + fireEvent.click(homeButton) + + expect(await screen.findByTestId('index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual('/') + } + + test('Route', () => runTest('Route')) + test('RouteApi', () => runTest('RouteApi')) +}) diff --git a/packages/react-router/tests/redirect.test.tsx b/packages/react-router/tests/redirect.test.tsx index 3009c9ddd1e..9ea86f402a3 100644 --- a/packages/react-router/tests/redirect.test.tsx +++ b/packages/react-router/tests/redirect.test.tsx @@ -357,6 +357,7 @@ describe('redirect', () => { expect(currentRedirect.headers.get('Location')).toEqual('/about') expect(currentRedirect.options).toEqual({ _fromLocation: { + fullPath: '/', hash: '', href: '/', pathname: '/', @@ -367,6 +368,7 @@ describe('redirect', () => { __TSR_key: currentRedirect.options._fromLocation!.state.__TSR_key, key: currentRedirect.options._fromLocation!.state.key, }, + url: new URL('http://localhost/'), }, href: '/about', to: '/about', diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index ef8bc2e7734..378c0d61a57 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -326,57 +326,57 @@ function createTestRouter( } describe('encoding: URL param segment for /posts/$slug', () => { - it('state.location.pathname, should have the params.slug value of "tanner"', async () => { + it('state.location.url.pathname, should have the params.slug value of "tanner"', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/posts/tanner'] }), }) await act(() => router.load()) - expect(router.state.location.pathname).toBe('/posts/tanner') + expect(router.state.location.url.pathname).toBe('/posts/tanner') }) - it('state.location.pathname, should have the params.slug value of "🚀"', async () => { + it('state.location.url.pathname, should have the params.slug value of "🚀"', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/posts/🚀'] }), }) await act(() => router.load()) - expect(router.state.location.pathname).toBe('/posts/🚀') + expect(router.state.location.url.pathname).toBe('/posts/%F0%9F%9A%80') }) - it('state.location.pathname, should have the params.slug value of "100%25"', async () => { + it('state.location.url.pathname, should have the params.slug value of "100%25"', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/posts/100%25'] }), }) await act(() => router.load()) - expect(router.state.location.pathname).toBe('/posts/100%25') + expect(router.state.location.url.pathname).toBe('/posts/100%25') }) - it('state.location.pathname, should have the params.slug value of "100%26"', async () => { + it('state.location.url.pathname, should have the params.slug value of "100%26"', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/posts/100%26'] }), }) await act(() => router.load()) - expect(router.state.location.pathname).toBe('/posts/100%26') + expect(router.state.location.url.pathname).toBe('/posts/100%26') }) - it('state.location.pathname, should have the params.slug value of "%F0%9F%9A%80"', async () => { + it('state.location.url.pathname, should have the params.slug value of "%F0%9F%9A%80"', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/posts/%F0%9F%9A%80'] }), }) await act(() => router.load()) - expect(router.state.location.pathname).toBe('/posts/%F0%9F%9A%80') + expect(router.state.location.url.pathname).toBe('/posts/%F0%9F%9A%80') }) - it('state.location.pathname, should have the params.slug value of "framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack"', async () => { + it('state.location.url.pathname, should have the params.slug value of "framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack"', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: [ @@ -387,7 +387,7 @@ describe('encoding: URL param segment for /posts/$slug', () => { await act(() => router.load()) - expect(router.state.location.pathname).toBe( + expect(router.state.location.url.pathname).toBe( '/posts/framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack', ) }) @@ -554,7 +554,7 @@ describe('encoding: URL param segment for /posts/$slug', () => { router.navigate({ to: '/posts/$slug', params: { slug: '@jane' } }), ) - expect(router.state.location.pathname).toBe('/posts/%40jane') + expect(router.state.location.url.pathname).toBe('/posts/%40jane') }) it('params.slug should be encoded in the final URL except characters in pathParamsAllowedCharacters', async () => { @@ -570,62 +570,62 @@ describe('encoding: URL param segment for /posts/$slug', () => { router.navigate({ to: '/posts/$slug', params: { slug: '@jane' } }), ) - expect(router.state.location.pathname).toBe('/posts/@jane') + expect(router.state.location.url.pathname).toBe('/posts/@jane') }) }) describe('encoding: URL splat segment for /$', () => { - it('state.location.pathname, should have the params._splat value of "tanner"', async () => { + it('state.location.url.pathname, should have the params._splat value of "tanner"', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/tanner'] }), }) await router.load() - expect(router.state.location.pathname).toBe('/tanner') + expect(router.state.location.url.pathname).toBe('/tanner') }) - it('state.location.pathname, should have the params._splat value of "🚀"', async () => { + it('state.location.url.pathname, should have the params._splat value of "🚀"', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/🚀'] }), }) await router.load() - expect(router.state.location.pathname).toBe('/🚀') + expect(router.state.location.url.pathname).toBe('/%F0%9F%9A%80') }) - it('state.location.pathname, should have the params._splat value of "100%25"', async () => { + it('state.location.url.pathname, should have the params._splat value of "100%25"', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/100%25'] }), }) await router.load() - expect(router.state.location.pathname).toBe('/100%25') + expect(router.state.location.url.pathname).toBe('/100%25') }) - it('state.location.pathname, should have the params._splat value of "100%26"', async () => { + it('state.location.url.pathname, should have the params._splat value of "100%26"', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/100%26'] }), }) await router.load() - expect(router.state.location.pathname).toBe('/100%26') + expect(router.state.location.url.pathname).toBe('/100%26') }) - it('state.location.pathname, should have the params._splat value of "%F0%9F%9A%80"', async () => { + it('state.location.url.pathname, should have the params._splat value of "%F0%9F%9A%80"', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/%F0%9F%9A%80'] }), }) await router.load() - expect(router.state.location.pathname).toBe('/%F0%9F%9A%80') + expect(router.state.location.url.pathname).toBe('/%F0%9F%9A%80') }) - it('state.location.pathname, should have the params._splat value of "framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack"', async () => { + it('state.location.url.pathname, should have the params._splat value of "framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack"', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: [ @@ -636,12 +636,12 @@ describe('encoding: URL splat segment for /$', () => { await router.load() - expect(router.state.location.pathname).toBe( + expect(router.state.location.url.pathname).toBe( '/framework%2Freact%2Fguide%2Ffile-based-routing%20tanstack', ) }) - it('state.location.pathname, should have the params._splat value of "framework/react/guide/file-based-routing tanstack"', async () => { + it('state.location.url.pathname, should have the params._splat value of "framework/react/guide/file-based-routing tanstack"', async () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/framework/react/guide/file-based-routing tanstack'], @@ -650,8 +650,8 @@ describe('encoding: URL splat segment for /$', () => { await router.load() - expect(router.state.location.pathname).toBe( - '/framework/react/guide/file-based-routing tanstack', + expect(router.state.location.url.pathname).toBe( + '/framework/react/guide/file-based-routing%20tanstack', ) }) @@ -736,12 +736,11 @@ describe('encoding: URL path segment', () => { it.each([ { input: '/path-segment/%C3%A9', - output: '/path-segment/é', - type: 'encoded', + output: '/path-segment/%C3%A9', }, { input: '/path-segment/é', - output: '/path-segment/é', + output: '/path-segment/%C3%A9', type: 'not encoded', }, { @@ -761,53 +760,46 @@ describe('encoding: URL path segment', () => { }, { input: '/path-segment/%F0%9F%9A%80', - output: '/path-segment/🚀', - type: 'encoded', + output: '/path-segment/%F0%9F%9A%80', }, { input: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon', - output: '/path-segment/🚀to%2Fthe%2Fmoon', - type: 'encoded', + output: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon', }, { input: '/path-segment/%25%F0%9F%9A%80to%2Fthe%2Fmoon', - output: '/path-segment/%25🚀to%2Fthe%2Fmoon', - type: 'encoded', + output: '/path-segment/%25%F0%9F%9A%80to%2Fthe%2Fmoon', }, { input: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25', - output: '/path-segment/🚀to%2Fthe%2Fmoon%25', - type: 'encoded', + output: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25', }, { input: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25%F0%9F%9A%80to%2Fthe%2Fmoon', - output: '/path-segment/🚀to%2Fthe%2Fmoon%25🚀to%2Fthe%2Fmoon', - type: 'encoded', + output: + '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25%F0%9F%9A%80to%2Fthe%2Fmoon', }, { input: '/path-segment/🚀', - output: '/path-segment/🚀', + output: '/path-segment/%F0%9F%9A%80', type: 'not encoded', }, { input: '/path-segment/🚀to%2Fthe%2Fmoon', - output: '/path-segment/🚀to%2Fthe%2Fmoon', + output: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon', type: 'not encoded', }, - ])( - 'should resolve $input to $output when the path segment is $type', - async ({ input, output }) => { - const { router } = createTestRouter({ - history: createMemoryHistory({ initialEntries: [input] }), - }) + ])('should resolve $input to $output', async ({ input, output }) => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: [input] }), + }) - render() - await act(() => router.load()) + render() + await act(() => router.load()) - expect(router.state.location.pathname).toBe(output) - }, - ) + expect(router.state.location.url.pathname).toBe(output) + }) }) describe('router emits events during rendering', () => { @@ -1857,3 +1849,985 @@ describe('statusCode reset on navigation', () => { expect(router.state.statusCode).toBe(404) }) }) + +describe('Router rewrite functionality', () => { + it('should rewrite URLs using fromURL before router interprets them', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const newPathRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/new-path', + component: () =>
New Path Content
, + }) + + const routeTree = rootRoute.addChildren([newPathRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/old-path'] }), + rewrite: { + fromURL: ({ url }) => { + // Rewrite /old-path to /new-path + if (url.pathname === '/old-path') { + return `/new-path${url.search}${url.hash}` + } + return undefined + }, + }, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('new-path')).toBeInTheDocument() + }) + + // Router should have interpreted the rewritten URL + expect(router.state.location.pathname).toBe('/new-path') + }) + + it('should handle fromURL rewrite with complex URL transformations', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const usersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/users', + component: () =>
Users Content
, + }) + + const routeTree = rootRoute.addChildren([usersRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/legacy/users?page=1#top'], + }), + rewrite: { + fromURL: ({ url }) => { + // Rewrite legacy URLs to new format + if (url.pathname === '/legacy/users') { + return `/users${url.search}${url.hash}` + } + return undefined + }, + }, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('users')).toBeInTheDocument() + }) + + // Router should have interpreted the rewritten URL + expect(router.state.location.pathname).toBe('/users') + expect(router.state.location.search).toEqual({ page: 1 }) + expect(router.state.location.hash).toBe('top') + }) + + it('should handle multiple fromURL rewrite conditions', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home Content
, + }) + + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () =>
About Content
, + }) + + const routeTree = rootRoute.addChildren([homeRoute, aboutRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/old-about'] }), + rewrite: { + fromURL: ({ url }) => { + // Multiple rewrite rules + if (url.pathname === '/old-home' || url.pathname === '/home') { + return '/' + } + if (url.pathname === '/old-about' || url.pathname === '/info') { + return '/about' + } + return undefined + }, + }, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('about')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/about') + }) + + it('should handle fromURL rewrite with search params and hash preservation', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const docsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/docs', + component: () =>
Documentation
, + }) + + const routeTree = rootRoute.addChildren([docsRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/old/documentation?version=v2&lang=en#installation'], + }), + rewrite: { + fromURL: ({ url }) => { + // Rewrite old docs URL structure + if (url.pathname === '/old/documentation') { + return `/docs${url.search}${url.hash}` + } + return undefined + }, + }, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('docs')).toBeInTheDocument() + }) + + // Verify the URL was rewritten correctly with search params and hash preserved + expect(router.state.location.pathname).toBe('/docs') + expect(router.state.location.search).toEqual({ + version: 'v2', + lang: 'en', + }) + expect(router.state.location.hash).toBe('installation') + }) + + it('should handle subdomain to path rewriting with fromURL', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const apiRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/api/users', + component: () =>
API Users
, + }) + + const routeTree = rootRoute.addChildren([apiRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['https://test.domain.com/users'], + }), + rewrite: { + fromURL: ({ url }) => { + // Rewrite test.domain.com/path to /api/path (subdomain becomes path segment) + if (url.pathname.startsWith('/test.domain.com/')) { + return url.pathname.replace('/test.domain.com/', '/api/') + } + return undefined + }, + }, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('api')).toBeInTheDocument() + }) + + // Router should have interpreted the rewritten URL + expect(router.state.location.pathname).toBe('/api/users') + }) + + it('should handle hostname-based routing with fromURL rewrite', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const appRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/app', + component: () =>
App Content
, + }) + + const adminRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/admin', + component: () =>
Admin Content
, + }) + + const routeTree = rootRoute.addChildren([appRoute, adminRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['https://admin.example.com/dashboard'], + }), + rewrite: { + fromURL: ({ url }) => { + // Route based on subdomain + if (url.hostname === 'admin.example.com') { + return '/admin' + } + if (url.hostname === 'app.example.com') { + return '/app' + } + return undefined + }, + }, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('admin')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/admin') + }) + + it('should handle multiple URL transformation patterns', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const productsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/products', + component: () =>
Products
, + }) + + const blogRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/blog', + component: () =>
Blog
, + }) + + const routeTree = rootRoute.addChildren([productsRoute, blogRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/old/shop/items?category=electronics'], + }), + rewrite: { + fromURL: ({ url }) => { + // Multiple transformation patterns + if (url.pathname === '/old/shop/items') { + return `/products${url.search}${url.hash}` + } + if (url.pathname.startsWith('/legacy/')) { + return url.pathname.replace('/legacy/', '/blog/') + } + if (url.pathname.startsWith('/v1/')) { + return url.pathname.replace('/v1/', '/') + } + return undefined + }, + }, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('products')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/products') + expect(router.state.location.search).toEqual({ category: 'electronics' }) + }) + + it('should handle returning a fully formed href string with origin (edge case)', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const apiRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/api/v2', + component: () =>
API v2
, + }) + + const routeTree = rootRoute.addChildren([apiRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['https://legacy.example.com/api/v1'], + }), + rewrite: { + fromURL: ({ url }) => { + // Edge case: return fully formed href string with origin + if ( + url.hostname === 'legacy.example.com' && + url.pathname === '/api/v1' + ) { + return 'https://api.example.com/api/v2' + } + return undefined + }, + }, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('api-v2')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/api/v2') + }) + + it('should handle mutating the url parameter and returning it (recommended pattern)', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const newApiRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/api/v3/users', + component: () =>
API v3 Users
, + }) + + const routeTree = rootRoute.addChildren([newApiRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: [ + 'https://old-api.company.com/users?limit=10&offset=20', + ], + }), + rewrite: { + fromURL: ({ url }) => { + // Recommended pattern: mutate the url parameter and return it + if ( + url.hostname === 'old-api.company.com' && + url.pathname === '/users' + ) { + url.hostname = 'api.company.com' + url.pathname = '/api/v3/users' + url.searchParams.set('version', '3') + return url // Return the mutated URL instance + } + return undefined + }, + }, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('api-v3')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/api/v3/users') + expect(router.state.location.search).toEqual({ + limit: 10, + offset: 20, + version: 3, + }) + }) + + it('should handle complex URL mutations with hostname and search params', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const blogRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/content/blog', + component: () =>
Blog Content
, + }) + + const routeTree = rootRoute.addChildren([blogRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: [ + 'https://blog.oldsite.com/posts?category=tech&year=2024#top', + ], + }), + rewrite: { + fromURL: ({ url }) => { + // Mutate URL: change subdomain to path, preserve params and hash + if (url.hostname === 'blog.oldsite.com') { + url.hostname = 'newsite.com' + url.pathname = '/content/blog' + url.searchParams.set('source', 'migration') + // Keep existing search params and hash + return url + } + return undefined + }, + }, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('blog')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/content/blog') + expect(router.state.location.search).toEqual({ + category: 'tech', + year: 2024, + source: 'migration', + }) + expect(router.state.location.hash).toBe('top') + }) + + it('should handle returning new URL instance vs mutating existing one', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const shopRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/shop/products', + component: () =>
Shop Products
, + }) + + const routeTree = rootRoute.addChildren([shopRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['https://store.example.com/items?id=123'], + }), + rewrite: { + fromURL: ({ url }) => { + // Alternative pattern: create new URL instance and return it + if ( + url.hostname === 'store.example.com' && + url.pathname === '/items' + ) { + const newUrl = new URL('https://example.com/shop/products') + newUrl.searchParams.set( + 'productId', + url.searchParams.get('id') || '', + ) + newUrl.searchParams.set('migrated', 'true') + return newUrl + } + return undefined + }, + }, + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('shop')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/shop/products') + expect(router.state.location.search).toEqual({ + productId: 123, + migrated: true, + }) + }) + + // NOTE: toURL functionality tests - Currently failing as toURL may not be fully implemented + // These tests are preserved for when toURL functionality becomes available + + it.skip('should handle toURL rewrite when navigating (PENDING: toURL not implemented)', async () => { + // This test demonstrates expected toURL behavior for programmatic navigation + // Currently fails because toURL doesn't affect history.location.pathname + const Navigate = () => { + const navigate = useNavigate() + return ( + + ) + } + + const rootRoute = createRootRoute({ + component: () => , + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: Navigate, + }) + + const dashboardRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard', + component: () =>
Dashboard
, + }) + + const routeTree = rootRoute.addChildren([indexRoute, dashboardRoute]) + + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createRouter({ + routeTree, + history, + rewrite: { + toURL: ({ url }) => { + // Should rewrite dashboard URLs to admin URLs in the history + if (url.pathname === '/dashboard') { + return '/admin/panel' + } + return undefined + }, + }, + }) + + render() + + const navigateBtn = await screen.findByTestId('navigate-btn') + + await act(() => { + fireEvent.click(navigateBtn) + }) + + await waitFor(() => { + expect(screen.getByTestId('dashboard')).toBeInTheDocument() + }) + + // Router internal state should show the internal path + expect(router.state.location.pathname).toBe('/dashboard') + + // EXPECTED: History should be updated with the rewritten path due to toURL + // ACTUAL: Currently fails - history.location.pathname remains '/dashboard' + expect(history.location.pathname).toBe('/admin/panel') + }) + + it.skip('should handle toURL rewrite with Link navigation (PENDING: toURL not implemented)', async () => { + // This test demonstrates expected toURL behavior for Link-based navigation + const rootRoute = createRootRoute({ + component: () => , + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( +
+ + Go to Profile + +
+ ), + }) + + const profileRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/profile', + component: () =>
User Profile
, + }) + + const routeTree = rootRoute.addChildren([indexRoute, profileRoute]) + + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createRouter({ + routeTree, + history, + rewrite: { + toURL: ({ url }) => { + // Should rewrite profile URLs to user URLs in history + if (url.pathname === '/profile') { + url.pathname = '/user' + return url + } + return undefined + }, + }, + }) + + render() + + const profileLink = await screen.findByTestId('profile-link') + + await act(() => { + fireEvent.click(profileLink) + }) + + await waitFor(() => { + expect(screen.getByTestId('profile')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/profile') + + // EXPECTED: History should show rewritten path + // ACTUAL: Currently fails - history shows original path + expect(history.location.pathname).toBe('/user') + }) + + it.skip('should handle toURL with search params and hash (PENDING: toURL not implemented)', async () => { + // This test would verify toURL rewriting with complex URL components + // Currently skipped as toURL functionality is not working as expected + }) + + it.skip('should handle toURL returning fully formed href string (PENDING: toURL not implemented)', async () => { + // This test would verify toURL returning complete URLs with origins + // Currently skipped as toURL functionality is not working as expected + }) +}) + +describe('rewriteBasepath utility', () => { + // Helper function to create basepath rewrite logic (mimicking the utility) + const createBasepathRewrite = ( + basepath: string, + additionalRewrite?: { + fromURL: (opts: { url: URL }) => URL | undefined + }, + ) => { + const trimmedBasepath = basepath.replace(/^\/+|\/+$/g, '') // trim slashes + return { + fromURL: ({ url }: { url: URL }) => { + if (trimmedBasepath) { + url.pathname = url.pathname.replace( + new RegExp(`^/${trimmedBasepath}`), + '', + ) + } + return additionalRewrite?.fromURL + ? additionalRewrite.fromURL({ url }) + : url + }, + } as const + } + + it('should handle basic basepath rewriting with fromURL', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () =>
About
, + }) + + const routeTree = rootRoute.addChildren([homeRoute, aboutRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/my-app/about'], + }), + rewrite: createBasepathRewrite('my-app'), + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('about')).toBeInTheDocument() + }) + + // Router should interpret the URL without the basepath + expect(router.state.location.pathname).toBe('/about') + }) + + it('should handle basepath with leading and trailing slashes', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const usersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/users', + component: () =>
Users
, + }) + + const routeTree = rootRoute.addChildren([usersRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/api/v1/users'], + }), + rewrite: createBasepathRewrite('/api/v1/'), // With leading and trailing slashes + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('users')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/users') + }) + + it('should handle empty basepath gracefully', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/test', + component: () =>
Test
, + }) + + const routeTree = rootRoute.addChildren([testRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/test'], + }), + rewrite: createBasepathRewrite(''), // Empty basepath + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('test')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/test') + }) + + it('should combine basepath with additional fromURL rewrite logic', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const newApiRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/api/v2', + component: () =>
API v2
, + }) + + const routeTree = rootRoute.addChildren([newApiRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/my-app/legacy/api/v1'], + }), + rewrite: createBasepathRewrite('my-app', { + // Additional rewrite logic after basepath removal + fromURL: ({ url }) => { + if (url.pathname === '/legacy/api/v1') { + url.pathname = '/api/v2' + return url + } + return undefined + }, + }), + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('api-v2')).toBeInTheDocument() + }) + + // Should first remove basepath (/my-app/legacy/api/v1 -> /legacy/api/v1) + // Then apply additional rewrite (/legacy/api/v1 -> /api/v2) + expect(router.state.location.pathname).toBe('/api/v2') + }) + + it('should handle complex basepath with subdomain-style paths', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const dashboardRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard', + component: () =>
Dashboard
, + }) + + const routeTree = rootRoute.addChildren([dashboardRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/tenant-123/dashboard'], + }), + rewrite: createBasepathRewrite('tenant-123'), + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('dashboard')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/dashboard') + }) + + it('should preserve search params and hash when rewriting basepath', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const searchRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/search', + component: () =>
Search
, + }) + + const routeTree = rootRoute.addChildren([searchRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/app/search?q=test&filter=all#results'], + }), + rewrite: createBasepathRewrite('app'), + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('search')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/search') + expect(router.state.location.search).toEqual({ + q: 'test', + filter: 'all', + }) + expect(router.state.location.hash).toBe('results') + }) + + it.skip('should handle nested basepath with multiple rewrite layers (complex case)', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const finalRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/final', + component: () =>
Final
, + }) + + const routeTree = rootRoute.addChildren([finalRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/base/legacy/old/path'], + }), + rewrite: createBasepathRewrite('base', { + fromURL: ({ url }) => { + // First layer: convert legacy paths + if (url.pathname === '/legacy/old/path') { + url.pathname = '/new/path' + return url + } + return undefined + }, + }), + }) + + // Add a second rewrite layer + const originalRewrite = router.options.rewrite + router.options.rewrite = { + fromURL: ({ url }) => { + // Apply basepath rewrite first + const result = originalRewrite?.fromURL?.({ url }) + if (result && typeof result !== 'string') { + // Second layer: convert new paths to final + if (result.pathname === '/new/path') { + result.pathname = '/final' + return result + } + } + return result + }, + } + + render() + + await waitFor(() => { + expect(screen.getByTestId('final')).toBeInTheDocument() + }) + + // Should apply: /base/legacy/old/path -> /legacy/old/path -> /new/path -> /final + expect(router.state.location.pathname).toBe('/final') + }) + + it.skip('should handle basepath with toURL rewriting (PENDING: toURL not implemented)', async () => { + // This test would verify that basepath is added back when navigating + // Currently skipped as toURL functionality is not working as expected + + const rootRoute = createRootRoute({ + component: () => , + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( +
+ + About + +
+ ), + }) + + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () =>
About
, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + + const history = createMemoryHistory({ initialEntries: ['/my-app/'] }) + + const router = createRouter({ + routeTree, + history, + rewrite: createBasepathRewrite('my-app'), + }) + + render() + + const aboutLink = await screen.findByTestId('about-link') + + await act(() => { + fireEvent.click(aboutLink) + }) + + await waitFor(() => { + expect(screen.getByTestId('about')).toBeInTheDocument() + }) + + // Router internal state should show clean path + expect(router.state.location.pathname).toBe('/about') + + // EXPECTED: History should show path with basepath added back + // ACTUAL: Currently fails due to toURL not being implemented + expect(history.location.pathname).toBe('/my-app/about') + }) +}) diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx index 9a587392748..d68373ff4ee 100644 --- a/packages/react-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx @@ -31,6 +31,7 @@ function setup({ scripts, defaultPendingMs, defaultPendingMinMs, + staleTime, }: { beforeLoad?: () => any loader?: () => any @@ -39,24 +40,26 @@ function setup({ scripts?: () => any defaultPendingMs?: number defaultPendingMinMs?: number + staleTime?: number }) { const select = vi.fn() const rootRoute = createRootRoute({ component: function RootComponent() { useRouterState({ select }) - return + return ( + <> + Back + Posts + + + ) }, }) const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - component: () => ( - <> -

Index

- Posts - - ), + component: () =>

Index

, }) const postsRoute = createRoute({ @@ -83,6 +86,8 @@ function setup({ defaultPendingComponent: () =>

Loading...

, defaultNotFoundComponent: () =>

Not Found Title

, defaultPreload: 'intent', + defaultStaleTime: staleTime, + defaultGcTime: staleTime, }) render() @@ -90,6 +95,15 @@ function setup({ return { select, router } } +async function back() { + const link = await waitFor(() => screen.getByRole('link', { name: 'Back' })) + fireEvent.click(link) + const title = await waitFor(() => + screen.getByRole('heading', { name: /Index/ }), + ) + expect(title).toBeInTheDocument() +} + async function run({ select }: ReturnType) { // navigate to /posts const link = await waitFor(() => screen.getByRole('link', { name: 'Posts' })) @@ -211,4 +225,70 @@ describe("Store doesn't update *too many* times during navigation", () => { // Any change that increases this number should be investigated. expect(updates).toBe(14) }) + + test('navigate, w/ preloaded & async loaders', async () => { + const params = setup({ + beforeLoad: () => Promise.resolve({ foo: 'bar' }), + loader: () => resolveAfter(100, { hello: 'world' }), + staleTime: 1000, + }) + + await params.router.preloadRoute({ to: '/posts' }) + const updates = await run(params) + + // This number should be as small as possible to minimize the amount of work + // that needs to be done during a navigation. + // Any change that increases this number should be investigated. + expect(updates).toBe(7) + }) + + test('navigate, w/ preloaded & sync loaders', async () => { + const params = setup({ + beforeLoad: () => ({ foo: 'bar' }), + loader: () => ({ hello: 'world' }), + staleTime: 1000, + }) + + await params.router.preloadRoute({ to: '/posts' }) + const updates = await run(params) + + // This number should be as small as possible to minimize the amount of work + // that needs to be done during a navigation. + // Any change that increases this number should be investigated. + expect(updates).toBe(6) + }) + + test('navigate, w/ previous navigation & async loader', async () => { + const params = setup({ + loader: () => resolveAfter(100, { hello: 'world' }), + staleTime: 1000, + }) + + await run(params) + await back() + const updates = await run(params) + + // This number should be as small as possible to minimize the amount of work + // that needs to be done during a navigation. + // Any change that increases this number should be investigated. + expect(updates).toBe(5) + }) + + test('preload a preloaded route w/ async loader', async () => { + const params = setup({ + loader: () => resolveAfter(100, { hello: 'world' }), + }) + + await params.router.preloadRoute({ to: '/posts' }) + await new Promise((r) => setTimeout(r, 20)) + const before = params.select.mock.calls.length + await params.router.preloadRoute({ to: '/posts' }) + const after = params.select.mock.calls.length + const updates = after - before + + // This number should be as small as possible to minimize the amount of work + // that needs to be done during a navigation. + // Any change that increases this number should be investigated. + expect(updates).toBe(1) + }) }) diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index 8d98abdf286..fa731c0b16b 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -1504,7 +1504,8 @@ test.each([true, false])( }) const PostsComponent = () => { - const navigate = postsRoute.useNavigate() + const navigate = useNavigate() + return ( <>

Posts

@@ -1619,7 +1620,7 @@ test.each([true, false])( }) const PostsComponent = () => { - const navigate = postsRoute.useNavigate() + const navigate = useNavigate() return ( <>

Posts

@@ -1776,6 +1777,343 @@ test.each([true, false])( }, ) +test.each([true, false])( + 'should navigate to from route when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> + + + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render() + + const postButton = await screen.findByTestId('posts-btn') + + await act(() => fireEvent.click(postButton)) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + + const searchButton = await screen.findByTestId('search-btn') + + await act(() => fireEvent.click(searchButton)) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + + const homeBtn = await screen.findByTestId('home-btn') + + await act(() => fireEvent.click(homeBtn)) + + expect(router.state.location.pathname).toBe(`/`) + expect(router.state.location.search).toEqual({}) + }, +) + +test.each([true, false])( + 'should navigate to from route with path params when using "." in nested route structure with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + const navigate = postsRoute.useNavigate() + return ( + <> +

Posts

+ + + + + + ) + } + + const PostDetailComponent = () => { + const navigate = postDetailRoute.useNavigate() + return ( + <> +

Post Detail

+ + + + + + ) + } + + const PostInfoComponent = () => { + return ( + <> +

Post Info

+ + ) + } + + const PostNotesComponent = () => { + return ( + <> +

Post Notes

+ + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const postDetailRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostDetailComponent, + }) + + const postInfoRoute = createRoute({ + getParentRoute: () => postDetailRoute, + path: 'info', + component: PostInfoComponent, + }) + + const postNotesRoute = createRoute({ + getParentRoute: () => postDetailRoute, + path: 'notes', + component: PostNotesComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postDetailRoute.addChildren([postInfoRoute, postNotesRoute]), + ]), + ]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render() + + const postsButton = await screen.findByTestId('posts-btn') + + fireEvent.click(postsButton) + + expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) + + const firstPostButton = await screen.findByTestId('first-post-btn') + + fireEvent.click(firstPostButton) + + expect( + await screen.findByTestId('post-detail-index-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1${tail}`) + + const postInfoButton = await screen.findByTestId('post-info-btn') + + fireEvent.click(postInfoButton) + + expect(await screen.findByTestId('post-info-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1/info${tail}`) + + const toPostDetailIndexButton = await screen.findByTestId( + 'to-post-detail-index-btn', + ) + + fireEvent.click(toPostDetailIndexButton) + + expect( + await screen.findByTestId('post-detail-index-heading'), + ).toBeInTheDocument() + expect(screen.queryByTestId('post-info-heading')).not.toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1${tail}`) + + const postNotesButton = await screen.findByTestId('post-notes-btn') + + fireEvent.click(postNotesButton) + + expect(await screen.findByTestId('post-notes-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1/notes${tail}`) + + const toPostsIndexButton = await screen.findByTestId('to-posts-index-btn') + + fireEvent.click(toPostsIndexButton) + + expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() + expect(screen.queryByTestId('post-notes-heading')).not.toBeInTheDocument() + expect( + screen.queryByTestId('post-detail-index-heading'), + ).not.toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) + + const secondPostButton = await screen.findByTestId('second-post-btn') + + fireEvent.click(secondPostButton) + + expect( + await screen.findByTestId('post-detail-index-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/2${tail}`) + }, +) + describe('when on /posts/$postId and navigating to ../ with default `from` /posts', () => { async function runTest(navigateVia: 'Route' | 'RouteApi') { const rootRoute = createRootRoute() @@ -1975,7 +2313,11 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param Route

- + ) @@ -2150,6 +2502,25 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( expect(window.location.pathname).toBe(`${basepath}/param/foo`) }) + test('should navigate to a parent link based on active location', async () => { + const router = setupRouter() + + render() + + await act(async () => { + history.push(`${basepath}/param/foo/a/b`) + }) + + const relativeLink = await screen.findByTestId('link-to-previous') + + // Click the link and ensure the new location + await act(async () => { + fireEvent.click(relativeLink) + }) + + expect(window.location.pathname).toBe(`${basepath}/param/foo/a`) + }) + test('should navigate to same route with different params', async () => { const router = setupRouter() diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 69ec837b7e1..d808d753c1a 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -103,7 +103,6 @@ export { parsePathname, interpolatePath, matchPathname, - removeBasepath, matchByPath, } from './path' export type { Segment } from './path' @@ -430,3 +429,5 @@ export { } from './ssr/serializer/transformer' export { defaultSerovalPlugins } from './ssr/serializer/seroval-plugins' + +export { rewriteBasepath } from './rewriteBasepath' diff --git a/packages/router-core/src/location.ts b/packages/router-core/src/location.ts index 4b09f7b7ff7..0f0861a2272 100644 --- a/packages/router-core/src/location.ts +++ b/packages/router-core/src/location.ts @@ -2,12 +2,51 @@ import type { ParsedHistoryState } from '@tanstack/history' import type { AnySchema } from './validators' export interface ParsedLocation { + /** + * @description The public href of the location, including the origin before any rewrites. + * If a rewrite is applied, the `href` property will be the rewritten URL. + */ + publicHref: string + /** + * @description The full URL of the location, including the origin. As a replacement, + * please upgrade to the new `fullPath` property, which is derived by + * combining `pathname`, `search`, and `hash`. like so: + * `${pathname}${searchStr}${hash}`. If you're looking for the actual + * `href` of the location, you can use the `location.url.href` property. + */ href: string + /** + * The full path of the location, including pathname, search, and hash. + * Does not include the origin. Is the equivalent of calling + * `url.replace(url.origin, '')` + */ + fullPath: string + /** + * @description The pathname of the location, including the leading slash. + */ pathname: string + /** + * The parsed search parameters of the location in object form. + */ search: TSearchObj + /** + * The search string of the location, including the leading question mark. + */ searchStr: string + /** + * The in-memory state of the location as it *may* exist in the browser's history. + */ state: ParsedHistoryState + /** + * The hash of the location, including the leading hash character. + */ hash: string + /** + * The masked location of the location. + */ maskedLocation?: ParsedLocation + /** + * Whether to unmask the location on reload. + */ unmaskOnReload?: boolean } diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 827f17c2432..42466128e49 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -97,7 +97,7 @@ export function exactPathTest( // /a/b/c + d/ = /a/b/c/d // /a/b/c + d/e = /a/b/c/d/e interface ResolvePathOptions { - basepath: string + // basepath: string base: string to: string trailingSlash?: 'always' | 'never' | 'preserve' @@ -151,16 +151,12 @@ function segmentToString(segment: Segment): string { } export function resolvePath({ - basepath, base, to, trailingSlash = 'never', - caseSensitive, + // caseSensitive, parseCache, }: ResolvePathOptions) { - base = removeBasepath(basepath, base, caseSensitive) - to = removeBasepath(basepath, to, caseSensitive) - let baseSegments = parsePathname(base, parseCache).slice() const toSegments = parsePathname(to, parseCache) @@ -201,7 +197,8 @@ export function resolvePath({ } const segmentValues = baseSegments.map(segmentToString) - const joined = joinPaths([basepath, ...segmentValues]) + // const joined = joinPaths([basepath, ...segmentValues]) + const joined = joinPaths(segmentValues) return joined } @@ -491,17 +488,11 @@ function encodePathParam(value: string, decodeCharMap?: Map) { } export function matchPathname( - basepath: string, currentPathname: string, matchLocation: Pick, parseCache?: ParsePathnameCache, ): AnyPathParams | undefined { - const pathParams = matchByPath( - basepath, - currentPathname, - matchLocation, - parseCache, - ) + const pathParams = matchByPath(currentPathname, matchLocation, parseCache) // const searchMatched = matchBySearch(location.search, matchLocation) if (matchLocation.to && !pathParams) { @@ -511,49 +502,7 @@ export function matchPathname( return pathParams ?? {} } -export function removeBasepath( - basepath: string, - pathname: string, - caseSensitive: boolean = false, -) { - // normalize basepath and pathname for case-insensitive comparison if needed - const normalizedBasepath = caseSensitive ? basepath : basepath.toLowerCase() - const normalizedPathname = caseSensitive ? pathname : pathname.toLowerCase() - - switch (true) { - // default behaviour is to serve app from the root - pathname - // left untouched - case normalizedBasepath === '/': - return pathname - - // shortcut for removing the basepath if it matches the pathname - case normalizedPathname === normalizedBasepath: - return '' - - // in case pathname is shorter than basepath - there is - // nothing to remove - case pathname.length < basepath.length: - return pathname - - // avoid matching partial segments - strict equality handled - // earlier, otherwise, basepath separated from pathname with - // separator, therefore lack of separator means partial - // segment match (`/app` should not match `/application`) - case normalizedPathname[normalizedBasepath.length] !== '/': - return pathname - - // remove the basepath from the pathname if it starts with it - case normalizedPathname.startsWith(normalizedBasepath): - return pathname.slice(basepath.length) - - // otherwise, return the pathname as is - default: - return pathname - } -} - export function matchByPath( - basepath: string, from: string, { to, @@ -562,14 +511,7 @@ export function matchByPath( }: Pick, parseCache?: ParsePathnameCache, ): Record | undefined { - // check basepath first - if (basepath !== '/' && !from.startsWith(basepath)) { - return undefined - } - // Remove the base path from the pathname - from = removeBasepath(basepath, from, caseSensitive) - // Default to to $ (wildcard) - to = removeBasepath(basepath, `${to ?? '$'}`, caseSensitive) + const stringTo = to as string // Parse the from and to const baseSegments = parsePathname( @@ -577,7 +519,7 @@ export function matchByPath( parseCache, ) const routeSegments = parsePathname( - to.startsWith('/') ? to : `/${to}`, + stringTo.startsWith('/') ? stringTo : `/${stringTo}`, parseCache, ) diff --git a/packages/router-core/src/rewriteBasepath.ts b/packages/router-core/src/rewriteBasepath.ts new file mode 100644 index 00000000000..570ce27c8b7 --- /dev/null +++ b/packages/router-core/src/rewriteBasepath.ts @@ -0,0 +1,27 @@ +import { joinPaths, trimPath } from './path' +import type { LocationRewrite } from './router' + +export function rewriteBasepath( + basepath: string, + rewrite?: LocationRewrite, + opts?: { + caseSensitive?: boolean + }, +): LocationRewrite { + const trimmedBasepath = trimPath(basepath) + return { + fromHref: ({ href }) => { + const url = new URL(href) + url.pathname = url.pathname.replace( + new RegExp(`^/${trimmedBasepath}/`, opts?.caseSensitive ? '' : 'i'), + '/', + ) + return rewrite?.fromHref ? rewrite.fromHref({ href: url.href }) : url.href + }, + toHref: ({ href }) => { + const url = new URL(href) + url.pathname = joinPaths(['/', trimmedBasepath, url.pathname]) + return rewrite?.toHref ? rewrite.toHref({ href: url.href }) : url.href + }, + } +} diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 223984cf092..35ccd4d9e04 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -20,7 +20,6 @@ import { SEGMENT_TYPE_WILDCARD, cleanPath, interpolatePath, - joinPaths, matchPathname, parsePathname, resolvePath, @@ -35,6 +34,7 @@ import { rootRouteId } from './root' import { isRedirect, redirect } from './redirect' import { createLRUCache } from './lru-cache' import { loadMatches, loadRouteChunk, routeNeedsPreload } from './load-matches' +import { rewriteBasepath } from './rewriteBasepath' import type { ParsePathnameCache, Segment } from './path' import type { SearchParser, SearchSerializer } from './searchParams' import type { AnyRedirect, ResolvedRedirect } from './redirect' @@ -267,6 +267,18 @@ export interface RouterOptions< /** * The basepath for then entire router. This is useful for mounting a router instance at a subpath. * + * @deprecated - use `rewrite.fromURL` with the new `rewriteBasepath` utility instead: + * ```ts + * const router = createRouter({ + * routeTree, + * rewrite: rewriteBasepath('/basepath') + * // Or wrap existing rewrite functionality + * rewrite: rewriteBasepath('/basepath', { + * toURL: ({ url }) => {...}, + * fromURL: ({ url }) => {...}, + * }) + * }) + * ``` * @default '/' * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#basepath-property) */ @@ -431,8 +443,45 @@ export interface RouterOptions< disableGlobalCatchBoundary?: boolean serializationAdapters?: TSerializationAdapters + /** + * Configures how the router will rewrite the location between the actual href and the internal href of the router. + * + * @default undefined + * @description You can provide a custom rewrite pair (in/out) or use the utilities like `rewriteBasepath` as a convenience for common use cases, or even do both! + * This is useful for basepath rewriting, shifting data from the origin to the path (for things like ) + */ + rewrite?: LocationRewrite } +export type LocationRewrite = { + /** + * A function that will be called to rewrite the URL before it is interpreted by the router from the history instance. + * Utilities like `rewriteBasepath` are provided as a convenience for common use cases. + * + * @default undefined + */ + fromHref?: LocationRewriteFunction + /** + * A function that will be called to rewrite the URL before it is committed to the actual history instance from the router. + * Utilities like `rewriteBasepath` are provided as a convenience for common use cases. + * + * @default undefined + */ + toHref?: LocationRewriteFunction +} + +/** + * A function that will be called to rewrite the URL. + * + * @param url The URL to rewrite. + * @returns The rewritten URL (as a URL instance or full href string) or undefined if no rewrite is needed. + */ +export type LocationRewriteFunction = ({ + href, +}: { + href: string +}) => undefined | string + export interface RouterState< in out TRouteTree extends AnyRoute = AnyRoute, in out TRouteMatch = MakeRouteMatchUnion, @@ -825,6 +874,7 @@ export class RouterCore< > history!: TRouterHistory latestLocation!: ParsedLocation> + // @deprecated - basepath functionality is now implemented via the `rewrite` option basepath!: string routeTree!: TRouteTree routesById!: RoutesById @@ -892,7 +942,6 @@ export class RouterCore< ) } - const previousOptions = this.options this.options = { ...this.options, ...newOptions, @@ -909,21 +958,6 @@ export class RouterCore< ) : undefined - if ( - !this.basepath || - (newOptions.basepath && newOptions.basepath !== previousOptions.basepath) - ) { - if ( - newOptions.basepath === undefined || - newOptions.basepath === '' || - newOptions.basepath === '/' - ) { - this.basepath = '/' - } else { - this.basepath = `/${trimPath(newOptions.basepath)}` - } - } - if ( !this.history || (this.options.history && this.options.history !== this.history) @@ -932,7 +966,7 @@ export class RouterCore< this.options.history ?? ((this.isServer ? createMemoryHistory({ - initialEntries: [this.basepath || '/'], + initialEntries: ['/'], }) : createBrowserHistory()) as TRouterHistory) this.updateLatestLocation() @@ -1030,20 +1064,43 @@ export class RouterCore< previousLocation, ) => { const parse = ({ - pathname, - search, - hash, + href: publicHref, state, }: HistoryLocation): ParsedLocation> => { - const parsedSearch = this.options.parseSearch(search) + // For backwards compatibility, we support a basepath option, which we now implement as a rewrite + let href = publicHref + + if (this.options.basepath) { + href = + rewriteBasepath(this.options.basepath).fromHref?.({ href }) || href + } + + // Before we do any processing, we need to allow rewrites to modify the URL + if (this.options.rewrite?.fromHref) { + href = this.options.rewrite.fromHref({ href }) || href + } + + // Make sure we derive all the properties we need from the URL object now + // (These used to come from the history location object, but won't in v2) + const url = new URL(href) + const parsedSearch = this.options.parseSearch(url.search) const searchStr = this.options.stringifySearch(parsedSearch) + // Make sure our final url uses the re-stringified pathname, search, and has for consistency + // (We were already doing this, so just keeping it for now) + url.search = searchStr + + const fullPath = url.href.replace(url.origin, '') + + const { pathname, hash } = url return { + publicHref: href, + href, + fullPath, pathname, searchStr, search: replaceEqualDeep(previousLocation?.search, parsedSearch) as any, hash: hash.split('#').reverse()[0] ?? '', - href: `${pathname}${searchStr}${hash}`, state: replaceEqualDeep(previousLocation?.state, state), } } @@ -1071,7 +1128,6 @@ export class RouterCore< resolvePathWithBase = (from: string, path: string) => { const resolvedPath = resolvePath({ - basepath: this.basepath, base: from, to: cleanPath(path), trailingSlash: this.options.trailingSlash, @@ -1307,7 +1363,7 @@ export class RouterCore< ? replaceEqualDeep(previousMatch.params, routeParams) : routeParams, _strictParams: usedParams, - pathname: joinPaths([this.basepath, interpolatedPath]), + pathname: interpolatedPath, updatedAt: Date.now(), search: previousMatch ? replaceEqualDeep(previousMatch.search, preMatchSearch) @@ -1412,7 +1468,6 @@ export class RouterCore< return getMatchedRoutes({ pathname, routePathname, - basepath: this.basepath, caseSensitive: this.options.caseSensitive, routesByPath: this.routesByPath, routesById: this.routesById, @@ -1450,50 +1505,44 @@ export class RouterCore< _buildLocation: true, }) + // Now let's find the starting pathname + // This should default to the current location if no from is provided const lastMatch = last(allCurrentLocationMatches)! - // First let's find the starting pathname - // By default, start with the current location - let fromPath = this.resolvePathWithBase(lastMatch.fullPath, '.') - const toPath = dest.to - ? this.resolvePathWithBase(fromPath, `${dest.to}`) - : this.resolvePathWithBase(fromPath, '.') - - const routeIsChanging = - !!dest.to && - !comparePaths(dest.to.toString(), fromPath) && - !comparePaths(toPath, fromPath) - - // If the route is changing we need to find the relative fromPath - if (dest.unsafeRelative === 'path') { - fromPath = currentLocation.pathname - } else if (routeIsChanging && dest.from) { - fromPath = dest.from - - // do this check only on navigations during test or development - if (process.env.NODE_ENV !== 'production' && dest._isNavigate) { - const allFromMatches = this.getMatchedRoutes( - dest.from, - undefined, - ).matchedRoutes + // check that from path exists in the current route tree + // do this check only on navigations during test or development + if ( + dest.from && + process.env.NODE_ENV !== 'production' && + dest._isNavigate + ) { + const allFromMatches = this.getMatchedRoutes( + dest.from, + undefined, + ).matchedRoutes - const matchedFrom = findLast(allCurrentLocationMatches, (d) => { - return comparePaths(d.fullPath, fromPath) - }) + const matchedFrom = findLast(allCurrentLocationMatches, (d) => { + return comparePaths(d.fullPath, dest.from!) + }) - const matchedCurrent = findLast(allFromMatches, (d) => { - return comparePaths(d.fullPath, currentLocation.pathname) - }) + const matchedCurrent = findLast(allFromMatches, (d) => { + return comparePaths(d.fullPath, lastMatch.fullPath) + }) - // for from to be invalid it shouldn't just be unmatched to currentLocation - // but the currentLocation should also be unmatched to from - if (!matchedFrom && !matchedCurrent) { - console.warn(`Could not find match for from: ${fromPath}`) - } + // for from to be invalid it shouldn't just be unmatched to currentLocation + // but the currentLocation should also be unmatched to from + if (!matchedFrom && !matchedCurrent) { + console.warn(`Could not find match for from: ${dest.from}`) } } - fromPath = this.resolvePathWithBase(fromPath, '.') + const defaultedFromPath = + dest.unsafeRelative === 'path' + ? currentLocation.pathname + : (dest.from ?? lastMatch.fullPath) + + // ensure this includes the basePath if set + const fromPath = this.resolvePathWithBase(defaultedFromPath, '.') // From search should always use the current location const fromSearch = lastMatch.search @@ -1501,6 +1550,7 @@ export class RouterCore< const fromParams = { ...lastMatch.params } // Resolve the next to + // ensure this includes the basePath if set const nextTo = dest.to ? this.resolvePathWithBase(fromPath, `${dest.to}`) : this.resolvePathWithBase(fromPath, '.') @@ -1606,14 +1656,41 @@ export class RouterCore< // Replace the equal deep nextState = replaceEqualDeep(currentLocation.state, nextState) + // Create the full path of the location + const fullPath = `${nextPathname}${searchStr}${hashStr}` + + // Create the new href with full origin + const href = new URL(fullPath, new URL(currentLocation.href).origin).href + + let publicHref = href + + // If a rewrite function is provided, use it to rewrite the URL + if (this.options.rewrite?.toHref) { + publicHref = + this.options.rewrite.toHref({ href: publicHref }) || publicHref + } + + // For backwards compatibility, we support a basepath option, which we now implement as a rewrite + if (this.options.basepath) { + publicHref = + rewriteBasepath(this.options.basepath).toHref?.({ + href: publicHref, + }) || publicHref + } + + // Lastly, allow the history type to modify the URL + publicHref = this.history.createHref(publicHref) + // Return the next location return { + publicHref, + href, + fullPath, pathname: nextPathname, search: nextSearch, searchStr, state: nextState as any, hash: hash ?? '', - href: `${nextPathname}${searchStr}${hashStr}`, unmaskOnReload: dest.unmaskOnReload, } } @@ -1631,7 +1708,6 @@ export class RouterCore< const foundMask = this.options.routeMasks?.find((d) => { const match = matchPathname( - this.basepath, next.pathname, { to: d.from, @@ -1705,7 +1781,9 @@ export class RouterCore< return isEqual } - const isSameUrl = this.latestLocation.href === next.href + const isSameUrl = + trimPathRight(this.latestLocation.fullPath) === + trimPathRight(next.fullPath) const previousCommitPromise = this.commitLocationPromise this.commitLocationPromise = createControlledPromise(() => { @@ -1754,7 +1832,7 @@ export class RouterCore< this.shouldViewTransition = viewTransition this.history[next.replace ? 'replace' : 'push']( - nextHistory.href, + nextHistory.publicHref, nextHistory.state, { ignoreBlocker }, ) @@ -1816,7 +1894,7 @@ export class RouterCore< if (reloadDocument) { if (!href) { const location = this.buildLocation({ to, ...rest } as any) - href = this.history.createHref(location.href) + href = location.href } if (rest.replace) { window.location.replace(href) @@ -1863,12 +1941,13 @@ export class RouterCore< } if ( - trimPath(normalizeUrl(this.latestLocation.href)) !== - trimPath(normalizeUrl(nextLocation.href)) + trimPath(normalizeUrl(this.latestLocation.fullPath)) !== + trimPath(normalizeUrl(nextLocation.fullPath)) ) { throw redirect({ href: nextLocation.href }) } } + // Match the routes const pendingMatches = this.matchRoutes(this.latestLocation) @@ -2014,6 +2093,7 @@ export class RouterCore< this.latestLoadPromise = undefined this.commitLocationPromise = undefined } + resolve() }) }) @@ -2312,7 +2392,6 @@ export class RouterCore< : this.state.resolvedLocation || this.state.location const match = matchPathname( - this.basepath, baseLocation.pathname, { ...opts, @@ -2648,7 +2727,6 @@ export function processRouteTree({ export function getMatchedRoutes({ pathname, routePathname, - basepath, caseSensitive, routesByPath, routesById, @@ -2657,7 +2735,6 @@ export function getMatchedRoutes({ }: { pathname: string routePathname?: string - basepath: string caseSensitive?: boolean routesByPath: Record routesById: Record @@ -2668,7 +2745,6 @@ export function getMatchedRoutes({ const trimmedPath = trimPathRight(pathname) const getMatchedParams = (route: TRouteLike) => { const result = matchPathname( - basepath, trimmedPath, { to: route.fullPath, diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 2a241bcb77d..7546f38f341 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -7,95 +7,12 @@ import { interpolatePath, matchPathname, parsePathname, - removeBasepath, removeTrailingSlash, resolvePath, trimPathLeft, } from '../src/path' import type { Segment as PathSegment } from '../src/path' -describe('removeBasepath', () => { - it.each([ - { - name: '`/` should leave pathname as-is', - basepath: '/', - pathname: '/path', - expected: '/path', - }, - { - name: 'should return empty string if basepath is the same as pathname', - basepath: '/path', - pathname: '/path', - expected: '', - }, - { - name: 'should remove basepath from the beginning of the pathname', - basepath: '/app', - pathname: '/app/path/app', - expected: '/path/app', - }, - { - name: 'should remove multisegment basepath from the beginning of the pathname', - basepath: '/app/new', - pathname: '/app/new/path/app/new', - expected: '/path/app/new', - }, - { - name: 'should remove basepath only in case it matches segments completely', - basepath: '/app', - pathname: '/application', - expected: '/application', - }, - { - name: 'should remove multisegment basepath only in case it matches segments completely', - basepath: '/app/new', - pathname: '/app/new-application', - expected: '/app/new-application', - }, - ])('$name', ({ basepath, pathname, expected }) => { - expect(removeBasepath(basepath, pathname)).toBe(expected) - }) - describe('case sensitivity', () => { - describe('caseSensitive = true', () => { - it.each([ - { - name: 'should not remove basepath from the beginning of the pathname', - basepath: '/app', - pathname: '/App/path/App', - expected: '/App/path/App', - }, - { - name: 'should not remove basepath from the beginning of the pathname with multiple segments', - basepath: '/app/New', - pathname: '/App/New/path/App', - expected: '/App/New/path/App', - }, - ])('$name', ({ basepath, pathname, expected }) => { - expect(removeBasepath(basepath, pathname, true)).toBe(expected) - }) - }) - - describe('caseSensitive = false', () => { - it.each([ - { - name: 'should remove basepath from the beginning of the pathname', - basepath: '/App', - pathname: '/app/path/app', - expected: '/path/app', - }, - { - name: 'should remove multisegment basepath from the beginning of the pathname', - basepath: '/App/New', - pathname: '/app/new/path/app', - expected: '/path/app', - }, - ])('$name', ({ basepath, pathname, expected }) => { - expect(removeBasepath(basepath, pathname, false)).toBe(expected) - }) - }) - }) -}) - describe.each([{ basepath: '/' }, { basepath: '/app' }, { basepath: '/app/' }])( 'removeTrailingSlash with basepath $basepath', ({ basepath }) => { @@ -167,39 +84,32 @@ describe.each([{ basepath: '/' }, { basepath: '/app' }, { basepath: '/app/' }])( describe('resolvePath', () => { describe.each([ - ['/', '/', '/', '/'], - ['/', '/', '/a', '/a'], - ['/', '/', 'a/', '/a'], - ['/', '/', '/a/b', '/a/b'], - ['/', 'a', 'b', '/a/b'], - ['/a/b', 'c', '/a/b/c', '/a/b/c'], - ['/a/b', '/', 'c', '/a/b/c'], - ['/a/b', '/', './c', '/a/b/c'], - ['/', '/', 'a/b', '/a/b'], - ['/', '/', './a/b', '/a/b'], - ['/', '/a/b/c', 'd', '/a/b/c/d'], - ['/', '/a/b/c', './d', '/a/b/c/d'], - ['/', '/a/b/c', './../d', '/a/b/d'], - ['/', '/a/b/c/d', './../d', '/a/b/c/d'], - ['/', '/a/b/c', '../../d', '/a/d'], - ['/', '/a/b/c', '../d', '/a/b/d'], - ['/', '/a/b/c', '..', '/a/b'], - ['/', '/a/b/c', '../..', '/a'], - ['/', '/a/b/c', '../../..', '/'], - ['/', '/a/b/c/', '../../..', '/'], - ['/products', '/', '/products-list', '/products/products-list'], - ['/basepath', '/products', '.', '/basepath/products'], - ])('resolves correctly', (base, a, b, eq) => { - it(`Base: ${base} - ${a} to ${b} === ${eq}`, () => { - expect(resolvePath({ basepath: base, base: a, to: b })).toEqual(eq) + ['/', '/', '/'], + ['/', '/a', '/a'], + ['/', 'a/', '/a'], + ['/', '/a/b', '/a/b'], + ['a', 'b', '/a/b'], + ['/', 'a/b', '/a/b'], + ['/', './a/b', '/a/b'], + ['/a/b/c', 'd', '/a/b/c/d'], + ['/a/b/c', './d', '/a/b/c/d'], + ['/a/b/c', './../d', '/a/b/d'], + ['/a/b/c/d', './../d', '/a/b/c/d'], + ['/a/b/c', '../../d', '/a/d'], + ['/a/b/c', '../d', '/a/b/d'], + ['/a/b/c', '..', '/a/b'], + ['/a/b/c', '../..', '/a'], + ['/a/b/c', '../../..', '/'], + ['/a/b/c/', '../../..', '/'], + ])('resolves correctly', (a, b, eq) => { + it(`${a} to ${b} === ${eq}`, () => { + expect(resolvePath({ base: a, to: b })).toEqual(eq) }) - it(`Base: ${base} - ${a}/ to ${b} === ${eq} (trailing slash)`, () => { - expect(resolvePath({ basepath: base, base: a + '/', to: b })).toEqual(eq) + it(`${a}/ to ${b} === ${eq} (trailing slash)`, () => { + expect(resolvePath({ base: a + '/', to: b })).toEqual(eq) }) - it(`Base: ${base} - ${a}/ to ${b}/ === ${eq} (trailing slash + trailing slash)`, () => { - expect( - resolvePath({ basepath: base, base: a + '/', to: b + '/' }), - ).toEqual(eq) + it(`${a}/ to ${b}/ === ${eq} (trailing slash + trailing slash)`, () => { + expect(resolvePath({ base: a + '/', to: b + '/' })).toEqual(eq) }) }) describe('trailingSlash', () => { @@ -207,7 +117,6 @@ describe('resolvePath', () => { it('keeps trailing slash', () => { expect( resolvePath({ - basepath: '/', base: '/a/b/c', to: 'd/', trailingSlash: 'always', @@ -217,7 +126,6 @@ describe('resolvePath', () => { it('adds trailing slash', () => { expect( resolvePath({ - basepath: '/', base: '/a/b/c', to: 'd', trailingSlash: 'always', @@ -229,7 +137,6 @@ describe('resolvePath', () => { it('removes trailing slash', () => { expect( resolvePath({ - basepath: '/', base: '/a/b/c', to: 'd/', trailingSlash: 'never', @@ -239,7 +146,6 @@ describe('resolvePath', () => { it('does not add trailing slash', () => { expect( resolvePath({ - basepath: '/', base: '/a/b/c', to: 'd', trailingSlash: 'never', @@ -251,7 +157,6 @@ describe('resolvePath', () => { it('keeps trailing slash', () => { expect( resolvePath({ - basepath: '/', base: '/a/b/c', to: 'd/', trailingSlash: 'preserve', @@ -261,7 +166,6 @@ describe('resolvePath', () => { it('does not add trailing slash', () => { expect( resolvePath({ - basepath: '/', base: '/a/b/c', to: 'd', trailingSlash: 'preserve', @@ -294,7 +198,6 @@ describe('resolvePath', () => { const candidate = base + trimPathLeft(to) expect( resolvePath({ - basepath: '/', base, to: candidate, trailingSlash: 'never', @@ -324,7 +227,6 @@ describe('resolvePath', () => { const candidate = base + trimPathLeft(to) expect( resolvePath({ - basepath: '/', base, to: candidate, trailingSlash: 'never', @@ -621,7 +523,7 @@ describe('matchPathname', () => { ])( '$name', ({ basepath, input, matchingOptions, expectedMatchedParams }) => { - expect(matchPathname(basepath, input, matchingOptions)).toStrictEqual( + expect(matchPathname(input, matchingOptions)).toStrictEqual( expectedMatchedParams, ) }, @@ -703,7 +605,7 @@ describe('matchPathname', () => { }, }, ])('$name', ({ input, matchingOptions, expectedMatchedParams }) => { - expect(matchPathname('/', input, matchingOptions)).toStrictEqual( + expect(matchPathname(input, matchingOptions)).toStrictEqual( expectedMatchedParams, ) }) @@ -767,7 +669,7 @@ describe('matchPathname', () => { }, }, ])('$name', ({ input, matchingOptions, expectedMatchedParams }) => { - expect(matchPathname('/', input, matchingOptions)).toStrictEqual( + expect(matchPathname(input, matchingOptions)).toStrictEqual( expectedMatchedParams, ) }) @@ -826,7 +728,7 @@ describe('matchPathname', () => { }, }, ])('$name', ({ input, matchingOptions, expectedMatchedParams }) => { - expect(matchPathname('/', input, matchingOptions)).toStrictEqual( + expect(matchPathname(input, matchingOptions)).toStrictEqual( expectedMatchedParams, ) }) diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index 91d92623c72..bd6e1dfd4bd 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -11,7 +11,6 @@ export { parsePathname, interpolatePath, matchPathname, - removeBasepath, matchByPath, rootRouteId, defaultSerializeError, @@ -354,3 +353,4 @@ export { ScriptOnce } from './ScriptOnce' export { Asset } from './Asset' export { HeadContent, useTags } from './HeadContent' export { Scripts } from './Scripts' +export { rewriteBasepath } from '@tanstack/router-core' diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 008483ac0d8..5e08fe73d12 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -15,7 +15,7 @@ import { useRouter } from './useRouter' import { useIntersectionObserver } from './utils' -import { useMatch } from './useMatch' +import { useActiveLocation } from './useActiveLocation' import type { AnyRouter, Constrain, @@ -133,20 +133,20 @@ export function useLinkProps< select: (s) => s.location.searchStr, }) - // when `from` is not supplied, use the route of the current match as the `from` location - // so relative routing works as expected - const from = useMatch({ - strict: false, - select: (match) => options.from ?? match.fullPath, - }) + const { getFromPath, activeLocation } = useActiveLocation() - const _options = () => ({ - ...options, - from: from(), - }) + const from = getFromPath(options.from) + + const _options = () => { + return { + ...options, + from: from(), + } + } const next = Solid.createMemo(() => { currentSearch() + activeLocation() return router.buildLocation(_options() as any) }) @@ -391,8 +391,8 @@ export function useLinkProps< return _options().disabled ? undefined : maskedLocation - ? router.history.createHref(maskedLocation.href) - : router.history.createHref(nextLocation?.href) + ? maskedLocation.fullPath + : nextLocation?.fullPath }) return Solid.mergeProps( diff --git a/packages/solid-router/src/useActiveLocation.ts b/packages/solid-router/src/useActiveLocation.ts new file mode 100644 index 00000000000..268af7296dd --- /dev/null +++ b/packages/solid-router/src/useActiveLocation.ts @@ -0,0 +1,61 @@ +import { last } from '@tanstack/router-core' +import { createEffect, createMemo, createSignal } from 'solid-js' +import { useMatch } from './useMatch' +import { useRouter } from './useRouter' +import { useRouterState } from './useRouterState' +import type { Accessor } from 'solid-js' +import type { ParsedLocation } from '@tanstack/router-core' + +export type UseLocationResult = { + activeLocation: Accessor + getFromPath: (from?: string) => Accessor + setActiveLocation: (location?: ParsedLocation) => void +} + +export function useActiveLocation( + location?: ParsedLocation, +): UseLocationResult { + const router = useRouter() + // we are not using a variable here for router state location since we need to only calculate that if the location is not passed in. It can result in unnecessary history actions if we do that. + const [activeLocation, setActiveLocation] = createSignal( + location ?? useRouterState({ select: (s) => s.location })(), + ) + const [customActiveLocation, setCustomActiveLocation] = createSignal< + ParsedLocation | undefined + >(location) + + createEffect(() => { + setActiveLocation( + customActiveLocation() ?? useRouterState({ select: (s) => s.location })(), + ) + }) + + const matchIndex = useMatch({ + strict: false, + select: (match) => match.index, + }) + + const getFromPath = (from?: string) => + createMemo(() => { + const activeLocationMatches = router.matchRoutes( + customActiveLocation() ?? activeLocation(), + { + _buildLocation: false, + }, + ) + + const activeLocationMatch = last(activeLocationMatches) + + return ( + from ?? + activeLocationMatch?.fullPath ?? + router.state.matches[matchIndex()]!.fullPath + ) + }) + + return { + activeLocation, + getFromPath, + setActiveLocation: setCustomActiveLocation, + } +} diff --git a/packages/solid-router/src/useNavigate.tsx b/packages/solid-router/src/useNavigate.tsx index 96d1207533b..0a7f7437531 100644 --- a/packages/solid-router/src/useNavigate.tsx +++ b/packages/solid-router/src/useNavigate.tsx @@ -1,6 +1,6 @@ import * as Solid from 'solid-js' import { useRouter } from './useRouter' -import { useMatch } from './useMatch' +import { useActiveLocation } from './useActiveLocation' import type { AnyRouter, FromPathOption, @@ -15,20 +15,19 @@ export function useNavigate< >(_defaultOpts?: { from?: FromPathOption }): UseNavigateResult { - const { navigate, state } = useRouter() + const router = useRouter() - const matchIndex = useMatch({ - strict: false, - select: (match) => match.index, - }) + const { getFromPath, setActiveLocation } = useActiveLocation( + router.latestLocation, + ) return ((options: NavigateOptions) => { - return navigate({ + setActiveLocation(router.latestLocation) + const from = getFromPath(options.from ?? _defaultOpts?.from) + + return router.navigate({ ...options, - from: - options.from ?? - _defaultOpts?.from ?? - state.matches[matchIndex()]!.fullPath, + from: from(), }) }) as UseNavigateResult } diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index 9eba08fd094..ae751d4386f 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -4636,7 +4636,9 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param Route

- Link to ./a + + Link to ./a + Link to c @@ -4656,7 +4658,12 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param A Route

- Link to .. from /param/foo/a + + Link to .. from /param/foo/a + + + Link to .. from current active route + ) @@ -4833,6 +4840,25 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( ) }) + test('should navigate to a parent link based on active location', async () => { + const router = setupRouter() + + render(() => ) + + window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) + + const relativeLink = await screen.findByTestId('link-to-previous') + + expect(relativeLink.getAttribute('href')).toBe(`${basepath}/param/foo/a`) + + // Click the link and ensure the new location + fireEvent.click(relativeLink) + + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/param/foo/a`), + ) + }) + test('should navigate to a child link based on pathname', async () => { const router = setupRouter() @@ -4937,9 +4963,600 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( fireEvent.click(parentLink) - waitFor(() => + await waitFor(() => expect(window.location.pathname).toBe(`${basepath}/param/bar/a/b`), ) }) }, ) + +describe('relative links to current route', () => { + test.each([true, false])( + 'should navigate to current route when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> + + Post + + + Search + + + Search2 + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () => ( + <> +
Post
+ + ), + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postButton = await screen.findByTestId('posts-link') + const searchButton = await screen.findByTestId('search-link') + const searchButton2 = await screen.findByTestId('search2-link') + + fireEvent.click(postButton) + + await waitFor(() => { + expect(window.location.pathname).toBe(`/post${tail}`) + }) + + fireEvent.click(searchButton) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + }) + + fireEvent.click(searchButton2) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value2' }) + }) + }, + ) + + test.each([true, false])( + 'should navigate to current route with search params when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> + + Post + + + Search + + + Search2 + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postButton = await screen.findByTestId('posts-link') + + fireEvent.click(postButton) + + await waitFor(() => { + expect(window.location.pathname).toBe(`/post${tail}`) + }) + + const searchButton = await screen.findByTestId('search-link') + + fireEvent.click(searchButton) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + }) + + const searchButton2 = await screen.findByTestId('search2-link') + + fireEvent.click(searchButton2) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value2' }) + }) + }, + ) + + test.each([true, false])( + 'should navigate to current route with changing path params when using "." in nested route structure with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> +

Index

+ + Posts + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + To first post + + + To second post + + + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + + Params: {params().postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([postsRoute.addChildren([postRoute])]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postsButton = await screen.findByTestId('posts-link') + + fireEvent.click(postsButton) + + await waitFor(() => { + expect(window.location.pathname).toEqual(`/posts${tail}`) + }) + + const firstPostButton = await screen.findByTestId('first-post-link') + + fireEvent.click(firstPostButton) + + await waitFor(() => { + expect(window.location.pathname).toEqual(`/posts/id1${tail}`) + }) + + const secondPostButton = await screen.findByTestId('second-post-link') + + fireEvent.click(secondPostButton) + + await waitFor(() => { + expect(window.location.pathname).toEqual(`/posts/id2${tail}`) + }) + }, + ) +}) + +describe('relative links to from route', () => { + test.each([true, false])( + 'should navigate to from route when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> + + Post + + + Search + + + Go To Home + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postButton = await screen.findByTestId('posts-link') + + fireEvent.click(postButton) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + }) + + const searchButton = await screen.findByTestId('search-link') + + fireEvent.click(searchButton) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + }) + + const homeBtn = await screen.findByTestId('home-link') + + fireEvent.click(homeBtn) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/`) + expect(router.state.location.search).toEqual({}) + }) + }, + ) + + test.each([true, false])( + 'should navigate to from route with path params when using "." in nested route structure with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> +

Index

+ + Posts + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + To first post + + + To second post + + + To posts list + + + + ) + } + + const PostDetailComponent = () => { + return ( + <> +

Post Detail

+ + To post info + + + To post notes + + + To index detail options + + + + ) + } + + const PostInfoComponent = () => { + return ( + <> +

Post Info

+ + ) + } + + const PostNotesComponent = () => { + return ( + <> +

Post Notes

+ + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const postDetailRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostDetailComponent, + }) + + const postInfoRoute = createRoute({ + getParentRoute: () => postDetailRoute, + path: 'info', + component: PostInfoComponent, + }) + + const postNotesRoute = createRoute({ + getParentRoute: () => postDetailRoute, + path: 'notes', + component: PostNotesComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postDetailRoute.addChildren([postInfoRoute, postNotesRoute]), + ]), + ]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postsButton = await screen.findByTestId('posts-link') + + fireEvent.click(postsButton) + + await waitFor(() => { + expect(screen.queryByTestId('posts-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) + }) + + const firstPostButton = await screen.findByTestId('first-post-link') + + fireEvent.click(firstPostButton) + + await waitFor(() => { + expect( + screen.queryByTestId('post-detail-index-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1${tail}`) + }) + + const postInfoButton = await screen.findByTestId('post-info-link') + + fireEvent.click(postInfoButton) + + await waitFor(() => { + expect(screen.queryByTestId('post-info-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1/info${tail}`) + }) + + const toPostDetailIndexButton = await screen.findByTestId( + 'to-post-detail-index-link', + ) + + fireEvent.click(toPostDetailIndexButton) + + await waitFor(() => { + expect( + screen.queryByTestId('post-detail-index-heading'), + ).toBeInTheDocument() + expect( + screen.queryByTestId('post-info-heading'), + ).not.toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1${tail}`) + }) + + const postNotesButton = await screen.findByTestId('post-notes-link') + + fireEvent.click(postNotesButton) + + await waitFor(() => { + expect(screen.queryByTestId('post-notes-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1/notes${tail}`) + }) + + const toPostsIndexButton = await screen.findByTestId( + 'to-posts-index-link', + ) + + fireEvent.click(toPostsIndexButton) + + await waitFor(() => { + expect(screen.queryByTestId('posts-index-heading')).toBeInTheDocument() + expect( + screen.queryByTestId('post-notes-heading'), + ).not.toBeInTheDocument() + expect( + screen.queryByTestId('post-detail-index-heading'), + ).not.toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) + }) + + const secondPostButton = await screen.findByTestId('second-post-link') + + fireEvent.click(secondPostButton) + + await waitFor(() => { + expect( + screen.queryByTestId('post-detail-index-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/2${tail}`) + }) + }, + ) +}) diff --git a/packages/solid-router/tests/redirect.test.tsx b/packages/solid-router/tests/redirect.test.tsx index d30265d70a3..941dec9909c 100644 --- a/packages/solid-router/tests/redirect.test.tsx +++ b/packages/solid-router/tests/redirect.test.tsx @@ -351,6 +351,7 @@ describe('redirect', () => { expect(currentRedirect.headers.get('Location')).toEqual('/about') expect(currentRedirect.options).toEqual({ _fromLocation: { + fullPath: '/', hash: '', href: '/', pathname: '/', @@ -361,6 +362,7 @@ describe('redirect', () => { __TSR_key: currentRedirect.options._fromLocation!.state.__TSR_key, key: currentRedirect.options._fromLocation!.state.key, }, + url: new URL('http://localhost/'), }, href: '/about', to: '/about', diff --git a/packages/solid-router/tests/router.test.tsx b/packages/solid-router/tests/router.test.tsx index d0a04bfa51e..31a94432061 100644 --- a/packages/solid-router/tests/router.test.tsx +++ b/packages/solid-router/tests/router.test.tsx @@ -333,7 +333,7 @@ describe('encoding: URL param segment for /posts/$slug', () => { await router.load() - expect(router.state.location.pathname).toBe('/posts/🚀') + expect(router.state.location.pathname).toBe('/posts/%F0%9F%9A%80') }) it('state.location.pathname, should have the params.slug value of "100%25"', async () => { @@ -578,7 +578,7 @@ describe('encoding: URL splat segment for /$', () => { await router.load() - expect(router.state.location.pathname).toBe('/🚀') + expect(router.state.location.pathname).toBe('/%F0%9F%9A%80') }) it('state.location.pathname, should have the params._splat value of "100%25"', async () => { @@ -637,7 +637,7 @@ describe('encoding: URL splat segment for /$', () => { await router.load() expect(router.state.location.pathname).toBe( - '/framework/react/guide/file-based-routing tanstack', + '/framework/react/guide/file-based-routing%20tanstack', ) }) @@ -722,78 +722,64 @@ describe('encoding: URL path segment', () => { it.each([ { input: '/path-segment/%C3%A9', - output: '/path-segment/é', - type: 'encoded', + output: '/path-segment/%C3%A9', }, { input: '/path-segment/é', - output: '/path-segment/é', - type: 'not encoded', + output: '/path-segment/%C3%A9', }, { input: '/path-segment/100%25', // `%25` = `%` output: '/path-segment/100%25', - type: 'not encoded', }, { input: '/path-segment/100%25%25', output: '/path-segment/100%25%25', - type: 'not encoded', }, { input: '/path-segment/100%26', // `%26` = `&` output: '/path-segment/100%26', - type: 'not encoded', }, { input: '/path-segment/%F0%9F%9A%80', - output: '/path-segment/🚀', - type: 'encoded', + output: '/path-segment/%F0%9F%9A%80', }, { input: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon', - output: '/path-segment/🚀to%2Fthe%2Fmoon', - type: 'encoded', + output: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon', }, { input: '/path-segment/%25%F0%9F%9A%80to%2Fthe%2Fmoon', - output: '/path-segment/%25🚀to%2Fthe%2Fmoon', - type: 'encoded', + output: '/path-segment/%25%F0%9F%9A%80to%2Fthe%2Fmoon', }, { input: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25', - output: '/path-segment/🚀to%2Fthe%2Fmoon%25', - type: 'encoded', + output: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25', }, { input: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25%F0%9F%9A%80to%2Fthe%2Fmoon', - output: '/path-segment/🚀to%2Fthe%2Fmoon%25🚀to%2Fthe%2Fmoon', - type: 'encoded', + output: + '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon%25%F0%9F%9A%80to%2Fthe%2Fmoon', }, { input: '/path-segment/🚀', - output: '/path-segment/🚀', - type: 'not encoded', + output: '/path-segment/%F0%9F%9A%80', }, { input: '/path-segment/🚀to%2Fthe%2Fmoon', - output: '/path-segment/🚀to%2Fthe%2Fmoon', - type: 'not encoded', + output: '/path-segment/%F0%9F%9A%80to%2Fthe%2Fmoon', }, - ])( - 'should resolve $input to $output when the path segment is $type', - async ({ input, output }) => { - const { router } = createTestRouter({ - history: createMemoryHistory({ initialEntries: [input] }), - }) + ])('should resolve $input to $output', async ({ input, output }) => { + const { router } = createTestRouter({ + history: createMemoryHistory({ initialEntries: [input] }), + }) - render(() => ) - await router.load() + render(() => ) + await router.load() - expect(router.state.location.pathname).toBe(output) - }, - ) + expect(router.state.location.pathname).toBe(output) + }) }) describe('router emits events during rendering', () => { diff --git a/packages/solid-router/tests/useNavigate.test.tsx b/packages/solid-router/tests/useNavigate.test.tsx index cf9ef4831db..d3eb186cb21 100644 --- a/packages/solid-router/tests/useNavigate.test.tsx +++ b/packages/solid-router/tests/useNavigate.test.tsx @@ -1,6 +1,6 @@ import * as Solid from 'solid-js' import '@testing-library/jest-dom/vitest' -import { afterEach, describe, expect, test } from 'vitest' +import { afterEach, beforeEach, describe, expect, test } from 'vitest' import { cleanup, fireEvent, @@ -13,6 +13,7 @@ import { z } from 'zod' import { Outlet, RouterProvider, + createBrowserHistory, createRootRoute, createRoute, createRouteMask, @@ -21,8 +22,17 @@ import { useNavigate, useParams, } from '../src' +import type { RouterHistory } from '../src' + +let history: RouterHistory + +beforeEach(() => { + history = createBrowserHistory() + expect(window.location.pathname).toBe('/') +}) afterEach(() => { + history.destroy() window.history.replaceState(null, 'root', '/') cleanup() }) @@ -1509,7 +1519,11 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param Route

- + ) @@ -1668,6 +1692,23 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( ) }) + test('should navigate to a parent link based on active location', async () => { + const router = setupRouter() + + render(() => ) + + window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) + + const relativeLink = await screen.findByTestId('link-to-previous') + + // Click the link and ensure the new location + fireEvent.click(relativeLink) + + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/param/foo/a`), + ) + }) + test('should navigate to same route with different params', async () => { const router = setupRouter() @@ -1686,3 +1727,799 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( }) }, ) + +describe('relative navigate to current route', () => { + test.each([true, false])( + 'should navigate to current route with search params when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> + + + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postButton = await screen.findByTestId('posts-btn') + + fireEvent.click(postButton) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + }) + + const searchButton = await screen.findByTestId('search-btn') + + fireEvent.click(searchButton) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + }) + + const searchButton2 = await screen.findByTestId('search2-btn') + + fireEvent.click(searchButton2) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value2' }) + }) + }, + ) + + test.each([true, false])( + 'should navigate to current route with changing path params when using "." in nested route structure with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + const navigate = useNavigate() + + return ( + <> +

Posts

+ + + + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + + Params: {params().postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([postsRoute.addChildren([postRoute])]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postsButton = await screen.findByTestId('posts-btn') + + fireEvent.click(postsButton) + + await waitFor(() => { + expect(screen.queryByTestId('posts-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) + }) + + const firstPostButton = await screen.findByTestId('first-post-btn') + + fireEvent.click(firstPostButton) + + await waitFor(() => { + expect(screen.queryByTestId('post-id1')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/id1${tail}`) + }) + + const secondPostButton = await screen.findByTestId('second-post-btn') + + fireEvent.click(secondPostButton) + + await waitFor(() => { + expect(screen.queryByTestId('post-id2')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/id2${tail}`) + }) + }, + ) + + test.each([true, false])( + 'should navigate to current route with search params when using "." in nested route structure from non-Index Route with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const PostsComponent = () => { + const navigate = postsRoute.useNavigate() + return ( + <> +

Posts

+ + + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const useModal = (name: string) => { + const currentOpen = postRoute.useSearch({ + select: (search) => search[`_${name}`], + }) + + const navigate = useNavigate() + + const setModal = (open: boolean) => { + navigate({ + to: '.', + search: (prev: {}) => ({ + ...prev, + [`_${name}`]: open ? true : undefined, + }), + resetScroll: false, + }) + } + + return [currentOpen, setModal] as const + } + + function DetailComponent(props: { id: string }) { + const params = useParams({ strict: false }) + const [currentTest, setTest] = useModal('test') + + return ( + <> +
+ Post Path "/{params().postId}/detail-{props.id}"! +
+ {currentTest() ? ( + + ) : ( + + )} + + ) + } + + const PostComponent = () => { + const params = useParams({ strict: false }) + + return ( +
+
Post "{params().postId}"!
+ + +
+ ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + validateSearch: z.object({ + _test: z.boolean().optional(), + }), + }) + + const detailRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'detail', + component: () => , + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postRoute.addChildren([detailRoute])]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postsButton = await screen.findByTestId('posts-btn') + + fireEvent.click(postsButton) + + await waitFor(() => { + expect(screen.queryByTestId('posts-index-heading')).toBeInTheDocument() + }) + + const post1Button = await screen.findByTestId('first-post-btn') + + fireEvent.click(post1Button) + + await waitFor(() => { + expect(screen.queryByTestId('post-heading')).toBeInTheDocument() + expect(screen.queryByTestId('detail-heading-1')).toBeInTheDocument() + expect(screen.queryByTestId('detail-heading-2')).toBeInTheDocument() + expect(screen.queryByTestId('detail-heading-1')).toHaveTextContent( + 'Post Path "/id1/detail-1', + ) + expect(screen.queryByTestId('detail-heading-2')).toHaveTextContent( + 'Post Path "/id1/detail-2', + ) + }) + + const detail1AddBtn = await screen.findByTestId('detail-btn-add-1') + + fireEvent.click(detail1AddBtn) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) + expect(router.state.location.search).toEqual({ _test: true }) + }) + + const detail1RemoveBtn = await screen.findByTestId('detail-btn-remove-1') + + fireEvent.click(detail1RemoveBtn) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) + expect(router.state.location.search).toEqual({}) + }) + + const detail2AddBtn = await screen.findByTestId('detail-btn-add-2') + + fireEvent.click(detail2AddBtn) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) + expect(router.state.location.search).toEqual({ _test: true }) + }) + }, + ) +}) + +describe('relative navigate to from route', () => { + test.each([true, false])( + 'should navigate to from route when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> + + + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postButton = await screen.findByTestId('posts-btn') + + fireEvent.click(postButton) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + }) + + const searchButton = await screen.findByTestId('search-btn') + + fireEvent.click(searchButton) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + }) + + const homeBtn = await screen.findByTestId('home-btn') + + fireEvent.click(homeBtn) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/`) + expect(router.state.location.search).toEqual({}) + }) + }, + ) + + test.each([true, false])( + 'should navigate to from route with path params when using "." in nested route structure with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + const navigate = postsRoute.useNavigate() + return ( + <> +

Posts

+ + + + + + ) + } + + const PostDetailComponent = () => { + const navigate = postDetailRoute.useNavigate() + return ( + <> +

Post Detail

+ + + + + + ) + } + + const PostInfoComponent = () => { + return ( + <> +

Post Info

+ + ) + } + + const PostNotesComponent = () => { + return ( + <> +

Post Notes

+ + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const postDetailRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostDetailComponent, + }) + + const postInfoRoute = createRoute({ + getParentRoute: () => postDetailRoute, + path: 'info', + component: PostInfoComponent, + }) + + const postNotesRoute = createRoute({ + getParentRoute: () => postDetailRoute, + path: 'notes', + component: PostNotesComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postDetailRoute.addChildren([postInfoRoute, postNotesRoute]), + ]), + ]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postsButton = await screen.findByTestId('posts-btn') + + fireEvent.click(postsButton) + + await waitFor(() => { + expect(screen.queryByTestId('posts-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) + }) + + const firstPostButton = await screen.findByTestId('first-post-btn') + + fireEvent.click(firstPostButton) + + await waitFor(() => { + expect( + screen.queryByTestId('post-detail-index-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1${tail}`) + }) + + const postInfoButton = await screen.findByTestId('post-info-btn') + + fireEvent.click(postInfoButton) + + await waitFor(() => { + expect(screen.queryByTestId('post-info-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1/info${tail}`) + }) + + const toPostDetailIndexButton = await screen.findByTestId( + 'to-post-detail-index-btn', + ) + + fireEvent.click(toPostDetailIndexButton) + + await waitFor(() => { + expect( + screen.queryByTestId('post-detail-index-heading'), + ).toBeInTheDocument() + expect( + screen.queryByTestId('post-info-heading'), + ).not.toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1${tail}`) + }) + + const postNotesButton = await screen.findByTestId('post-notes-btn') + + fireEvent.click(postNotesButton) + + await waitFor(() => { + expect(screen.queryByTestId('post-notes-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1/notes${tail}`) + }) + + const toPostsIndexButton = await screen.findByTestId('to-posts-index-btn') + + fireEvent.click(toPostsIndexButton) + + await waitFor(() => { + expect(screen.queryByTestId('posts-index-heading')).toBeInTheDocument() + expect( + screen.queryByTestId('post-notes-heading'), + ).not.toBeInTheDocument() + expect( + screen.queryByTestId('post-detail-index-heading'), + ).not.toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) + }) + + const secondPostButton = await screen.findByTestId('second-post-btn') + + fireEvent.click(secondPostButton) + + await waitFor(() => { + expect( + screen.queryByTestId('post-detail-index-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/2${tail}`) + }) + }, + ) +}) diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index b401284d940..59de97a0163 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -10,6 +10,7 @@ import { isResolvedRedirect, joinPaths, processRouteTree, + rewriteBasepath, trimPath, } from '@tanstack/router-core' import { attachRouterServerSsrUtils } from '@tanstack/router-core/ssr/server' @@ -334,12 +335,16 @@ async function handleServerRoutes(opts: { basePath: string executeRouter: () => Promise }) { - const url = new URL(opts.request.url) - const pathname = url.pathname + let href = new URL(opts.request.url).href + + if (opts.basePath) { + href = rewriteBasepath(opts.basePath).fromHref?.({ href }) || href + } + + const pathname = new URL(href).pathname const serverTreeResult = getMatchedRoutes({ pathname, - basepath: opts.basePath, caseSensitive: true, routesByPath: opts.processedServerRouteTree.routesByPath, routesById: opts.processedServerRouteTree.routesById,