diff --git a/.changeset/cold-schools-relate.md b/.changeset/cold-schools-relate.md new file mode 100644 index 0000000000..d45d6f8fb1 --- /dev/null +++ b/.changeset/cold-schools-relate.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Fix clientLoader.hydrate when an ancestor route is also hydrating a clientLoader diff --git a/.changeset/passthrough-reqeusts.md b/.changeset/passthrough-reqeusts.md new file mode 100644 index 0000000000..a466f7ec0e --- /dev/null +++ b/.changeset/passthrough-reqeusts.md @@ -0,0 +1,37 @@ +--- +"@react-router/dev": patch +"react-router": patch +--- + +Add `future.unstable_passThroughRequests` flag + +By default, React Router normalizes the `request.url` passed to your `loader`, `action`, and `middleware` functions by removing React Router's internal implementation details (`.data` suffixes, `index` + `_routes` query params). + +Enabling this flag removes that normalization and passes the raw HTTP `request` instance to your handlers. This provides a few benefits: + +- Reduces server-side overhead by eliminating multiple `new Request()` calls on the critical path +- Allows you to distinguish document from data requests in your handlers base don the presence of a `.data` suffix (useful for observability purposes) + +If you were previously relying on the normalization of `request.url`, you can switch to use the new sibling `unstable_url` parameter which contains a `URL` instance representing the normalized location: + +```tsx +// ❌ Before: you could assume there was no `.data` suffix in `request.url` +export async function loader({ request }: Route.LoaderArgs) { + let url = new URL(request.url); + if (url.pathname === "/path") { + // This check will fail with the flag enabled because the `.data` suffix will + // exist on data requests + } +} + +// ✅ After: use `unstable_url` for normalized routing logic and `request.url` +// for raw routing logic +export async function loader({ request, unstable_url }: Route.LoaderArgs) { + if (unstable_url.pathname === "/path") { + // This will always have the `.data` suffix stripped + } + + // And now you can distinguish between document versus data requests + let isDataRequest = new URL(request.url).pathname.endsWith(".data"); +} +``` diff --git a/.changeset/remove-agnostic-types.md b/.changeset/remove-agnostic-types.md new file mode 100644 index 0000000000..268c8f3fb5 --- /dev/null +++ b/.changeset/remove-agnostic-types.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Internal refactor to consolidate framework-agnostic/React-specific route type layers - no public API changes diff --git a/.changeset/sweet-houses-kick.md b/.changeset/sweet-houses-kick.md new file mode 100644 index 0000000000..daeca90ffd --- /dev/null +++ b/.changeset/sweet-houses-kick.md @@ -0,0 +1,5 @@ +--- +"create-react-router": patch +--- + +chore: replace chalk with picocolors diff --git a/.changeset/unstable-url.md b/.changeset/unstable-url.md new file mode 100644 index 0000000000..77b7d1ad12 --- /dev/null +++ b/.changeset/unstable-url.md @@ -0,0 +1,10 @@ +--- +"@react-router/dev": patch +"react-router": patch +--- + +Add a new `unstable_url: URL` parameter to route handler methods (`loader`, `action`, `middleware`, etc.) representing the normalized URL the application is navigating to or fetching, with React Router implementation details removed (`.data`suffix, `index`/`_routes` query params) + +This is being added alongside the new `future.unstable_passthroughRequests` future flag so that users still have a way to access the normalized URL when that flag is enabled and non-normalized `request`'s are being passed to your handlers. When adopting this flag, you will only need to start leveraging this new parameter if you are relying on the normalization of `request.url` in your application code. + +If you don't have the flag enabled, then `unstable_url` will match `request.url`. diff --git a/.gitignore b/.gitignore index 9209f20a17..eed2c1bf39 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ worker-configuration.d.ts # v7 reference docs /public + +.claude/settings.local.json \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..50ee466624 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,22 @@ +# React Router Project Instructions + +## Session Start + +**REQUIRED**: Read [./AGENTS.md](./AGENTS.md) at the start of every session. It contains: + +- Project architecture and key files +- React Router modes (Declarative, Data, Framework, RSC) +- Build/test commands (Jest unit tests, Playwright integration tests with `--project chromium`) +- Testing patterns and conventions +- Documentation guidelines + +## During Work + +**Always consult AGENTS.md** when you need to: + +- Run tests or builds +- Understand which mode(s) a feature applies to +- Find key file locations +- Understand testing patterns + +Do not guess at commands - reference AGENTS.md for the correct syntax. diff --git a/contributors.yml b/contributors.yml index 31584cfcfc..0042fd098f 100644 --- a/contributors.yml +++ b/contributors.yml @@ -237,6 +237,7 @@ - KostiantynPopovych - KubasuIvanSakwa - KutnerUri +- kuzznicki - kylegirard - LadyTsukiko - landisdesign @@ -301,6 +302,7 @@ - mtendekuyokwa19 - mtliendo - namoscato +- Nandann018-ux - nanianlisao - ned-park - nenene3 @@ -356,6 +358,7 @@ - robbtraister - RobHannay - robinvdvleuten +- roli-lpci - rossipedia - rtmann - rtzll diff --git a/decisions/0016-plan-remove-agnostic-types.md b/decisions/0016-plan-remove-agnostic-types.md new file mode 100644 index 0000000000..9640c42c86 --- /dev/null +++ b/decisions/0016-plan-remove-agnostic-types.md @@ -0,0 +1,368 @@ +# Plan: Remove "Agnostic" Layer from RouteObject/RouteMatch Types + +**Goal**: Consolidate the framework-agnostic and React-specific type layers into a single React-aware layer without breaking the public API. + +## Background + +React Router historically maintained a framework-agnostic layer to support multiple UI frameworks. Since we no longer support other frameworks, we can simplify our type hierarchy by removing this abstraction. + +### Current Structure + +**Agnostic Layer** (`lib/router/utils.ts`): + +- `AgnosticBaseRouteObject` - Base route properties (loader, action, etc.) +- `AgnosticIndexRouteObject` - Index route without children +- `AgnosticNonIndexRouteObject` - Route with optional children +- `AgnosticRouteObject` - Union of index/non-index routes +- `AgnosticDataIndexRouteObject` - Index route with required `id` +- `AgnosticDataNonIndexRouteObject` - Non-index route with required `id` +- `AgnosticDataRouteObject` - Data route with required `id` +- `AgnosticRouteMatch` - Match result +- `AgnosticDataRouteMatch` - Data route match result + +**React Layer** (`lib/context.ts`): + +- `IndexRouteObject` - Extends agnostic type + React fields (`element`, `Component`, `errorElement`, `ErrorBoundary`, `hydrateFallbackElement`, `HydrateFallback`) +- `NonIndexRouteObject` - Extends agnostic type + React fields +- `RouteObject` - Union of index/non-index +- `DataRouteObject` - Route with required `id` +- `RouteMatch` - Extends `AgnosticRouteMatch` +- `DataRouteMatch` - Extends `AgnosticRouteMatch` + +### Problem + +The two-layer structure adds complexity: + +1. Duplicate type definitions with subtle differences +2. Requires understanding inheritance hierarchy +3. The "agnostic" layer no longer serves a purpose +4. React types extend agnostic types just to add React-specific fields + +## Proposed Solution + +**Strategy**: Consolidate types by renaming the "agnostic" types to be the primary React-aware types, eliminating the prefix entirely. + +### Approach: Rename and Merge (Alternative 1) + +Since `Agnostic*` types are not exported from the public API, we can safely rename them to become the primary `RouteObject`, `RouteMatch`, etc. types. The React layer in `context.ts` will re-export these types, maintaining the public API contract. + +#### Step 1: Update `lib/router/utils.ts` + +Rename existing `Agnostic*` types and add React-specific fields: + +```typescript +/** + * Base RouteObject with common props shared by all types of routes + */ +type BaseRouteObject = { + caseSensitive?: boolean; + path?: string; + id?: string; + middleware?: MiddlewareFunction[]; + loader?: LoaderFunction | boolean; + action?: ActionFunction | boolean; + hasErrorBoundary?: boolean; + shouldRevalidate?: ShouldRevalidateFunction; + handle?: any; + lazy?: LazyRouteDefinition; + // React-specific fields (merged from context.ts) + element?: React.ReactNode | null; + hydrateFallbackElement?: React.ReactNode | null; + errorElement?: React.ReactNode | null; + Component?: React.ComponentType | null; + HydrateFallback?: React.ComponentType | null; + ErrorBoundary?: React.ComponentType | null; +}; + +/** + * Index routes must not have children + */ +export type IndexRouteObject = BaseRouteObject & { + children?: undefined; + index: true; +}; + +/** + * Non-index routes may have children, but cannot have index + */ +export type NonIndexRouteObject = BaseRouteObject & { + children?: RouteObject[]; + index?: false; +}; + +/** + * A route object represents a logical route, with (optionally) its child + * routes organized in a tree-like structure. + */ +export type RouteObject = IndexRouteObject | NonIndexRouteObject; + +export type DataIndexRouteObject = IndexRouteObject & { + id: string; +}; + +export type DataNonIndexRouteObject = NonIndexRouteObject & { + children?: DataRouteObject[]; + id: string; +}; + +/** + * A data route object, which is just a RouteObject with a required unique ID + */ +export type DataRouteObject = DataIndexRouteObject | DataNonIndexRouteObject; + +/** + * A RouteMatch contains info about how a route matched a URL. + */ +export interface RouteMatch< + ParamKey extends string = string, + RouteObjectType extends RouteObject = RouteObject, +> { + /** + * The names and values of dynamic parameters in the URL. + */ + params: Params; + /** + * The portion of the URL pathname that was matched. + */ + pathname: string; + /** + * The portion of the URL pathname that was matched before child routes. + */ + pathnameBase: string; + /** + * The route object that was used to match. + */ + route: RouteObjectType; +} + +export interface DataRouteMatch extends RouteMatch {} +``` + +Key changes: + +- `AgnosticBaseRouteObject` → `BaseRouteObject` (add React fields) +- `AgnosticIndexRouteObject` → `IndexRouteObject` +- `AgnosticNonIndexRouteObject` → `NonIndexRouteObject` +- `AgnosticRouteObject` → `RouteObject` +- `AgnosticDataIndexRouteObject` → `DataIndexRouteObject` +- `AgnosticDataNonIndexRouteObject` → `DataNonIndexRouteObject` +- `AgnosticDataRouteObject` → `DataRouteObject` +- `AgnosticRouteMatch` → `RouteMatch` +- `AgnosticDataRouteMatch` → `DataRouteMatch` + +#### Step 2: Update `lib/context.ts` + +Convert to simple re-exports since the types now exist in `utils.ts`: + +```typescript +// Re-export route types from utils (they're now React-aware) +export type { + IndexRouteObject, + NonIndexRouteObject, + RouteObject, + DataRouteObject, + RouteMatch, + DataRouteMatch, +} from "./router/utils"; + +// PatchRoutesOnNavigationFunction types can now be simplified +export type PatchRoutesOnNavigationFunctionArgs = + AgnosticPatchRoutesOnNavigationFunctionArgs; + +export type PatchRoutesOnNavigationFunction = + AgnosticPatchRoutesOnNavigationFunction; +``` + +Remove duplicate type definitions and re-export from `utils.ts`: + +**Before:** +```typescriptimport or reference `Agnostic\*` types: + +**Files to update:** + +1. `lib/router/router.ts` - Update all `AgnosticDataRouteObject` → `DataRouteObject`, `AgnosticDataRouteMatch` → `DataRouteMatch`, `AgnosticRouteObject` → `RouteObject` +2. `lib/router/instrumentation.ts` - Update route type references +3. `lib/router/utils.ts` - Update internal function signatures, type parameters, and helper functions +4. `lib/dom/ssr/links.ts` - Update `AgnosticDataRouteMatch` → `DataRouteMatch` +5. `lib/rsc/server.rsc.ts` - Update `AgnosticDataRouteMatch` → `DataRouteMatch` +6. `lib/context.ts` - Update `AgnosticPatchRoutesOnNavigationFunctionArgs` references (or rename those too) +7. `__tests__/**/*.ts` - Update test type references (~40 files) + +**Note**: Some files may still reference `Agnostic*` types that are part of function/generic names (like `AgnosticPatchRoutesOnNavigationFunction`). These can be renamed in a follow-up or kept as-is if the name makes sense in context. + +```` + +**After:** +```typescript +// Re-export route types from utils (they're now React-aware) +export type { + IndexRouteObject, + NonIndexRouteObject, + RouteObject, + DataRouteObject, + RouteMatch, + DataRouteMatch, +} from "./router/utils"; + +// PatchRoutesOnNavigationFunction types remain the same +export type PatchRoutesOnNavigationFunctionArgs = + AgnosticPatchRoutesOnNavigationFunctionArgs; + +export type PatchRoutesOnNavigationFunction = + AgnosticPatchRoutesOnNavigationFunction; +```` + +This eliminates ~70 lines of duplicate type definitions.ction matchRoutes< +RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject + +> (...) + +// After +function matchRoutes< +RouteObjectType extends RouteObject = RouteObject + +> (...) + +```` + +#### Step 5: Update Helper Functions + +Update type guards and helper functions: + +```typescript +// Before +function isIndexRoute( + route: AgnosticRouteObject, +): route is AgnosticIndexRouteObject + +// After +function isIndexRoute( + route: RouteObject, +): route is IndexRouteObject +```` + +**No migration needed!** + +Since `Agnostic*` types are not part of the public API (not exported from `index.ts`), this is purely an internal refactoring. External consumers only use `RouteObject`, `DataRouteObject`, `RouteMatch`, and `DataRouteMatch` which remain unchanged in the public API.`RouteMatch`, `DataRouteMatch`) still exported + +**Internal API**: ⚠️ Requires updates + +- Functions using `Agnostic*` types need updating +- Tests using `Agnostic*` types need updating +- Backward compatibility aliases prevent hard breaks + +**Type Safety**: ✅ Maintained + +- All types remain strongly typed +- React-specific fields properly typed throughout +- No loss of type information + +### Migration Path for Consumers + +For any external code using the `Agnostic*` types (unlikely since they're not exported): + +1. **Short term**: Backward compatibility aliases work transparently +2. **Medium term**: Types marked `@internal` and `@deprecated` +3. **Long term**: Remove aliases in next major version + +### Testing Strategy + +1. **Type checks**: Ensure `pnpm typecheck` passes +2. **Unit tests**: Ensure `pnpm test` passes +3. **Integration tests**: Ensure `pnpm test:integration --project chromium` passes +4. **Build**: Ensure `pnpm build` succeeds +5. **Docs generation**: Ensure `pnpm docs` works correctly + +### Implementation Order + +### Implementation Order + +1. ✅ Create this plan document +2. ✅ Update `lib/router/utils.ts` - Rename `Agnostic*` types to remove prefix, add React fields to base types +3. ✅ Update `lib/context.ts` - Remove duplicate definitions, convert to re-exports +4. ✅ Update `lib/router/router.ts` - Replace all `Agnostic*` type references +5. ✅ Update `lib/router/instrumentation.ts` - Replace `Agnostic*` type references +6. ✅ Update `lib/dom/ssr/links.ts` - Replace `AgnosticDataRouteMatch` references +7. ✅ Update `lib/rsc/server.rsc.ts` - Replace `AgnosticDataRouteMatch` references +8. ✅ Update test files - Replace `Agnostic*` type references (~40 files) - No test files needed updating +9. ✅ Run full test suite (`pnpm typecheck && pnpm test && pnpm test:integration --project chromium`) - All passing +10. ✅ Update JSDoc comments if any reference "agnostic" or "framework-agnostic" +11. ✅ Create changeset + +### Risks & Mitigations + +Accidentally changing public API behavior + +- **Mitigation**: Public exports in `index.ts` remain unchanged, only importing from same location (`./lib/context`) + +**Risk**: Type inference changes in complex generic scenarios + +- **Mitigation**: All type fields remain identical, just location changes. Run full typecheck suite. + +**Risk**: Complex merge conflicts if done across multiple PRs + +- **Mitigation**: Complete in single atomic PR + +**Risk**: Missing some `Agnostic*` type references in large codebase + +- **Mitigation**: TypeScript compiler will catch all references; can also use global find/replace to identify all usages first +- **Mitigation**: Thorough testing at each step, update tests incrementally + +## Alternative Approaches Considered + +### Alternative 1: Rename to Remove "Agnostic" Prefix + +Move all types f2: In-Place Consolidation with Aliases + +Keep types in their current files but merge the layers by making the "agnostic" types React-aware and converting the React layer to aliases: + +- `AgnosticBaseRouteObject` stays in `utils.ts` but gains React fields +- React types in `context.ts` become aliases: `export type RouteObject = AgnosticRouteObject` +- Add `@deprecated` backward compatibility aliases + +**Pros**: Gradual migration path with deprecated aliases +**Cons**: Keeps confusing "Agnostic" naming, unnecessary since types aren't exported + +### Alternative 3but make React types standalone (not extending): + +- Both define all fields independently +- No inheritance relationship + +**Pros**: Minimal code changes +**Cons**: Doesn't reduce duplication, doesn't achieve goal + +### Alternative 3: Gradual Deprecation Path + +4: Gradual Deprecation Path + +Add new types, deprecate old ones, remove later: + +- Create `RouteObjectV2` with merged types +- Deprecate `AgnosticRouteObject` and React `RouteObject` +- Remove in next major + +**Pros**: Maximum safety, clear migration path +**Cons**: Temporary duplication, longer timeline, unnecessary complexity + +## Decision + +**Selected Approach**: Rename and Merge (Alternative 1, now main proposal) + +**Rationale**: + +1. ✅ Achieves goal of removing agnostic layer completely +2. ✅ **Non-breaking** - `Agnostic*` types are not in public API +3. ✅ Cleaner - removes confusing "Agnostic" prefix entirely +4. ✅ Simpler - no deprecated aliases needed since types aren't exported +5. ✅ Single atomic change is easier to review and test +6. ✅ Types live in one logical place (`utils.ts`) +7. ✅ Reduces ~70 lines of duplicate type definitions in `context.ts` + +## Follow-up Work + +After this refactoring: + +1. Consider if `RouteManifest` generic parameter is still needed +2. Evaluate if other "agnostic" patterns exist elsewhere +3. Update internal documentation about type architecture +4. Consider removing `@internal` aliases in v8 diff --git a/docs/api/components/Link.md b/docs/api/components/Link.md index 3aae2112eb..cd92fa643c 100644 --- a/docs/api/components/Link.md +++ b/docs/api/components/Link.md @@ -236,7 +236,7 @@ standard revalidation behavior. [modes: framework, data] -Masked path for for this navigation, when you want to navigate the router to +Masked path for this navigation, when you want to navigate the router to one location but display a separate location in the URL bar. This is useful for contextual navigations such as opening an image in a modal diff --git a/docs/api/utils/matchRoutes.md b/docs/api/utils/matchRoutes.md index 6fd028649b..7c31862bc7 100644 --- a/docs/api/utils/matchRoutes.md +++ b/docs/api/utils/matchRoutes.md @@ -42,13 +42,11 @@ matchRoutes(routes, "/dashboard"); // [rootMatch, dashboardMatch] ## Signature ```tsx -function matchRoutes< - RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject, ->( +function matchRoutes( routes: RouteObjectType[], locationArg: Partial | string, basename = "/", -): AgnosticRouteMatch[] | null +): RouteMatch[] | null ``` ## Params diff --git a/docs/upgrading/future.md b/docs/upgrading/future.md index afdbbf9e61..71c265bf83 100644 --- a/docs/upgrading/future.md +++ b/docs/upgrading/future.md @@ -5,7 +5,7 @@ order: 1 # Future Flags and Deprecations -This guide walks you through the process of adopting future flags in your React Router app. By following this strategy, you will be able to upgrade to the next major version of React Router with minimal changes. To read more about future flags see [API Development Strategy](../community/api-development-strategy). +This guide walks you through the process of adopting future flags in your React Router app. By following this strategy, you will be able to upgrade to the next major version of React Router with minimal changes. To read more about future flags see [API Development Strategy][api-development-strategy]. We highly recommend you make a commit after each step and ship it instead of doing everything all at once. Most flags can be adopted in any order, with exceptions noted below. @@ -104,5 +104,75 @@ export default { No code changes are required unless you have custom Vite configuration that needs to be updated for the [Environment API][vite-environment]. Most users won't need to make any changes. +## Unstable Future Flags (Optional) + +We document some [unstable] flags here as a reference for folks contributing to the project via beta testing, but they are not generally recommended for production use and may having breaking changes patch/minor releases - adopt with caution! + +### future.unstable_passThroughRequests + +[MODES: framework] + +
+
+ +**Background** + +By default, React Router normalizes the `request.url` passed to your `loader`, `action`, and `middleware` functions by removing React Router's internal implementation details. Specifically, it removes `.data` suffixes and internal search parameters like `?index` and `?_routes`. + +This flag eliminates that normalization and passes the raw HTTP `request` instance to your handlers. This provides a few benefits: + +- Reduces server-side overhead by eliminating multiple `new Request()` calls on the critical path +- Allows you to distinguish document from data requests in your handlers base don the presence of a `.data` suffix (useful for [observability] purposes) + +If you were previously relying on the normalization of `request.url`, you can switch to use the new sibling `unstable_url` parameter which contains a `URL` instance representing the normalized location. + +👉 **Enable the Flag** + +```ts filename=react-router.config.ts +import type { Config } from "@react-router/dev/config"; + +export default { + future: { + unstable_passThroughRequests: true, + }, +} satisfies Config; +``` + +**Update your Code** + +If your code relies on inspecting the request URL, you should review it for any assumptions about the URL format: + +```tsx +// ❌ Before: assuming no `.data` suffix in `request.url` pathname +export async function loader({ + request, +}: Route.LoaderArgs) { + let url = new URL(request.url); + if (url.pathname === "/path") { + // This check might now behave differently because the request pathname will + // contain the `.data` suffix on data requests + } +} + +// ✅ After: use `unstable_url` for normalized routing logic and `request.url` +// for raw routing logic +export async function loader({ + request, + unstable_url, +}: Route.LoaderArgs) { + if (unstable_url.pathname === "/path") { + // This will always have the `.data` suffix stripped + } + + // And now you can distinguish between document versus data requests + let isDataRequest = new URL( + request.url, + ).pathname.endsWith(".data"); +} +``` + +[api-development-strategy]: ../community/api-development-strategy +[unstable]: ../community/api-development-strategy#unstable-flags +[observability]: ../how-to/instrumentation [Response]: https://developer.mozilla.org/en-US/docs/Web/API/Response [vite-environment]: https://vite.dev/guide/api-environment diff --git a/examples/modal/package-lock.json b/examples/modal/package-lock.json index 08cb323fe0..da288722cb 100644 --- a/examples/modal/package-lock.json +++ b/examples/modal/package-lock.json @@ -6,16 +6,15 @@ "": { "name": "modal", "dependencies": { - "@reach/dialog": "0.18.0", - "react": "^17.0.2", - "react-dom": "^17.0.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-router-dom": "^6.15.0" }, "devDependencies": { "@rollup/plugin-replace": "^5.0.2", "@types/node": "18.x", - "@types/react": "^17.0.59", - "@types/react-dom": "^17.0.20", + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", "@vitejs/plugin-react": "^3.0.1", "typescript": "^4.9.5", "vite": "^4.0.4" @@ -314,17 +313,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/runtime": { - "version": "7.22.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.3.tgz", - "integrity": "sha512-XsDuspWKLUsxwCp6r7EhsExHtYfbe5oAGQ19kqngTdCPUoPQzOPdUbD/pB9PJiwb2ptYKQDjSJT3R6dC+EPqfQ==", - "dependencies": { - "regenerator-runtime": "^0.13.11" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.21.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.21.9.tgz", @@ -780,51 +768,6 @@ "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", "dev": true }, - "node_modules/@reach/dialog": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@reach/dialog/-/dialog-0.18.0.tgz", - "integrity": "sha512-hWhzmBK8VJj+yf6OivFqHFZIV4q9TISZrkPaglKE5oFYtrr75lxWjrBTA+BshL0r/FfKodvNtdT8yq4vj/6Gcw==", - "dependencies": { - "@reach/polymorphic": "0.18.0", - "@reach/portal": "0.18.0", - "@reach/utils": "0.18.0", - "react-focus-lock": "2.5.2", - "react-remove-scroll": "2.4.3" - }, - "peerDependencies": { - "react": "^16.8.0 || 17.x", - "react-dom": "^16.8.0 || 17.x" - } - }, - "node_modules/@reach/polymorphic": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@reach/polymorphic/-/polymorphic-0.18.0.tgz", - "integrity": "sha512-N9iAjdMbE//6rryZZxAPLRorzDcGBnluf7YQij6XDLiMtfCj1noa7KyLpEc/5XCIB/EwhX3zCluFAwloBKdblA==", - "peerDependencies": { - "react": "^16.8.0 || 17.x" - } - }, - "node_modules/@reach/portal": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@reach/portal/-/portal-0.18.0.tgz", - "integrity": "sha512-TImozRapd576ofRk30Le2L3lRTFXF1p47B182wnp5eMTdZa74JX138BtNGEPJFOyrMaVmguVF8SSwZ6a0fon1Q==", - "dependencies": { - "@reach/utils": "0.18.0" - }, - "peerDependencies": { - "react": "^16.8.0 || 17.x", - "react-dom": "^16.8.0 || 17.x" - } - }, - "node_modules/@reach/utils": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@reach/utils/-/utils-0.18.0.tgz", - "integrity": "sha512-KdVMdpTgDyK8FzdKO9SCpiibuy/kbv3pwgfXshTI6tEcQT1OOwj7BAksnzGC0rPz0UholwC+AgkqEl3EJX3M1A==", - "peerDependencies": { - "react": "^16.8.0 || 17.x", - "react-dom": "^16.8.0 || 17.x" - } - }, "node_modules/@remix-run/router": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.8.0.tgz", @@ -892,34 +835,29 @@ "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "devOptional": true + "dev": true }, "node_modules/@types/react": { - "version": "17.0.60", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.60.tgz", - "integrity": "sha512-pCH7bqWIfzHs3D+PDs3O/COCQJka+Kcw3RnO9rFA2zalqoXg7cNjJDh6mZ7oRtY1wmY4LVwDdAbA1F7Z8tv3BQ==", - "devOptional": true, + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", "dependencies": { "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "17.0.20", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.20.tgz", - "integrity": "sha512-4pzIjSxDueZZ90F52mU3aPoogkHIoSIDG+oQ+wQK7Cy2B9S+MvOqY0uEA/qawKz381qrEDkvpwyt8Bm31I8sbA==", + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, - "dependencies": { - "@types/react": "^17" + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" } }, - "node_modules/@types/scheduler": { - "version": "0.16.3", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", - "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", - "devOptional": true - }, "node_modules/@vitejs/plugin-react": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-3.1.0.tgz", @@ -1039,10 +977,11 @@ "dev": true }, "node_modules/csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "devOptional": true + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" }, "node_modules/debug": { "version": "4.3.4", @@ -1061,11 +1000,6 @@ } } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" - }, "node_modules/electron-to-chromium": { "version": "1.4.419", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.419.tgz", @@ -1133,17 +1067,6 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true }, - "node_modules/focus-lock": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-0.9.2.tgz", - "integrity": "sha512-YtHxjX7a0IC0ZACL5wsX8QdncXofWpGPNoVMuI/nZUrPGp6LmNI6+D5j0pPj+v8Kw5EpweA+T5yImK0rnWf7oQ==", - "dependencies": { - "tslib": "^2.0.3" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -1167,14 +1090,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "engines": { - "node": ">=6" - } - }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -1193,14 +1108,6 @@ "node": ">=4" } }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1292,14 +1199,6 @@ "integrity": "sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==", "dev": true }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -1346,73 +1245,31 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "loose-envify": "^1.1.0" }, "engines": { "node": ">=0.10.0" } }, - "node_modules/react-clientside-effect": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz", - "integrity": "sha512-XGGGRQAKY+q25Lz9a/4EPqom7WRjz3z9R2k4jhVKA/puQFH/5Nt27vFZYql4m4NVNdUvX8PS3O7r/Zzm7cjUlg==", - "dependencies": { - "@babel/runtime": "^7.12.13" - }, - "peerDependencies": { - "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" - } - }, "node_modules/react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" - }, - "peerDependencies": { - "react": "17.0.2" - } - }, - "node_modules/react-focus-lock": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.5.2.tgz", - "integrity": "sha512-WzpdOnEqjf+/A3EH9opMZWauag7gV0BxFl+EY4ElA4qFqYsUsBLnmo2sELbN5OC30S16GAWMy16B9DLPpdJKAQ==", - "dependencies": { - "@babel/runtime": "^7.0.0", - "focus-lock": "^0.9.1", - "prop-types": "^15.6.2", - "react-clientside-effect": "^1.2.5", - "use-callback-ref": "^1.2.5", - "use-sidecar": "^1.0.5" + "scheduler": "^0.23.2" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0" + "react": "^18.3.1" } }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, "node_modules/react-refresh": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", @@ -1422,56 +1279,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-remove-scroll": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.4.3.tgz", - "integrity": "sha512-lGWYXfV6jykJwbFpsuPdexKKzp96f3RbvGapDSIdcyGvHb7/eqyn46C7/6h+rUzYar1j5mdU+XECITHXCKBk9Q==", - "dependencies": { - "react-remove-scroll-bar": "^2.1.0", - "react-style-singleton": "^2.1.0", - "tslib": "^1.0.0", - "use-callback-ref": "^1.2.3", - "use-sidecar": "^1.0.1" - }, - "engines": { - "node": ">=8.5.0" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0", - "react": "^16.8.0 || ^17.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz", - "integrity": "sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==", - "dependencies": { - "react-style-singleton": "^2.2.1", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, "node_modules/react-router": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.15.0.tgz", @@ -1502,33 +1309,6 @@ "react-dom": ">=16.8" } }, - "node_modules/react-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", - "dependencies": { - "get-nonce": "^1.0.0", - "invariant": "^2.2.4", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" - }, "node_modules/rollup": { "version": "3.23.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.23.1.tgz", @@ -1546,12 +1326,12 @@ } }, "node_modules/scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "loose-envify": "^1.1.0" } }, "node_modules/semver": { @@ -1593,11 +1373,6 @@ "node": ">=4" } }, - "node_modules/tslib": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.3.tgz", - "integrity": "sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==" - }, "node_modules/typescript": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", @@ -1641,47 +1416,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/use-callback-ref": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.0.tgz", - "integrity": "sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/vite": { "version": "4.3.9", "resolved": "https://registry.npmjs.org/vite/-/vite-4.3.9.tgz", diff --git a/examples/modal/package.json b/examples/modal/package.json index c665689c27..b6a0b9e621 100644 --- a/examples/modal/package.json +++ b/examples/modal/package.json @@ -7,16 +7,15 @@ "serve": "vite preview" }, "dependencies": { - "@reach/dialog": "0.18.0", - "react": "^17.0.2", - "react-dom": "^17.0.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-router-dom": "^6.15.0" }, "devDependencies": { "@rollup/plugin-replace": "^5.0.2", "@types/node": "18.x", - "@types/react": "^17.0.59", - "@types/react-dom": "^17.0.20", + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", "@vitejs/plugin-react": "^3.0.1", "typescript": "^4.9.5", "vite": "^4.0.4" diff --git a/examples/modal/src/App.tsx b/examples/modal/src/App.tsx index 50aece0aa0..2e092a493b 100644 --- a/examples/modal/src/App.tsx +++ b/examples/modal/src/App.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { createPortal } from "react-dom"; import { Routes, Route, @@ -8,8 +9,6 @@ import { useNavigate, useParams, } from "react-router-dom"; -import { Dialog } from "@reach/dialog"; -import "@reach/dialog/styles.css"; import { IMAGES, getImageById } from "./images"; @@ -228,3 +227,124 @@ function NoMatch() { ); } + +type DialogProps = { + children: React.ReactNode; + onDismiss: () => void; + "aria-label"?: string; + "aria-labelledby"?: string; + initialFocusRef?: React.RefObject; +}; + +function Dialog({ + children, + onDismiss, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledby, + initialFocusRef, +}: DialogProps) { + let overlayRef = React.useRef(null); + let contentRef = React.useRef(null); + let previouslyFocusedRef = React.useRef(null); + + React.useEffect(() => { + previouslyFocusedRef.current = document.activeElement as HTMLElement | null; + + let focusTarget = initialFocusRef?.current ?? contentRef.current; + if (focusTarget) { + focusTarget.focus(); + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.stopPropagation(); + onDismiss(); + return; + } + + if (event.key === "Tab") { + let container = contentRef.current; + if (!container) return; + + let focusable = getFocusableElements(container); + if (focusable.length === 0) { + event.preventDefault(); + container.focus(); + return; + } + + let activeElement = document.activeElement as HTMLElement | null; + let currentIndex = focusable.indexOf(activeElement ?? focusable[0]); + + let nextIndex = currentIndex; + if (event.shiftKey) { + nextIndex = + currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1; + } else { + nextIndex = + currentIndex === focusable.length - 1 ? 0 : currentIndex + 1; + } + + event.preventDefault(); + focusable[nextIndex].focus(); + } + } + + document.addEventListener("keydown", onKeyDown); + + return () => { + document.removeEventListener("keydown", onKeyDown); + previouslyFocusedRef.current?.focus(); + }; + }, [initialFocusRef, onDismiss]); + + return createPortal( +
{ + if (event.target === event.currentTarget) { + onDismiss(); + } + }} + style={{ + position: "fixed", + inset: 0, + background: "rgba(0, 0, 0, 0.55)", + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "24px", + zIndex: 1000, + }} + > +
+ {children} +
+
, + document.body, + ); +} + +function getFocusableElements(container: HTMLElement) { + return Array.from( + container.querySelectorAll( + 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex]:not([tabindex="-1"]), [contenteditable="true"]', + ), + ).filter((element) => !element.hasAttribute("disabled")); +} diff --git a/integration/CHANGELOG.md b/integration/CHANGELOG.md index 6fccf850d7..2cf67d87b7 100644 --- a/integration/CHANGELOG.md +++ b/integration/CHANGELOG.md @@ -5,7 +5,6 @@ ### Minor Changes - Unstable Vite support for Node-based Remix apps ([#7590](https://github.com/remix-run/remix/pull/7590)) - - `remix build` 👉 `vite build && vite build --ssr` - `remix dev` 👉 `vite dev` diff --git a/integration/passthrough-requests-test.ts b/integration/passthrough-requests-test.ts new file mode 100644 index 0000000000..e9b1e19ba2 --- /dev/null +++ b/integration/passthrough-requests-test.ts @@ -0,0 +1,231 @@ +import { test, expect } from "@playwright/test"; +import { + type AppFixture, + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import { reactRouterConfig } from "./helpers/vite.js"; + +const files = { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export function loader() { + return "ROOT"; + } + + export default function Root() { + return ( + + + + + + + + + + + ); + } + + export function shouldRevalidate({ defaultShouldRevalidate }) { + return defaultShouldRevalidate; + } + `, + "app/routes/_index.tsx": js` + import { Form, Link } from "react-router"; + + export function loader({ request, unstable_url }) { + let url = new URL(request.url); + return { + url: url.pathname + url.search, + path: unstable_url.pathname + unstable_url.search + }; + } + + export function action({ request, unstable_url }) { + let url = new URL(request.url); + return { + url: url.pathname + url.search, + path: unstable_url.pathname + unstable_url.search + }; + } + + export default function Component({ loaderData, actionData }) { + return ( + <> + Add param +
+ +
+ + Go to new page + +

{loaderData.url}

+

{loaderData.path}

+ {actionData ? + <> +

{actionData.url}

+

{actionData.path}

+ : + null} + + ) + } + `, + "app/routes/page.tsx": js` + export function loader({ request, unstable_url }) { + let url = new URL(request.url); + return { + url: url.pathname + url.search, + path: unstable_url.pathname + unstable_url.search + }; + } + + export default function Component({ loaderData }) { + return ( + <> +

{loaderData.url}

+

{loaderData.path}

+ + ) + } + `, +}; + +test.describe("pass through requests", () => { + test("sends proper arguments to loaders when future.unstable_passThroughRequests is disabled", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "react-router.config.ts": reactRouterConfig({ + future: { + unstable_passThroughRequests: false, + }, + }), + ...files, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let requests: string[] = []; + page.on("request", (req) => { + let url = new URL(req.url()); + if (url.pathname.endsWith(".data")) { + requests.push(url.pathname + url.search); + } + }); + + // Document load + await app.goto("/"); + expect(await page.locator("[data-loader-url]").textContent()).toBe("/"); + expect(await page.locator("[data-loader-path]").textContent()).toBe("/"); + + // Client-side navigation with query params + await app.clickLink("/?a=1"); + expect(await page.locator("[data-loader-url]").textContent()).toBe("/?a=1"); + expect(await page.locator("[data-loader-path]").textContent()).toBe( + "/?a=1", + ); + expect(requests).toEqual(["/_root.data?a=1"]); + requests = []; + + // Client-side form submission with query params + await app.clickElement('button[type="submit"]'); + expect(await page.locator("[data-action-url]").textContent()).toBe("/?a=1"); + expect(await page.locator("[data-action-path]").textContent()).toBe( + "/?a=1", + ); + expect(await page.locator("[data-loader-url]").textContent()).toBe("/?a=1"); + expect(await page.locator("[data-loader-path]").textContent()).toBe( + "/?a=1", + ); + expect(requests).toEqual(["/_root.data?index&a=1", "/_root.data?a=1"]); + requests = []; + + // Navigate to new page + await app.clickLink("/page?b=2"); + expect(await page.locator("[data-loader-url]").textContent()).toBe( + "/page?b=2", + ); + expect(await page.locator("[data-loader-path]").textContent()).toBe( + "/page?b=2", + ); + expect(requests).toEqual(["/page.data?b=2&_routes=routes%2Fpage"]); + requests = []; + }); + + test("sends proper arguments to loaders when future.unstable_passThroughRequests is enabled", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "react-router.config.ts": reactRouterConfig({ + future: { + unstable_passThroughRequests: true, + }, + }), + ...files, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let requests: string[] = []; + page.on("request", (req) => { + let url = new URL(req.url()); + if (url.pathname.endsWith(".data")) { + requests.push(url.pathname + url.search); + } + }); + + // Document load + await app.goto("/"); + expect(await page.locator("[data-loader-url]").textContent()).toBe("/"); + expect(await page.locator("[data-loader-path]").textContent()).toBe("/"); + + // Client-side navigation with query params + await app.clickLink("/?a=1"); + expect(await page.locator("[data-loader-url]").textContent()).toBe( + "/_root.data?a=1", + ); + expect(await page.locator("[data-loader-path]").textContent()).toBe( + "/?a=1", + ); + expect(requests).toEqual(["/_root.data?a=1"]); + requests = []; + + // Client-side form submission with query params + await app.clickElement('button[type="submit"]'); + expect(await page.locator("[data-action-url]").textContent()).toBe( + "/_root.data?index&a=1", + ); + expect(await page.locator("[data-action-path]").textContent()).toBe( + "/?a=1", + ); + expect(await page.locator("[data-loader-url]").textContent()).toBe( + "/_root.data?a=1", + ); + expect(await page.locator("[data-loader-path]").textContent()).toBe( + "/?a=1", + ); + expect(requests).toEqual(["/_root.data?index&a=1", "/_root.data?a=1"]); + requests = []; + + // Navigate to new page + await app.clickLink("/page?b=2"); + expect(await page.locator("[data-loader-url]").textContent()).toBe( + "/page.data?b=2&_routes=routes%2Fpage", + ); + expect(await page.locator("[data-loader-path]").textContent()).toBe( + "/page?b=2", + ); + expect(requests).toEqual(["/page.data?b=2&_routes=routes%2Fpage"]); + requests = []; + }); +}); diff --git a/integration/vite-presets-test.ts b/integration/vite-presets-test.ts index 4c2eb5a979..abf835389a 100644 --- a/integration/vite-presets-test.ts +++ b/integration/vite-presets-test.ts @@ -245,6 +245,7 @@ test.describe("Vite / presets", async () => { // Ensure future flags from presets are properly merged expect(buildEndArgsMeta.futureFlags).toEqual({ unstable_optimizeDeps: true, + unstable_passThroughRequests: false, unstable_subResourceIntegrity: false, unstable_trailingSlashAwareDataRequests: false, unstable_previewServerPrerendering: false, diff --git a/package.json b/package.json index 568a7299e6..581823b18f 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@typescript-eslint/parser": "^7.5.0", "babel-jest": "^29.7.0", "babel-plugin-dev-expression": "^0.2.3", - "chalk": "^4.1.2", + "picocolors": "^1.1.1", "dox": "^1.0.0", "eslint": "^8.57.0", "eslint-config-react-app": "^7.0.1", diff --git a/packages/create-react-router/package.json b/packages/create-react-router/package.json index a2df126d02..dd0aaafaf5 100644 --- a/packages/create-react-router/package.json +++ b/packages/create-react-router/package.json @@ -39,7 +39,7 @@ "dependencies": { "@remix-run/web-fetch": "^4.4.2", "arg": "^5.0.1", - "chalk": "^4.1.2", + "picocolors": "^1.1.1", "execa": "5.1.1", "gunzip-maybe": "^1.4.2", "log-update": "^5.0.1", diff --git a/packages/create-react-router/utils.ts b/packages/create-react-router/utils.ts index 0effcdb084..6c30c56c85 100644 --- a/packages/create-react-router/utils.ts +++ b/packages/create-react-router/utils.ts @@ -5,56 +5,69 @@ import process from "node:process"; import os from "node:os"; import { type Key as ActionKey } from "node:readline"; import { erase, cursor } from "sisteransi"; -import chalk from "chalk"; +import pc from "picocolors"; // https://no-color.org/ -const SUPPORTS_COLOR = chalk.supportsColor && !process.env.NO_COLOR; +// picocolors natively respects NO_COLOR and FORCE_COLOR env vars +const SUPPORTS_COLOR = pc.isColorSupported; export const color = { supportsColor: SUPPORTS_COLOR, - heading: safeColor(chalk.bold), - arg: safeColor(chalk.yellowBright), - error: safeColor(chalk.red), - warning: safeColor(chalk.yellow), - hint: safeColor(chalk.blue), - bold: safeColor(chalk.bold), - black: safeColor(chalk.black), - white: safeColor(chalk.white), - blue: safeColor(chalk.blue), - cyan: safeColor(chalk.cyan), - red: safeColor(chalk.red), - yellow: safeColor(chalk.yellow), - green: safeColor(chalk.green), - blackBright: safeColor(chalk.blackBright), - whiteBright: safeColor(chalk.whiteBright), - blueBright: safeColor(chalk.blueBright), - cyanBright: safeColor(chalk.cyanBright), - redBright: safeColor(chalk.redBright), - yellowBright: safeColor(chalk.yellowBright), - greenBright: safeColor(chalk.greenBright), - bgBlack: safeColor(chalk.bgBlack), - bgWhite: safeColor(chalk.bgWhite), - bgBlue: safeColor(chalk.bgBlue), - bgCyan: safeColor(chalk.bgCyan), - bgRed: safeColor(chalk.bgRed), - bgYellow: safeColor(chalk.bgYellow), - bgGreen: safeColor(chalk.bgGreen), - bgBlackBright: safeColor(chalk.bgBlackBright), - bgWhiteBright: safeColor(chalk.bgWhiteBright), - bgBlueBright: safeColor(chalk.bgBlueBright), - bgCyanBright: safeColor(chalk.bgCyanBright), - bgRedBright: safeColor(chalk.bgRedBright), - bgYellowBright: safeColor(chalk.bgYellowBright), - bgGreenBright: safeColor(chalk.bgGreenBright), - gray: safeColor(chalk.gray), - dim: safeColor(chalk.dim), - reset: safeColor(chalk.reset), - inverse: safeColor(chalk.inverse), - hex: (color: string) => safeColor(chalk.hex(color)), - underline: chalk.underline, + heading: safeColor(pc.bold), + arg: safeColor(pc.yellowBright), + error: safeColor(pc.red), + warning: safeColor(pc.yellow), + hint: safeColor(pc.blue), + bold: safeColor(pc.bold), + black: safeColor(pc.black), + white: safeColor(pc.white), + blue: safeColor(pc.blue), + cyan: safeColor(pc.cyan), + red: safeColor(pc.red), + yellow: safeColor(pc.yellow), + green: safeColor(pc.green), + blackBright: safeColor(pc.blackBright), + whiteBright: safeColor(pc.whiteBright), + blueBright: safeColor(pc.blueBright), + cyanBright: safeColor(pc.cyanBright), + redBright: safeColor(pc.redBright), + yellowBright: safeColor(pc.yellowBright), + greenBright: safeColor(pc.greenBright), + bgBlack: safeColor(pc.bgBlack), + bgWhite: safeColor(pc.bgWhite), + bgBlue: safeColor(pc.bgBlue), + bgCyan: safeColor(pc.bgCyan), + bgRed: safeColor(pc.bgRed), + bgYellow: safeColor(pc.bgYellow), + bgGreen: safeColor(pc.bgGreen), + bgBlackBright: safeColor(pc.bgBlackBright), + bgWhiteBright: safeColor(pc.bgWhiteBright), + bgBlueBright: safeColor(pc.bgBlueBright), + bgCyanBright: safeColor(pc.bgCyanBright), + bgRedBright: safeColor(pc.bgRedBright), + bgYellowBright: safeColor(pc.bgYellowBright), + bgGreenBright: safeColor(pc.bgGreenBright), + gray: safeColor(pc.gray), + dim: safeColor(pc.dim), + reset: safeColor(pc.reset), + inverse: safeColor(pc.inverse), + hex: (hex: string) => safeColor(hexColor(hex)), + underline: pc.underline, }; -function safeColor(style: chalk.Chalk) { +/** + * Converts a hex color string to an ANSI true-color (24-bit) formatter. + * Used by the loading indicator gradient animation. + */ +function hexColor(hex: string): (input: string) => string { + let h = hex.replace("#", ""); + let r = parseInt(h.substring(0, 2), 16); + let g = parseInt(h.substring(2, 4), 16); + let b = parseInt(h.substring(4, 6), 16); + return (input: string) => `\x1b[38;2;${r};${g};${b}m${input}\x1b[39m`; +} + +function safeColor(style: (input: string) => string) { return SUPPORTS_COLOR ? style : identity; } @@ -93,8 +106,8 @@ export function logError(message: string) { function logBullet( logger: typeof log | typeof logError, - colorizePrefix: (v: V) => V, - colorizeText: (v: V) => V, + colorizePrefix: (v: string) => string, + colorizeText: (v: string) => string, symbol: string, prefix: string, text?: string | string[], diff --git a/packages/react-router-architect/CHANGELOG.md b/packages/react-router-architect/CHANGELOG.md index ac04993c40..c8c119e4ca 100644 --- a/packages/react-router-architect/CHANGELOG.md +++ b/packages/react-router-architect/CHANGELOG.md @@ -103,7 +103,6 @@ - Stabilize middleware and context APIs. ([#14215](https://github.com/remix-run/react-router/pull/14215)) We have removed the `unstable_` prefix from the following APIs and they are now considered stable and ready for production use: - - [`RouterContextProvider`](https://reactrouter.com/api/utils/RouterContextProvider) - [`createContext`](https://reactrouter.com/api/utils/createContext) - `createBrowserRouter` [`getContext`](https://reactrouter.com/api/data-routers/createBrowserRouter#optsgetcontext) option @@ -327,7 +326,6 @@ ### Major Changes - For Remix consumers migrating to React Router, the `crypto` global from the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) is now required when using cookie and session APIs. This means that the following APIs are provided from `react-router` rather than platform-specific packages: ([#11837](https://github.com/remix-run/react-router/pull/11837)) - - `createCookie` - `createCookieSessionStorage` - `createMemorySessionStorage` @@ -336,7 +334,6 @@ For consumers running older versions of Node, the `installGlobals` function from `@remix-run/node` has been updated to define `globalThis.crypto`, using [Node's `require('node:crypto').webcrypto` implementation.](https://nodejs.org/api/webcrypto.html) Since platform-specific packages no longer need to implement this API, the following low-level APIs have been removed: - - `createCookieFactory` - `createSessionStorageFactory` - `createCookieSessionStorageFactory` diff --git a/packages/react-router-cloudflare/CHANGELOG.md b/packages/react-router-cloudflare/CHANGELOG.md index 4190774f96..00809f3abf 100644 --- a/packages/react-router-cloudflare/CHANGELOG.md +++ b/packages/react-router-cloudflare/CHANGELOG.md @@ -91,7 +91,6 @@ - Stabilize middleware and context APIs. ([#14215](https://github.com/remix-run/react-router/pull/14215)) We have removed the `unstable_` prefix from the following APIs and they are now considered stable and ready for production use: - - [`RouterContextProvider`](https://reactrouter.com/api/utils/RouterContextProvider) - [`createContext`](https://reactrouter.com/api/utils/createContext) - `createBrowserRouter` [`getContext`](https://reactrouter.com/api/data-routers/createBrowserRouter#optsgetcontext) option @@ -290,7 +289,6 @@ - For Remix consumers migrating to React Router, all exports from `@remix-run/cloudflare-pages` are now provided for React Router consumers in the `@react-router/cloudflare` package. There is no longer a separate package for Cloudflare Pages. ([#11801](https://github.com/remix-run/react-router/pull/11801)) - For Remix consumers migrating to React Router, the `crypto` global from the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) is now required when using cookie and session APIs. This means that the following APIs are provided from `react-router` rather than platform-specific packages: ([#11837](https://github.com/remix-run/react-router/pull/11837)) - - `createCookie` - `createCookieSessionStorage` - `createMemorySessionStorage` @@ -299,7 +297,6 @@ For consumers running older versions of Node, the `installGlobals` function from `@remix-run/node` has been updated to define `globalThis.crypto`, using [Node's `require('node:crypto').webcrypto` implementation.](https://nodejs.org/api/webcrypto.html) Since platform-specific packages no longer need to implement this API, the following low-level APIs have been removed: - - `createCookieFactory` - `createSessionStorageFactory` - `createCookieSessionStorageFactory` diff --git a/packages/react-router-dev/CHANGELOG.md b/packages/react-router-dev/CHANGELOG.md index c0ee22e6cd..aad0e8732d 100644 --- a/packages/react-router-dev/CHANGELOG.md +++ b/packages/react-router-dev/CHANGELOG.md @@ -37,25 +37,25 @@ | URL `/a/b/c` | **HTTP pathname** | **`request` pathname\`** | | ------------ | ----------------- | ------------------------ | - | **Document** | `/a/b/c` | `/a/b/c` ✅ | - | **Data** | `/a/b/c.data` | `/a/b/c` ✅ | + | **Document** | `/a/b/c` | `/a/b/c` ✅ | + | **Data** | `/a/b/c.data` | `/a/b/c` ✅ | | URL `/a/b/c/` | **HTTP pathname** | **`request` pathname\`** | | ------------- | ----------------- | ------------------------ | - | **Document** | `/a/b/c/` | `/a/b/c/` ✅ | + | **Document** | `/a/b/c/` | `/a/b/c/` ✅ | | **Data** | `/a/b/c.data` | `/a/b/c` ⚠️ | With this flag enabled, these pathnames will be made consistent though a new `_.data` format for client-side `.data` requests: | URL `/a/b/c` | **HTTP pathname** | **`request` pathname\`** | | ------------ | ----------------- | ------------------------ | - | **Document** | `/a/b/c` | `/a/b/c` ✅ | - | **Data** | `/a/b/c.data` | `/a/b/c` ✅ | + | **Document** | `/a/b/c` | `/a/b/c` ✅ | + | **Data** | `/a/b/c.data` | `/a/b/c` ✅ | | URL `/a/b/c/` | **HTTP pathname** | **`request` pathname\`** | | ------------- | ------------------ | ------------------------ | - | **Document** | `/a/b/c/` | `/a/b/c/` ✅ | - | **Data** | `/a/b/c/_.data` ⬅️ | `/a/b/c/` ✅ | + | **Document** | `/a/b/c/` | `/a/b/c/` ✅ | + | **Data** | `/a/b/c/_.data` ⬅️ | `/a/b/c/` ✅ | This a bug fix but we are putting it behind an opt-in flag because it has the potential to be a "breaking bug fix" if you are relying on the URL format for any other application or caching logic. @@ -313,7 +313,6 @@ - Stabilize middleware and context APIs. ([#14215](https://github.com/remix-run/react-router/pull/14215)) We have removed the `unstable_` prefix from the following APIs and they are now considered stable and ready for production use: - - [`RouterContextProvider`](https://reactrouter.com/api/utils/RouterContextProvider) - [`createContext`](https://reactrouter.com/api/utils/createContext) - `createBrowserRouter` [`getContext`](https://reactrouter.com/api/data-routers/createBrowserRouter#optsgetcontext) option @@ -1056,7 +1055,6 @@ ``` This initial implementation targets type inference for: - - `Params` : Path parameters from your routing config in `routes.ts` including file-based routing - `LoaderData` : Loader data from `loader` and/or `clientLoader` within your route module - `ActionData` : Action data from `action` and/or `clientAction` within your route module @@ -1071,7 +1069,6 @@ ``` Check out our docs for more: - - [_Explanations > Type Safety_](https://reactrouter.com/dev/guides/explanation/type-safety) - [_How-To > Setting up type safety_](https://reactrouter.com/dev/guides/how-to/setting-up-type-safety) @@ -1271,7 +1268,6 @@ - Vite: Provide `Unstable_ServerBundlesFunction` and `Unstable_VitePluginConfig` types ([#8654](https://github.com/remix-run/remix/pull/8654)) - Vite: add `--sourcemapClient` and `--sourcemapServer` flags to `remix vite:build` ([#8613](https://github.com/remix-run/remix/pull/8613)) - - `--sourcemapClient` - `--sourcemapClient=inline` @@ -1608,7 +1604,6 @@ - Add support for `clientLoader`/`clientAction`/`HydrateFallback` route exports ([RFC](https://github.com/remix-run/remix/discussions/7634)) ([#8173](https://github.com/remix-run/remix/pull/8173)) Remix now supports loaders/actions that run on the client (in addition to, or instead of the loader/action that runs on the server). While we still recommend server loaders/actions for the majority of your data needs in a Remix app - these provide some levers you can pull for more advanced use-cases such as: - - Leveraging a data source local to the browser (i.e., `localStorage`) - Managing a client-side cache of server data (like `IndexedDB`) - Bypassing the Remix server in a BFF setup and hitting your API directly from the browser @@ -2012,7 +2007,6 @@ - Output esbuild metafiles for bundle analysis ([#6772](https://github.com/remix-run/remix/pull/6772)) Written to server build directory (`build/` by default): - - `metafile.css.json` - `metafile.js.json` (browser JS) - `metafile.server.json` (server JS) @@ -2110,7 +2104,6 @@ - built-in tls support ([#6483](https://github.com/remix-run/remix/pull/6483)) New options: - - `--tls-key` / `tlsKey`: TLS key - `--tls-cert` / `tlsCert`: TLS Certificate @@ -2381,7 +2374,6 @@ ``` The dev server will: - - force `NODE_ENV=development` and warn you if it was previously set to something else - rebuild your app whenever your Remix app code changes - restart your app server whenever rebuilds succeed diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts index 0052aeb646..7caf7ed27d 100644 --- a/packages/react-router-dev/config/config.ts +++ b/packages/react-router-dev/config/config.ts @@ -86,6 +86,7 @@ type ValidateConfigFunction = (config: ReactRouterConfig) => string | void; interface FutureConfig { unstable_optimizeDeps: boolean; + unstable_passThroughRequests: boolean; unstable_subResourceIntegrity: boolean; unstable_trailingSlashAwareDataRequests: boolean; /** @@ -684,6 +685,8 @@ async function resolveConfig({ let future: FutureConfig = { unstable_optimizeDeps: userAndPresetConfigs.future?.unstable_optimizeDeps ?? false, + unstable_passThroughRequests: + userAndPresetConfigs.future?.unstable_passThroughRequests ?? false, unstable_subResourceIntegrity: userAndPresetConfigs.future?.unstable_subResourceIntegrity ?? false, unstable_trailingSlashAwareDataRequests: diff --git a/packages/react-router-express/CHANGELOG.md b/packages/react-router-express/CHANGELOG.md index f394ddf339..e3a04f2727 100644 --- a/packages/react-router-express/CHANGELOG.md +++ b/packages/react-router-express/CHANGELOG.md @@ -103,7 +103,6 @@ - Stabilize middleware and context APIs. ([#14215](https://github.com/remix-run/react-router/pull/14215)) We have removed the `unstable_` prefix from the following APIs and they are now considered stable and ready for production use: - - [`RouterContextProvider`](https://reactrouter.com/api/utils/RouterContextProvider) - [`createContext`](https://reactrouter.com/api/utils/createContext) - `createBrowserRouter` [`getContext`](https://reactrouter.com/api/data-routers/createBrowserRouter#optsgetcontext) option diff --git a/packages/react-router-node/CHANGELOG.md b/packages/react-router-node/CHANGELOG.md index d731580ca7..46f78a092c 100644 --- a/packages/react-router-node/CHANGELOG.md +++ b/packages/react-router-node/CHANGELOG.md @@ -92,7 +92,6 @@ - Stabilize middleware and context APIs. ([#14215](https://github.com/remix-run/react-router/pull/14215)) We have removed the `unstable_` prefix from the following APIs and they are now considered stable and ready for production use: - - [`RouterContextProvider`](https://reactrouter.com/api/utils/RouterContextProvider) - [`createContext`](https://reactrouter.com/api/utils/createContext) - `createBrowserRouter` [`getContext`](https://reactrouter.com/api/data-routers/createBrowserRouter#optsgetcontext) option @@ -292,7 +291,6 @@ - Remove single fetch future flag. ([#11522](https://github.com/remix-run/react-router/pull/11522)) - For Remix consumers migrating to React Router, the `crypto` global from the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) is now required when using cookie and session APIs. This means that the following APIs are provided from `react-router` rather than platform-specific packages: ([#11837](https://github.com/remix-run/react-router/pull/11837)) - - `createCookie` - `createCookieSessionStorage` - `createMemorySessionStorage` @@ -301,7 +299,6 @@ For consumers running older versions of Node, the `installGlobals` function from `@remix-run/node` has been updated to define `globalThis.crypto`, using [Node's `require('node:crypto').webcrypto` implementation.](https://nodejs.org/api/webcrypto.html) Since platform-specific packages no longer need to implement this API, the following low-level APIs have been removed: - - `createCookieFactory` - `createSessionStorageFactory` - `createCookieSessionStorageFactory` @@ -709,12 +706,10 @@ - Introduces the `defer()` API from `@remix-run/router` with support for server-rendering and HTTP streaming. This utility allows you to defer values returned from `loader` functions by returning promises instead of resolved values. This has been refered to as _"sending a promise over the wire"_. ([#4920](https://github.com/remix-run/remix/pull/4920)) Informational Resources: - - - Documentation Resources (better docs specific to Remix are in the works): - - - - diff --git a/packages/react-router-serve/CHANGELOG.md b/packages/react-router-serve/CHANGELOG.md index f956dc5e12..ba58db9041 100644 --- a/packages/react-router-serve/CHANGELOG.md +++ b/packages/react-router-serve/CHANGELOG.md @@ -728,12 +728,10 @@ - Introduces the `defer()` API from `@remix-run/router` with support for server-rendering and HTTP streaming. This utility allows you to defer values returned from `loader` functions by returning promises instead of resolved values. This has been refered to as _"sending a promise over the wire"_. ([#4920](https://github.com/remix-run/remix/pull/4920)) Informational Resources: - - - Documentation Resources (better docs specific to Remix are in the works): - - - - diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 98ed5b8338..fe148cf413 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -12,9 +12,9 @@ - Fix matchPath optional params matching without a "/" separator. ([#14689](https://github.com/remix-run/react-router/pull/14689)) - matchPath("/users/:id?", "/usersblah") now returns null. - - matchPath("/test\_route/:part?", "/test\_route\_more") now returns null. + - matchPath("/test_route/:part?", "/test_route_more") now returns null. -- add RSC unstable\_getRequest ([#14758](https://github.com/remix-run/react-router/pull/14758)) +- add RSC unstable_getRequest ([#14758](https://github.com/remix-run/react-router/pull/14758)) - Fix `HydrateFallback` rendering during initial lazy route discovery with matching splat route ([#14740](https://github.com/remix-run/react-router/pull/14740)) @@ -60,7 +60,6 @@ ``` Notes: - - The masked location, if present, will be available on `useLocation().unstable_mask` so you can detect whether you are currently masked or not. - Masked URLs only work for SPA use cases, and will be removed from `history.state` during SSR. - This provides a first-class API to mask URLs in Data Mode to achieve the same behavior you could do in Declarative Mode via [manual `backgroundLocation` management](https://github.com/remix-run/react-router/tree/main/examples/modal). @@ -106,25 +105,25 @@ | URL `/a/b/c` | **HTTP pathname** | **`request` pathname\`** | | ------------ | ----------------- | ------------------------ | - | **Document** | `/a/b/c` | `/a/b/c` ✅ | - | **Data** | `/a/b/c.data` | `/a/b/c` ✅ | + | **Document** | `/a/b/c` | `/a/b/c` ✅ | + | **Data** | `/a/b/c.data` | `/a/b/c` ✅ | | URL `/a/b/c/` | **HTTP pathname** | **`request` pathname\`** | | ------------- | ----------------- | ------------------------ | - | **Document** | `/a/b/c/` | `/a/b/c/` ✅ | + | **Document** | `/a/b/c/` | `/a/b/c/` ✅ | | **Data** | `/a/b/c.data` | `/a/b/c` ⚠️ | With this flag enabled, these pathnames will be made consistent though a new `_.data` format for client-side `.data` requests: | URL `/a/b/c` | **HTTP pathname** | **`request` pathname\`** | | ------------ | ----------------- | ------------------------ | - | **Document** | `/a/b/c` | `/a/b/c` ✅ | - | **Data** | `/a/b/c.data` | `/a/b/c` ✅ | + | **Document** | `/a/b/c` | `/a/b/c` ✅ | + | **Data** | `/a/b/c.data` | `/a/b/c` ✅ | | URL `/a/b/c/` | **HTTP pathname** | **`request` pathname\`** | | ------------- | ------------------ | ------------------------ | - | **Document** | `/a/b/c/` | `/a/b/c/` ✅ | - | **Data** | `/a/b/c/_.data` ⬅️ | `/a/b/c/` ✅ | + | **Document** | `/a/b/c/` | `/a/b/c/` ✅ | + | **Data** | `/a/b/c/_.data` ⬅️ | `/a/b/c/` ✅ | This a bug fix but we are putting it behind an opt-in flag because it has the potential to be a "breaking bug fix" if you are relying on the URL format for any other application or caching logic. @@ -151,14 +150,12 @@ - \[UNSTABLE] Add a new `unstable_defaultShouldRevalidate` flag to various APIs to allow opt-ing out of standard revalidation behaviors. ([#14542](https://github.com/remix-run/react-router/pull/14542)) If active routes include a `shouldRevalidate` function, then your value will be passed as `defaultShouldRevalidate` in those function so that the route always has the final revalidation determination. - - `
` - `submit(data, { method: "post", unstable_defaultShouldRevalidate: false })` - `` - `fetcher.submit(data, { method: "post", unstable_defaultShouldRevalidate: false })` This is also available on non-submission APIs that may trigger revalidations due to changing search params: - - `` - `navigate("/?foo=bar", { unstable_defaultShouldRevalidate: false })` - `setSearchParams(params, { unstable_defaultShouldRevalidate: false })` @@ -181,7 +178,6 @@ - ⚠️ This is a breaking change if you have begun using `fetcher.unstable_reset()` - Stabilize the `dataStrategy` `match.shouldRevalidateArgs`/`match.shouldCallHandler()` APIs. ([#14592](https://github.com/remix-run/react-router/pull/14592)) - - The `match.shouldLoad` API is now marked deprecated in favor of these more powerful alternatives - If you're using this API in a custom `dataStrategy` today, you can swap to the new API at your convenience: @@ -310,7 +306,6 @@ - Ensure action handlers run for routes with middleware even if no loader is present ([#14443](https://github.com/remix-run/react-router/pull/14443)) - Add `unstable_instrumentations` API to allow users to add observablity to their apps by instrumenting route loaders, actions, middlewares, lazy, as well as server-side request handlers and client side navigations/fetches ([#14412](https://github.com/remix-run/react-router/pull/14412)) - - Framework Mode: - `entry.server.tsx`: `export const unstable_instrumentations = [...]` - `entry.client.tsx`: `` @@ -472,7 +467,6 @@ - Stabilize middleware and context APIs. ([#14215](https://github.com/remix-run/react-router/pull/14215)) We have removed the `unstable_` prefix from the following APIs and they are now considered stable and ready for production use: - - [`RouterContextProvider`](https://reactrouter.com/api/utils/RouterContextProvider) - [`createContext`](https://reactrouter.com/api/utils/createContext) - `createBrowserRouter` [`getContext`](https://reactrouter.com/api/data-routers/createBrowserRouter#optsgetcontext) option @@ -499,7 +493,7 @@ - \[UNSTABLE] Add ``/`` prop for client side error reporting ([#14162](https://github.com/remix-run/react-router/pull/14162)) -- server action revalidation opt out via $SKIP\_REVALIDATION field ([#14154](https://github.com/remix-run/react-router/pull/14154)) +- server action revalidation opt out via $SKIP_REVALIDATION field ([#14154](https://github.com/remix-run/react-router/pull/14154)) - Properly escape interpolated param values in `generatePath()` ([#13530](https://github.com/remix-run/react-router/pull/13530)) @@ -548,7 +542,6 @@ - Remove dependency on `@types/node` in TypeScript declaration files ([#14059](https://github.com/remix-run/react-router/pull/14059)) - Fix types for `UIMatch` to reflect that the `loaderData`/`data` properties may be `undefined` ([#12206](https://github.com/remix-run/react-router/pull/12206)) - - When an `ErrorBoundary` is being rendered, not all active matches will have loader data available, since it may have been their `loader` that threw to trigger the boundary - The `UIMatch.data` type was not correctly handing this and would always reflect the presence of data, leading to the unexpected runtime errors when an `ErrorBoundary` was rendered - ⚠️ This may cause some type errors to show up in your code for unguarded `match.data` accesses - you should properly guard for `undefined` values in those scenarios. @@ -582,7 +575,6 @@ - \[UNSTABLE] When middleware is enabled, make the `context` parameter read-only (via `Readonly`) so that TypeScript will not allow you to write arbitrary fields to it in loaders, actions, or middleware. ([#14097](https://github.com/remix-run/react-router/pull/14097)) - \[UNSTABLE] Rename and alter the signature/functionality of the `unstable_respond` API in `staticHandler.query`/`staticHandler.queryRoute` ([#14103](https://github.com/remix-run/react-router/pull/14103)) - - The API has been renamed to `unstable_generateMiddlewareResponse` for clarity - The main functional change is that instead of running the loaders/actions before calling `unstable_respond` and handing you the result, we now pass a `query`/`queryRoute` function as a parameter and you execute the loaders/actions inside your callback, giving you full access to pre-processing and error handling - The `query` version of the API now has a signature of `(query: (r: Request) => Promise) => Promise` @@ -1228,7 +1220,6 @@ ``` Similar to server-side requests, a fresh `context` will be created per navigation (or `fetcher` call). If you have initial data you'd like to populate in the context for every request, you can provide an `unstable_getContext` function at the root of your app: - - Library mode - `createBrowserRouter(routes, { unstable_getContext })` - Framework mode - `` @@ -1416,7 +1407,6 @@ _No changes_ - Remove `future.v7_normalizeFormMethod` future flag ([#11697](https://github.com/remix-run/react-router/pull/11697)) - For Remix consumers migrating to React Router, the `crypto` global from the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) is now required when using cookie and session APIs. This means that the following APIs are provided from `react-router` rather than platform-specific packages: ([#11837](https://github.com/remix-run/react-router/pull/11837)) - - `createCookie` - `createCookieSessionStorage` - `createMemorySessionStorage` @@ -1425,7 +1415,6 @@ _No changes_ For consumers running older versions of Node, the `installGlobals` function from `@remix-run/node` has been updated to define `globalThis.crypto`, using [Node's `require('node:crypto').webcrypto` implementation.](https://nodejs.org/api/webcrypto.html) Since platform-specific packages no longer need to implement this API, the following low-level APIs have been removed: - - `createCookieFactory` - `createSessionStorageFactory` - `createCookieSessionStorageFactory` @@ -1581,7 +1570,6 @@ _No changes_ ``` This initial implementation targets type inference for: - - `Params` : Path parameters from your routing config in `routes.ts` including file-based routing - `LoaderData` : Loader data from `loader` and/or `clientLoader` within your route module - `ActionData` : Action data from `action` and/or `clientAction` within your route module @@ -1596,7 +1584,6 @@ _No changes_ ``` Check out our docs for more: - - [_Explanations > Type Safety_](https://reactrouter.com/dev/guides/explanation/type-safety) - [_How-To > Setting up type safety_](https://reactrouter.com/dev/guides/how-to/setting-up-type-safety) diff --git a/packages/react-router/__tests__/dom/partial-hydration-test.tsx b/packages/react-router/__tests__/dom/partial-hydration-test.tsx index 9b61ac66ba..6ec3501380 100644 --- a/packages/react-router/__tests__/dom/partial-hydration-test.tsx +++ b/packages/react-router/__tests__/dom/partial-hydration-test.tsx @@ -66,9 +66,6 @@ describe("Partial Hydration Behavior", () => { }, ], { - future: { - v7_partialHydration: true, - }, patchRoutesOnNavigation({ path, patch }) { if (path === "/parent/child") { patch("parent", [ @@ -155,9 +152,6 @@ describe("Partial Hydration Behavior", () => { }, ], { - future: { - v7_partialHydration: true, - }, patchRoutesOnNavigation({ path, patch }) { if (path === "/parent/child") { patch("parent", [ @@ -248,9 +242,6 @@ describe("Partial Hydration Behavior", () => { }, ], { - future: { - v7_partialHydration: true, - }, async patchRoutesOnNavigation({ path, patch }) { await patchDfd.promise; if (path === "/parent/child") { @@ -853,4 +844,76 @@ function testPartialHydration( expect(rootSpy).toHaveBeenCalledTimes(1); expect(indexSpy).not.toHaveBeenCalled(); }); + + it("renders child fallback when ancestor route has hydration data and a hydrating loader", async () => { + let rootDfd = createDeferred(); + let rootLoader: LoaderFunction = () => rootDfd.promise; + rootLoader.hydrate = true; + let indexDfd = createDeferred(); + let indexLoader: LoaderFunction = () => indexDfd.promise; + indexLoader.hydrate = true; + let router = createTestRouter( + [ + { + id: "root", + path: "/", + loader: rootLoader, + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Home - ${data}`}

+ + + ); + }, + children: [ + { + id: "index", + index: true, + loader: indexLoader, + HydrateFallback: () =>

Index Loading...

, + Component() { + let data = useLoaderData() as string; + return

{`Index - ${data}`}

; + }, + }, + ], + }, + ], + { + hydrationData: { + loaderData: { + root: "HYDRATED ROOT", + }, + }, + }, + ); + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index Loading... +

+
" + `); + + rootDfd.resolve("ROOT UPDATED"); + indexDfd.resolve("INDEX UPDATED"); + await waitFor(() => screen.getByText(/INDEX UPDATED/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - ROOT UPDATED +

+

+ Index - INDEX UPDATED +

+
" + `); + }); } diff --git a/packages/react-router/__tests__/router/fetchers-test.ts b/packages/react-router/__tests__/router/fetchers-test.ts index 0fdf16c762..a8bcf82c96 100644 --- a/packages/react-router/__tests__/router/fetchers-test.ts +++ b/packages/react-router/__tests__/router/fetchers-test.ts @@ -172,6 +172,15 @@ describe("fetchers", () => { await A.loaders.foo.resolve("A DATA"); expect(A.fetcher.state).toBe("idle"); expect(A.fetcher.data).toBe("A DATA"); + expect(A.loaders.foo.stub).toHaveBeenCalledWith({ + params: {}, + request: new Request("http://localhost/foo", { + signal: A.loaders.foo.stub.mock.calls[0][0].request.signal, + }), + unstable_pattern: "/foo", + unstable_url: new URL("http://localhost/foo"), + context: {}, + }); }); it("loader re-fetch", async () => { @@ -212,11 +221,15 @@ describe("fetchers", () => { expect(A.fetcher.formAction).toBe("/foo"); expect(A.fetcher.formData).toEqual(createFormData({ key: "value" })); expect(A.fetcher.formEncType).toBe("application/x-www-form-urlencoded"); - expect( - new URL( - A.loaders.foo.stub.mock.calls[0][0].request.url, - ).searchParams.toString(), - ).toBe("key=value"); + expect(A.loaders.foo.stub).toHaveBeenCalledWith({ + params: {}, + request: new Request("http://localhost/foo?key=value", { + signal: A.loaders.foo.stub.mock.calls[0][0].request.signal, + }), + unstable_pattern: "/foo", + unstable_url: new URL("http://localhost/foo?key=value"), + context: {}, + }); await A.loaders.foo.resolve("A DATA"); expect(A.fetcher.state).toBe("idle"); @@ -264,6 +277,13 @@ describe("fetchers", () => { formData: createFormData({ key: "value" }), }); expect(A.fetcher.state).toBe("submitting"); + expect(A.actions.foo.stub).toHaveBeenCalledWith({ + params: {}, + request: expect.any(Request), + unstable_pattern: "/foo", + unstable_url: new URL("http://localhost/foo"), + context: {}, + }); await A.actions.foo.resolve("A ACTION"); expect(A.fetcher.state).toBe("loading"); @@ -374,6 +394,7 @@ describe("fetchers", () => { signal: A.loaders.root.stub.mock.calls[0][0].request.signal, }), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); }); @@ -3375,6 +3396,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -3405,6 +3427,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -3433,6 +3456,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -3461,6 +3485,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -3490,6 +3515,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -3521,6 +3547,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -3551,6 +3578,7 @@ describe("fetchers", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); diff --git a/packages/react-router/__tests__/router/router-test.ts b/packages/react-router/__tests__/router/router-test.ts index c5f9bbee35..7bd315f1b1 100644 --- a/packages/react-router/__tests__/router/router-test.ts +++ b/packages/react-router/__tests__/router/router-test.ts @@ -1752,6 +1752,7 @@ describe("a router", () => { signal: nav.loaders.tasks.stub.mock.calls[0][0].request.signal, }), unstable_pattern: "/tasks", + unstable_url: new URL("http://localhost/tasks"), context: {}, }); @@ -1762,6 +1763,7 @@ describe("a router", () => { signal: nav2.loaders.tasksId.stub.mock.calls[0][0].request.signal, }), unstable_pattern: "/tasks/:id", + unstable_url: new URL("http://localhost/tasks/1"), context: {}, }); @@ -1772,6 +1774,7 @@ describe("a router", () => { signal: nav3.loaders.tasks.stub.mock.calls[0][0].request.signal, }), unstable_pattern: "/tasks", + unstable_url: new URL("http://localhost/tasks?foo=bar#hash"), context: {}, }); @@ -1784,6 +1787,7 @@ describe("a router", () => { signal: nav4.loaders.tasks.stub.mock.calls[0][0].request.signal, }), unstable_pattern: "/tasks", + unstable_url: new URL("http://localhost/tasks?foo=bar#hash"), context: {}, }); @@ -2210,6 +2214,7 @@ describe("a router", () => { params: {}, request: expect.any(Request), unstable_pattern: "/tasks", + unstable_url: new URL("http://localhost/tasks"), context: {}, }); @@ -2254,7 +2259,8 @@ describe("a router", () => { expect(nav.actions.tasks.stub).toHaveBeenCalledWith({ params: {}, request: expect.any(Request), - unstable_pattern: expect.any(String), + unstable_pattern: "/tasks", + unstable_url: new URL("http://localhost/tasks?foo=bar"), context: {}, }); // Assert request internals, cannot do a deep comparison above since some @@ -2289,6 +2295,7 @@ describe("a router", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); diff --git a/packages/react-router/__tests__/router/ssr-test.ts b/packages/react-router/__tests__/router/ssr-test.ts index 317bbe50d8..2cf9bd61c2 100644 --- a/packages/react-router/__tests__/router/ssr-test.ts +++ b/packages/react-router/__tests__/router/ssr-test.ts @@ -837,12 +837,29 @@ describe("ssr", () => { ]); await query(createRequest("/child")); + expect(rootLoaderStub).toHaveBeenCalledTimes(1); + expect(rootLoaderStub).toHaveBeenCalledWith({ + request: new Request("http://localhost/child"), + unstable_pattern: "/child", + unstable_url: new URL("http://localhost/child"), + params: {}, + context: {}, + }); // @ts-expect-error let rootLoaderRequest = rootLoaderStub.mock.calls[0][0]?.request; - // @ts-expect-error - let childLoaderRequest = childLoaderStub.mock.calls[0][0]?.request; expect(rootLoaderRequest.method).toBe("GET"); expect(rootLoaderRequest.url).toBe("http://localhost/child"); + + expect(childLoaderStub).toHaveBeenCalledTimes(1); + expect(childLoaderStub).toHaveBeenCalledWith({ + request: new Request("http://localhost/child"), + unstable_pattern: "/child", + unstable_url: new URL("http://localhost/child"), + params: {}, + context: {}, + }); + // @ts-expect-error + let childLoaderRequest = childLoaderStub.mock.calls[0][0]?.request; expect(childLoaderRequest.method).toBe("GET"); expect(childLoaderRequest.url).toBe("http://localhost/child"); }); @@ -874,6 +891,14 @@ describe("ssr", () => { }), ); + expect(actionStub).toHaveBeenCalledTimes(1); + expect(actionStub).toHaveBeenCalledWith({ + request: expect.any(Request), + unstable_pattern: "/child", + unstable_url: new URL("http://localhost/child"), + params: {}, + context: {}, + }); // @ts-expect-error let actionRequest = actionStub.mock.calls[0][0]?.request; expect(actionRequest.method).toBe("POST"); @@ -883,14 +908,31 @@ describe("ssr", () => { ); expect((await actionRequest.formData()).get("key")).toBe("value"); + expect(rootLoaderStub).toHaveBeenCalledTimes(1); + expect(rootLoaderStub).toHaveBeenCalledWith({ + request: expect.any(Request), + unstable_pattern: "/child", + unstable_url: new URL("http://localhost/child"), + params: {}, + context: {}, + }); // @ts-expect-error let rootLoaderRequest = rootLoaderStub.mock.calls[0][0]?.request; - // @ts-expect-error - let childLoaderRequest = childLoaderStub.mock.calls[0][0]?.request; expect(rootLoaderRequest.method).toBe("GET"); expect(rootLoaderRequest.url).toBe("http://localhost/child"); expect(rootLoaderRequest.headers.get("test")).toBe("value"); expect(await rootLoaderRequest.text()).toBe(""); + + expect(childLoaderStub).toHaveBeenCalledTimes(1); + expect(childLoaderStub).toHaveBeenCalledWith({ + request: expect.any(Request), + unstable_pattern: "/child", + unstable_url: new URL("http://localhost/child"), + params: {}, + context: {}, + }); + // @ts-expect-error + let childLoaderRequest = childLoaderStub.mock.calls[0][0]?.request; expect(childLoaderRequest.method).toBe("GET"); expect(childLoaderRequest.url).toBe("http://localhost/child"); expect(childLoaderRequest.headers.get("test")).toBe("value"); diff --git a/packages/react-router/__tests__/router/submission-test.ts b/packages/react-router/__tests__/router/submission-test.ts index 7cc38b1c31..fcfb3d369a 100644 --- a/packages/react-router/__tests__/router/submission-test.ts +++ b/packages/react-router/__tests__/router/submission-test.ts @@ -949,6 +949,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -984,6 +985,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -1017,6 +1019,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -1122,6 +1125,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -1161,6 +1165,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); @@ -1197,6 +1202,7 @@ describe("submissions", () => { params: {}, request: expect.any(Request), unstable_pattern: expect.any(String), + unstable_url: expect.any(URL), context: {}, }); diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 696362f091..a398f1d0ed 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -30,6 +30,9 @@ export type { export type { ActionFunction, ActionFunctionArgs, + BaseRouteObject, + DataRouteMatch, + DataRouteObject, DataStrategyFunction, DataStrategyFunctionArgs, DataStrategyMatch, @@ -39,16 +42,22 @@ export type { FormEncType, FormMethod, HTMLFormMethod, + IndexRouteObject, LazyRouteFunction, LoaderFunction, LoaderFunctionArgs, MiddlewareFunction, + NonIndexRouteObject, ParamParseKey, Params, + PatchRoutesOnNavigationFunction, + PatchRoutesOnNavigationFunctionArgs, PathMatch, PathParam, PathPattern, RedirectFunction, + RouteMatch, + RouteObject, RouterContext, ShouldRevalidateFunction, ShouldRevalidateFunctionArgs, @@ -87,18 +96,7 @@ export { } from "./lib/router/utils"; // Expose react-router public API -export type { - DataRouteMatch, - DataRouteObject, - IndexRouteObject, - NavigateOptions, - Navigator, - NonIndexRouteObject, - PatchRoutesOnNavigationFunction, - PatchRoutesOnNavigationFunctionArgs, - RouteMatch, - RouteObject, -} from "./lib/context"; +export type { NavigateOptions, Navigator } from "./lib/context"; export { AwaitContextProvider as UNSAFE_AwaitContextProvider } from "./lib/context"; export type { AwaitProps, diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index e6a7a3d7af..78252451be 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -24,9 +24,15 @@ import type { } from "./router/router"; import { createRouter } from "./router/router"; import type { + DataRouteObject, DataStrategyFunction, + IndexRouteObject, LazyRouteFunction, + NonIndexRouteObject, Params, + PatchRoutesOnNavigationFunction, + RouteMatch, + RouteObject, TrackedPromise, } from "./router/utils"; import { @@ -36,16 +42,7 @@ import { stripBasename, } from "./router/utils"; -import type { - DataRouteObject, - IndexRouteObject, - Navigator, - NonIndexRouteObject, - PatchRoutesOnNavigationFunction, - RouteMatch, - RouteObject, - ViewTransitionContextObject, -} from "./context"; +import type { Navigator, ViewTransitionContextObject } from "./context"; import { AwaitContext, DataRouterContext, @@ -1215,6 +1212,9 @@ export interface IndexRouteProps { ErrorBoundary?: React.ComponentType | null; } +/** + * @category Types + */ export type RouteProps = PathRouteProps | LayoutRouteProps | IndexRouteProps; /** diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index ebe1b0f73b..bdfb1ccef3 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -11,79 +11,7 @@ import type { Router, StaticHandlerContext, } from "./router/router"; -import type { - AgnosticIndexRouteObject, - AgnosticNonIndexRouteObject, - AgnosticPatchRoutesOnNavigationFunction, - AgnosticPatchRoutesOnNavigationFunctionArgs, - AgnosticRouteMatch, - LazyRouteDefinition, - TrackedPromise, -} from "./router/utils"; - -// Create react-specific types from the agnostic types in @remix-run/router to -// export from react-router -export interface IndexRouteObject { - caseSensitive?: AgnosticIndexRouteObject["caseSensitive"]; - path?: AgnosticIndexRouteObject["path"]; - id?: AgnosticIndexRouteObject["id"]; - middleware?: AgnosticIndexRouteObject["middleware"]; - loader?: AgnosticIndexRouteObject["loader"]; - action?: AgnosticIndexRouteObject["action"]; - hasErrorBoundary?: AgnosticIndexRouteObject["hasErrorBoundary"]; - shouldRevalidate?: AgnosticIndexRouteObject["shouldRevalidate"]; - handle?: AgnosticIndexRouteObject["handle"]; - index: true; - children?: undefined; - element?: React.ReactNode | null; - hydrateFallbackElement?: React.ReactNode | null; - errorElement?: React.ReactNode | null; - Component?: React.ComponentType | null; - HydrateFallback?: React.ComponentType | null; - ErrorBoundary?: React.ComponentType | null; - lazy?: LazyRouteDefinition; -} - -export interface NonIndexRouteObject { - caseSensitive?: AgnosticNonIndexRouteObject["caseSensitive"]; - path?: AgnosticNonIndexRouteObject["path"]; - id?: AgnosticNonIndexRouteObject["id"]; - middleware?: AgnosticNonIndexRouteObject["middleware"]; - loader?: AgnosticNonIndexRouteObject["loader"]; - action?: AgnosticNonIndexRouteObject["action"]; - hasErrorBoundary?: AgnosticNonIndexRouteObject["hasErrorBoundary"]; - shouldRevalidate?: AgnosticNonIndexRouteObject["shouldRevalidate"]; - handle?: AgnosticNonIndexRouteObject["handle"]; - index?: false; - children?: RouteObject[]; - element?: React.ReactNode | null; - hydrateFallbackElement?: React.ReactNode | null; - errorElement?: React.ReactNode | null; - Component?: React.ComponentType | null; - HydrateFallback?: React.ComponentType | null; - ErrorBoundary?: React.ComponentType | null; - lazy?: LazyRouteDefinition; -} - -export type RouteObject = IndexRouteObject | NonIndexRouteObject; - -export type DataRouteObject = RouteObject & { - children?: DataRouteObject[]; - id: string; -}; - -export interface RouteMatch< - ParamKey extends string = string, - RouteObjectType extends RouteObject = RouteObject, -> extends AgnosticRouteMatch {} - -export interface DataRouteMatch extends RouteMatch {} - -export type PatchRoutesOnNavigationFunctionArgs = - AgnosticPatchRoutesOnNavigationFunctionArgs; - -export type PatchRoutesOnNavigationFunction = - AgnosticPatchRoutesOnNavigationFunction; +import type { TrackedPromise, RouteMatch } from "./router/utils"; export interface DataRouterContextObject // Omit `future` since those can be pulled from the `router` diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index d229f31320..ddb69df172 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -188,7 +188,8 @@ function createHydratedRouter({ unstable_instrumentations, mapRouteProperties, future: { - middleware: ssrInfo.context.future.v8_middleware, + unstable_passThroughRequests: + ssrInfo.context.future.unstable_passThroughRequests, }, dataStrategy: getTurboStreamSingleFetchDataStrategy( () => router, diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index d3924ca980..7b804e76fa 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -30,6 +30,8 @@ import type { DataStrategyFunction, FormEncType, HTMLFormMethod, + PatchRoutesOnNavigationFunction, + RouteObject, UIMatch, } from "../router/utils"; import { @@ -73,11 +75,7 @@ import { mapRouteProperties, hydrationRouteProperties, } from "../components"; -import type { - RouteObject, - NavigateOptions, - PatchRoutesOnNavigationFunction, -} from "../context"; +import type { NavigateOptions } from "../context"; import { DataRouterContext, DataRouterStateContext, @@ -1222,7 +1220,7 @@ export interface LinkProps unstable_defaultShouldRevalidate?: boolean; /** - * Masked path for for this navigation, when you want to navigate the router to + * Masked path for this navigation, when you want to navigate the router to * one location but display a separate location in the URL bar. * * This is useful for contextual navigations such as opening an image in a modal diff --git a/packages/react-router/lib/dom/server.tsx b/packages/react-router/lib/dom/server.tsx index ca9c5a3e8d..8714f1859b 100644 --- a/packages/react-router/lib/dom/server.tsx +++ b/packages/react-router/lib/dom/server.tsx @@ -20,13 +20,12 @@ import { IDLE_NAVIGATION, createStaticHandler as routerCreateStaticHandler, } from "../router/router"; -import type { RouteManifest } from "../router/utils"; +import type { RouteManifest, RouteObject } from "../router/utils"; import { convertRoutesToDataRoutes, isRouteErrorResponse, } from "../router/utils"; import { DataRoutes, Router, mapRouteProperties } from "../components"; -import type { RouteObject } from "../context"; import { DataRouterContext, DataRouterStateContext, @@ -390,9 +389,9 @@ export function createStaticRouter( manifest, ); - // Because our context matches may be from a framework-agnostic set of - // routes passed to createStaticHandler(), we update them here with our - // newly created/enhanced data routes + // Because our context matches may be from a set of routes passed to + // createStaticHandler(), we update them here with our newly created/enhanced + // data routes let matches = context.matches.map((match) => { let route = manifest[match.route.id] || match.route; return { @@ -411,6 +410,7 @@ export function createStaticRouter( get future() { return { v8_middleware: false, + unstable_passThroughRequests: false, ...opts?.future, }; }, diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx index 67d047b869..e3ffa5f8eb 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -6,7 +6,7 @@ import type { import * as React from "react"; import type { RouterState } from "../../router/router"; -import type { AgnosticDataRouteMatch } from "../../router/utils"; +import type { DataRouteMatch } from "../../router/utils"; import { matchRoutes } from "../../router/utils"; import type { FrameworkContextObject } from "./entry"; @@ -356,7 +356,7 @@ export function PrefetchPageLinks({ page, ...linkProps }: PageLinkDescriptor) { return ; } -function useKeyedPrefetchLinks(matches: AgnosticDataRouteMatch[]) { +function useKeyedPrefetchLinks(matches: DataRouteMatch[]) { let { manifest, routeModules } = useFrameworkContext(); let [keyedPrefetchLinks, setKeyedPrefetchLinks] = React.useState< @@ -387,7 +387,7 @@ function PrefetchPageLinksImpl({ matches: nextMatches, ...linkProps }: PageLinkDescriptor & { - matches: AgnosticDataRouteMatch[]; + matches: DataRouteMatch[]; }) { let location = useLocation(); let { future, manifest, routeModules } = useFrameworkContext(); diff --git a/packages/react-router/lib/dom/ssr/entry.ts b/packages/react-router/lib/dom/ssr/entry.ts index f965b43444..c8eaf06c74 100644 --- a/packages/react-router/lib/dom/ssr/entry.ts +++ b/packages/react-router/lib/dom/ssr/entry.ts @@ -44,6 +44,7 @@ export interface EntryContext extends FrameworkContextObject { } export interface FutureConfig { + unstable_passThroughRequests: boolean; unstable_subResourceIntegrity: boolean; unstable_trailingSlashAwareDataRequests: boolean; v8_middleware: boolean; diff --git a/packages/react-router/lib/dom/ssr/fog-of-war.ts b/packages/react-router/lib/dom/ssr/fog-of-war.ts index a9aaa8e861..72c24c7f8f 100644 --- a/packages/react-router/lib/dom/ssr/fog-of-war.ts +++ b/packages/react-router/lib/dom/ssr/fog-of-war.ts @@ -1,7 +1,9 @@ import * as React from "react"; -import type { PatchRoutesOnNavigationFunction } from "../../context"; import type { Router as DataRouter } from "../../router/router"; -import type { RouteManifest } from "../../router/utils"; +import type { + PatchRoutesOnNavigationFunction, + RouteManifest, +} from "../../router/utils"; import { matchRoutes } from "../../router/utils"; import type { AssetsManifest } from "./entry"; import type { RouteModules } from "./routeModules"; diff --git a/packages/react-router/lib/dom/ssr/hydration.tsx b/packages/react-router/lib/dom/ssr/hydration.tsx index 4185633bc7..bbcc904221 100644 --- a/packages/react-router/lib/dom/ssr/hydration.tsx +++ b/packages/react-router/lib/dom/ssr/hydration.tsx @@ -1,6 +1,6 @@ -import type { DataRouteObject } from "../../context"; import type { Path } from "../../router/history"; import type { Router as DataRouter, HydrationState } from "../../router/router"; +import type { DataRouteObject } from "../../router/utils"; import { matchRoutes } from "../../router/utils"; import type { ClientLoaderFunction } from "./routeModules"; import { shouldHydrateRouteLoader } from "./routes"; diff --git a/packages/react-router/lib/dom/ssr/links.ts b/packages/react-router/lib/dom/ssr/links.ts index 55f6dbc87b..ec5fc8dee2 100644 --- a/packages/react-router/lib/dom/ssr/links.ts +++ b/packages/react-router/lib/dom/ssr/links.ts @@ -1,5 +1,5 @@ import type { Location } from "../../router/history"; -import type { AgnosticDataRouteMatch } from "../../router/utils"; +import type { DataRouteMatch } from "../../router/utils"; import type { AssetsManifest } from "./entry"; import type { RouteModules, RouteModule } from "./routeModules"; @@ -16,7 +16,7 @@ import type { * loaded already. */ export function getKeyedLinksForMatches( - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], routeModules: RouteModules, manifest: AssetsManifest, ): KeyedLinkDescriptor[] { @@ -147,7 +147,7 @@ function isHtmlLinkDescriptor(object: any): object is HtmlLinkDescriptor { export type KeyedHtmlLinkDescriptor = { key: string; link: HtmlLinkDescriptor }; export async function getKeyedPrefetchLinks( - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], manifest: AssetsManifest, routeModules: RouteModules, ): Promise { @@ -178,18 +178,18 @@ export async function getKeyedPrefetchLinks( // This is ridiculously identical to transition.ts `filterMatchesToLoad` export function getNewMatchesForLinks( page: string, - nextMatches: AgnosticDataRouteMatch[], - currentMatches: AgnosticDataRouteMatch[], + nextMatches: DataRouteMatch[], + currentMatches: DataRouteMatch[], manifest: AssetsManifest, location: Location, mode: "data" | "assets", -): AgnosticDataRouteMatch[] { - let isNew = (match: AgnosticDataRouteMatch, index: number) => { +): DataRouteMatch[] { + let isNew = (match: DataRouteMatch, index: number) => { if (!currentMatches[index]) return true; return match.route.id !== currentMatches[index].route.id; }; - let matchPathChanged = (match: AgnosticDataRouteMatch, index: number) => { + let matchPathChanged = (match: DataRouteMatch, index: number) => { return ( // param change, /users/123 -> /users/456 currentMatches[index].pathname !== match.pathname || @@ -244,7 +244,7 @@ export function getNewMatchesForLinks( } export function getModuleLinkHrefs( - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], manifest: AssetsManifest, { includeHydrateFallback }: { includeHydrateFallback?: boolean } = {}, ): string[] { diff --git a/packages/react-router/lib/dom/ssr/routeModules.ts b/packages/react-router/lib/dom/ssr/routeModules.ts index e8926d46b4..43a79a8ece 100644 --- a/packages/react-router/lib/dom/ssr/routeModules.ts +++ b/packages/react-router/lib/dom/ssr/routeModules.ts @@ -8,11 +8,11 @@ import type { MiddlewareFunction, Params, ShouldRevalidateFunction, + DataRouteMatch, DataStrategyResult, } from "../../router/utils"; import type { EntryRoute } from "./routes"; -import type { DataRouteMatch } from "../../context"; import type { LinkDescriptor } from "../../router/links"; import type { SerializeFrom } from "../../types/route-data"; diff --git a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx index b9038287cc..4ecc5647ff 100644 --- a/packages/react-router/lib/dom/ssr/routes-test-stub.tsx +++ b/packages/react-router/lib/dom/ssr/routes-test-stub.tsx @@ -2,15 +2,13 @@ import * as React from "react"; import type { ActionFunction, ActionFunctionArgs, + DataRouteObject, + IndexRouteObject, LoaderFunction, LoaderFunctionArgs, MiddlewareFunction, -} from "../../router/utils"; -import type { - DataRouteObject, - IndexRouteObject, NonIndexRouteObject, -} from "../../context"; +} from "../../router/utils"; import type { LinksFunction, MetaFunction, RouteModules } from "./routeModules"; import type { InitialEntry } from "../../router/history"; import type { HydrationState } from "../../router/router"; @@ -18,7 +16,6 @@ import { convertRoutesToDataRoutes, RouterContextProvider, } from "../../router/utils"; -import type { MiddlewareEnabled } from "../../types/future"; import type { AppLoadContext } from "../../server-runtime/data"; import type { AssetsManifest, @@ -132,6 +129,8 @@ export function createRoutesStub( if (routerRef.current == null) { frameworkContextRef.current = { future: { + unstable_passThroughRequests: + future?.unstable_passThroughRequests === true, unstable_subResourceIntegrity: future?.unstable_subResourceIntegrity === true, v8_middleware: future?.v8_middleware === true, @@ -154,7 +153,7 @@ export function createRoutesStub( // the manifest and routeModules during the walk let patched = processRoutes( // @ts-expect-error `StubRouteObject` is stricter about `loader`/`action` - // types compared to `AgnosticRouteObject` + // types compared to `RouteObject` convertRoutesToDataRoutes(routes, (r) => r), _context !== undefined ? _context diff --git a/packages/react-router/lib/dom/ssr/routes.tsx b/packages/react-router/lib/dom/ssr/routes.tsx index 0c1682c250..3f87f76666 100644 --- a/packages/react-router/lib/dom/ssr/routes.tsx +++ b/packages/react-router/lib/dom/ssr/routes.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import type { HydrationState } from "../../router/router"; import type { ActionFunctionArgs, + DataRouteObject, LoaderFunctionArgs, RouteManifest, ShouldRevalidateFunction, @@ -21,7 +22,6 @@ import { RemixRootDefaultErrorBoundary } from "./errorBoundaries"; import { RemixRootDefaultHydrateFallback } from "./fallback"; import invariant from "./invariant"; import { useRouteError } from "../../hooks"; -import type { DataRouteObject } from "../../context"; export interface Route { index?: boolean; @@ -340,7 +340,13 @@ export function createClientRoutes( (routeModule.clientLoader?.hydrate === true || !route.hasLoader); dataRoute.loader = async ( - { request, params, context, unstable_pattern }: LoaderFunctionArgs, + { + request, + params, + context, + unstable_pattern, + unstable_url, + }: LoaderFunctionArgs, singleFetch?: unknown, ) => { try { @@ -359,6 +365,7 @@ export function createClientRoutes( params, context, unstable_pattern, + unstable_url, async serverLoader() { preventInvalidServerHandlerCall("loader", route); @@ -394,7 +401,13 @@ export function createClientRoutes( ); dataRoute.action = ( - { request, params, context, unstable_pattern }: ActionFunctionArgs, + { + request, + params, + context, + unstable_pattern, + unstable_url, + }: ActionFunctionArgs, singleFetch?: unknown, ) => { return prefetchStylesAndCallHandler(async () => { @@ -414,6 +427,7 @@ export function createClientRoutes( params, context, unstable_pattern, + unstable_url, async serverAction() { preventInvalidServerHandlerCall("action", route); return fetchServerAction(singleFetch); diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index fdf78ff273..b952903e0c 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -4,6 +4,7 @@ import { decode } from "../../../vendor/turbo-stream-v2/turbo-stream"; import type { Router as DataRouter } from "../../router/router"; import { isDataWithResponseInit, isResponse } from "../../router/router"; import type { + DataRouteMatch, DataStrategyFunction, DataStrategyFunctionArgs, DataStrategyResult, @@ -20,7 +21,6 @@ import type { AssetsManifest, EntryContext } from "./entry"; import { escapeHtml } from "./markup"; import invariant from "./invariant"; import type { RouteModules } from "./routeModules"; -import type { DataRouteMatch } from "../../context"; export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect"); diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 29887101ef..fbc561545c 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -1,11 +1,5 @@ import * as React from "react"; -import type { - DataRouteMatch, - NavigateOptions, - RouteContextObject, - RouteMatch, - RouteObject, -} from "./context"; +import type { NavigateOptions, RouteContextObject } from "./context"; import { AwaitContext, DataRouterContext, @@ -34,10 +28,13 @@ import type { } from "./router/router"; import { IDLE_BLOCKER } from "./router/router"; import type { + DataRouteMatch, ParamParseKey, Params, PathMatch, PathPattern, + RouteMatch, + RouteObject, UIMatch, } from "./router/utils"; import { @@ -300,7 +297,7 @@ function useIsomorphicLayoutEffect( * * Be cautious with `navigate(number)`. If your application can load up to a * route that has a button that tries to navigate forward/back, there may not be - * a `[`History`](https://developer.mozilla.org/en-US/docs/Web/API/History) + * a [`History`](https://developer.mozilla.org/en-US/docs/Web/API/History) * entry to go back or forward to, or it can go somewhere you don't expect * (like a different domain). * diff --git a/packages/react-router/lib/router/instrumentation.ts b/packages/react-router/lib/router/instrumentation.ts index 4ba8a16063..43fef3185e 100644 --- a/packages/react-router/lib/router/instrumentation.ts +++ b/packages/react-router/lib/router/instrumentation.ts @@ -5,7 +5,7 @@ import { createPath, invariant } from "./history"; import type { Router } from "./router"; import type { ActionFunctionArgs, - AgnosticDataRouteObject, + DataRouteObject, FormEncType, HTMLFormMethod, LazyRouteObject, @@ -141,7 +141,7 @@ const UninstrumentedSymbol = Symbol("Uninstrumented"); export function getRouteInstrumentationUpdates( fns: unstable_InstrumentRouteFunction[], - route: Readonly, + route: Readonly, ) { let aggregated: { lazy: InstrumentFunction[]; @@ -178,23 +178,23 @@ export function getRouteInstrumentationUpdates( ); let updates: { - middleware?: AgnosticDataRouteObject["middleware"]; - loader?: AgnosticDataRouteObject["loader"]; - action?: AgnosticDataRouteObject["action"]; - lazy?: AgnosticDataRouteObject["lazy"]; + middleware?: DataRouteObject["middleware"]; + loader?: DataRouteObject["loader"]; + action?: DataRouteObject["action"]; + lazy?: DataRouteObject["lazy"]; } = {}; // Instrument lazy functions if (typeof route.lazy === "function" && aggregated.lazy.length > 0) { let instrumented = wrapImpl(aggregated.lazy, route.lazy, () => undefined); if (instrumented) { - updates.lazy = instrumented as AgnosticDataRouteObject["lazy"]; + updates.lazy = instrumented as DataRouteObject["lazy"]; } } // Instrument the lazy object format if (typeof route.lazy === "object") { - let lazyObject: LazyRouteObject = route.lazy; + let lazyObject: LazyRouteObject = route.lazy; (["middleware", "loader", "action"] as const).forEach((key) => { let lazyFn = lazyObject[key]; let instrumentations = aggregated[`lazy.${key}`]; diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index e0c8ab445a..dfcbb108bf 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1,4 +1,3 @@ -import type { DataRouteMatch, RouteObject } from "../context"; import type { History, Location, Path, To } from "./history"; import { Action as NavigationType, @@ -20,10 +19,10 @@ import { instrumentClientSideRouter, } from "./instrumentation"; import type { - AgnosticDataRouteMatch, - AgnosticDataRouteObject, + DataRouteMatch, + DataRouteObject, DataStrategyMatch, - AgnosticRouteObject, + RouteObject, DataResult, DataStrategyFunction, DataStrategyFunctionArgs, @@ -42,7 +41,6 @@ import type { Submission, SuccessResult, UIMatch, - AgnosticPatchRoutesOnNavigationFunction, DataWithResponseInit, LoaderFunctionArgs, ActionFunctionArgs, @@ -50,6 +48,7 @@ import type { ActionFunction, MiddlewareFunction, MiddlewareNextFunction, + PatchRoutesOnNavigationFunction, } from "./utils"; import { ErrorResponseImpl, @@ -109,7 +108,7 @@ export interface Router { * * Return the routes for this router instance */ - get routes(): AgnosticDataRouteObject[]; + get routes(): DataRouteObject[]; /** * @private @@ -285,7 +284,7 @@ export interface Router { */ patchRoutes( routeId: string | null, - children: AgnosticRouteObject[], + children: RouteObject[], unstable_allowElementMutations?: boolean, ): void; @@ -296,7 +295,7 @@ export interface Router { * HMR needs to pass in-flight route updates to React Router * TODO: Replace this with granular route update APIs (addRoute, updateRoute, deleteRoute) */ - _internalSetRoutes(routes: AgnosticRouteObject[]): void; + _internalSetRoutes(routes: RouteObject[]): void; /** * @private @@ -337,7 +336,7 @@ export interface RouterState { /** * The current set of route matches */ - matches: AgnosticDataRouteMatch[]; + matches: DataRouteMatch[]; /** * Tracks whether we've completed our initial data load @@ -409,13 +408,15 @@ export type HydrationState = Partial< /** * Future flags to toggle new feature behavior */ -export interface FutureConfig {} +export interface FutureConfig { + unstable_passThroughRequests: boolean; +} /** * Initialization options for createRouter */ export interface RouterInit { - routes: AgnosticRouteObject[]; + routes: RouteObject[]; history: History; basename?: string; getContext?: () => MaybePromise; @@ -426,7 +427,7 @@ export interface RouterInit { hydrationData?: HydrationState; window?: Window; dataStrategy?: DataStrategyFunction; - patchRoutesOnNavigation?: AgnosticPatchRoutesOnNavigationFunction; + patchRoutesOnNavigation?: PatchRoutesOnNavigationFunction; } /** @@ -449,12 +450,12 @@ export interface StaticHandlerContext { * A StaticHandler instance manages a singular SSR navigation/fetch event */ export interface StaticHandler { - dataRoutes: AgnosticDataRouteObject[]; + dataRoutes: DataRouteObject[]; query( request: Request, opts?: { requestContext?: unknown; - filterMatchesToLoad?: (match: AgnosticDataRouteMatch) => boolean; + filterMatchesToLoad?: (match: DataRouteMatch) => boolean; skipLoaderErrorBubbling?: boolean; skipRevalidation?: boolean; dataStrategy?: DataStrategyFunction; @@ -462,10 +463,11 @@ export interface StaticHandler { query: ( r: Request, args?: { - filterMatchesToLoad?: (match: AgnosticDataRouteMatch) => boolean; + filterMatchesToLoad?: (match: DataRouteMatch) => boolean; }, ) => Promise, ) => MaybePromise; + unstable_normalizePath?: (request: Request) => Path; }, ): Promise; queryRoute( @@ -477,6 +479,7 @@ export interface StaticHandler { generateMiddlewareResponse?: ( queryRoute: (r: Request) => Promise, ) => MaybePromise; + unstable_normalizePath?: (request: Request) => Path; }, ): Promise; } @@ -793,7 +796,7 @@ interface FetchLoadMatch { */ interface RevalidatingFetcher extends FetchLoadMatch { key: string; - match: AgnosticDataRouteMatch | null; + match: DataRouteMatch | null; matches: DataStrategyMatch[] | null; request: Request | null; controller: AbortController | null; @@ -892,7 +895,7 @@ export function createRouter(init: RouterInit): Router { if (init.unstable_instrumentations) { let instrumentations = init.unstable_instrumentations; - mapRouteProperties = (route: AgnosticDataRouteObject) => { + mapRouteProperties = (route: DataRouteObject) => { return { ..._mapRouteProperties(route), ...getRouteInstrumentationUpdates( @@ -914,7 +917,7 @@ export function createRouter(init: RouterInit): Router { undefined, manifest, ); - let inFlightDataRoutes: AgnosticDataRouteObject[] | undefined; + let inFlightDataRoutes: DataRouteObject[] | undefined; let basename = init.basename || "/"; if (!basename.startsWith("/")) { basename = `/${basename}`; @@ -923,6 +926,7 @@ export function createRouter(init: RouterInit): Router { // Config driven behavior flags let future: FutureConfig = { + unstable_passThroughRequests: false, ...init.future, }; // Cleanup function for history @@ -1025,11 +1029,14 @@ export function createRouter(init: RouterInit): Router { } // Toggle renderFallback based on per-route values + // Using a `.forEach` is important instead of something like an `.every` + // here because we need to evaluate renderFallback for all matches renderFallback = false; - initialized = relevantMatches.every((m) => { + initialized = true; + relevantMatches.forEach((m) => { let status = getRouteHydrationStatus(m.route, loaderData, errors); renderFallback = renderFallback || status.renderFallback; - return !status.shouldLoad; + initialized = initialized && !status.shouldLoad; }); } } @@ -1273,7 +1280,7 @@ export function createRouter(init: RouterInit): Router { ) { return { ...m, - route: route as AgnosticDataRouteObject, + route: route as DataRouteObject, }; } return m; @@ -1907,7 +1914,7 @@ export function createRouter(init: RouterInit): Router { request: Request, location: Location, submission: Submission, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], scopedContext: RouterContextProvider, isFogOfWar: boolean, initialHydration: boolean, @@ -1991,6 +1998,7 @@ export function createRouter(init: RouterInit): Router { mapRouteProperties, manifest, request, + location, matches, actionMatch, initialHydration ? [] : hydrationRouteProperties, @@ -1998,6 +2006,7 @@ export function createRouter(init: RouterInit): Router { ); let results = await callDataStrategy( request, + location, dsMatches, scopedContext, null, @@ -2078,7 +2087,7 @@ export function createRouter(init: RouterInit): Router { async function handleLoaders( request: Request, location: Location, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], scopedContext: RouterContextProvider, isFogOfWar: boolean, overrideNavigation?: Navigation, @@ -2272,6 +2281,7 @@ export function createRouter(init: RouterInit): Router { dsMatches, revalidatingFetchers, request, + location, scopedContext, ); @@ -2465,7 +2475,7 @@ export function createRouter(init: RouterInit): Router { key: string, routeId: string, path: string, - requestMatches: AgnosticDataRouteMatch[], + requestMatches: DataRouteMatch[], scopedContext: RouterContextProvider, isFogOfWar: boolean, flushSync: boolean, @@ -2536,6 +2546,7 @@ export function createRouter(init: RouterInit): Router { mapRouteProperties, manifest, fetchRequest, + path, requestMatches, match, hydrationRouteProperties, @@ -2543,6 +2554,7 @@ export function createRouter(init: RouterInit): Router { ); let actionResults = await callDataStrategy( fetchRequest, + path, fetchMatches, scopedContext, key, @@ -2684,6 +2696,7 @@ export function createRouter(init: RouterInit): Router { dsMatches, revalidatingFetchers, revalidationRequest, + nextLocation, scopedContext, ); @@ -2782,7 +2795,7 @@ export function createRouter(init: RouterInit): Router { key: string, routeId: string, path: string, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], scopedContext: RouterContextProvider, isFogOfWar: boolean, flushSync: boolean, @@ -2842,6 +2855,7 @@ export function createRouter(init: RouterInit): Router { mapRouteProperties, manifest, fetchRequest, + path, matches, match, hydrationRouteProperties, @@ -2849,6 +2863,7 @@ export function createRouter(init: RouterInit): Router { ); let results = await callDataStrategy( fetchRequest, + path, dsMatches, scopedContext, key, @@ -3050,6 +3065,7 @@ export function createRouter(init: RouterInit): Router { // pass around the manifest, mapRouteProperties, etc. async function callDataStrategy( request: Request, + path: To, matches: DataStrategyMatch[], scopedContext: RouterContextProvider, fetcherKey: string | null, @@ -3060,6 +3076,7 @@ export function createRouter(init: RouterInit): Router { results = await callDataStrategyImpl( dataStrategyImpl as DataStrategyFunction, request, + path, matches, fetcherKey, scopedContext, @@ -3135,11 +3152,13 @@ export function createRouter(init: RouterInit): Router { matches: DataStrategyMatch[], fetchersToLoad: RevalidatingFetcher[], request: Request, + location: Location, scopedContext: RouterContextProvider, ) { // Kick off loaders and fetchers in parallel let loaderResultsPromise = callDataStrategy( request, + location, matches, scopedContext, null, @@ -3150,6 +3169,7 @@ export function createRouter(init: RouterInit): Router { if (f.matches && f.match && f.request && f.controller) { let results = await callDataStrategy( f.request, + f.path, f.matches, scopedContext, f.key, @@ -3428,7 +3448,7 @@ export function createRouter(init: RouterInit): Router { }; } - function getScrollKey(location: Location, matches: AgnosticDataRouteMatch[]) { + function getScrollKey(location: Location, matches: DataRouteMatch[]) { if (getScrollRestorationKey) { let key = getScrollRestorationKey( location, @@ -3441,7 +3461,7 @@ export function createRouter(init: RouterInit): Router { function saveScrollPosition( location: Location, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], ): void { if (savedScrollPositions && getScrollPosition) { let key = getScrollKey(location, matches); @@ -3451,7 +3471,7 @@ export function createRouter(init: RouterInit): Router { function getSavedScrollPosition( location: Location, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], ): number | null { if (savedScrollPositions) { let key = getScrollKey(location, matches); @@ -3464,13 +3484,13 @@ export function createRouter(init: RouterInit): Router { } function checkFogOfWar( - matches: AgnosticDataRouteMatch[] | null, - routesToUse: AgnosticDataRouteObject[], + matches: DataRouteMatch[] | null, + routesToUse: DataRouteObject[], pathname: string, - ): { active: boolean; matches: AgnosticDataRouteMatch[] | null } { + ): { active: boolean; matches: DataRouteMatch[] | null } { if (init.patchRoutesOnNavigation) { if (!matches) { - let fogMatches = matchRoutesImpl( + let fogMatches = matchRoutesImpl( routesToUse, pathname, basename, @@ -3483,7 +3503,7 @@ export function createRouter(init: RouterInit): Router { // If we matched a dynamic param or a splat, it might only be because // we haven't yet discovered other routes that would match with a // higher score. Call patchRoutesOnNavigation just to be sure - let partialMatches = matchRoutesImpl( + let partialMatches = matchRoutesImpl( routesToUse, pathname, basename, @@ -3499,12 +3519,12 @@ export function createRouter(init: RouterInit): Router { type DiscoverRoutesSuccessResult = { type: "success"; - matches: AgnosticDataRouteMatch[] | null; + matches: DataRouteMatch[] | null; }; type DiscoverRoutesErrorResult = { type: "error"; error: any; - partialMatches: AgnosticDataRouteMatch[]; + partialMatches: DataRouteMatch[]; }; type DiscoverRoutesAbortedResult = { type: "aborted" }; type DiscoverRoutesResult = @@ -3513,7 +3533,7 @@ export function createRouter(init: RouterInit): Router { | DiscoverRoutesAbortedResult; async function discoverRoutes( - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], pathname: string, signal: AbortSignal, fetcherKey?: string, @@ -3522,7 +3542,7 @@ export function createRouter(init: RouterInit): Router { return { type: "success", matches }; } - let partialMatches: AgnosticDataRouteMatch[] | null = matches; + let partialMatches: DataRouteMatch[] | null = matches; while (true) { let isNonHMR = inFlightDataRoutes == null; let routesToUse = inFlightDataRoutes || dataRoutes; @@ -3564,7 +3584,7 @@ export function createRouter(init: RouterInit): Router { } let newMatches = matchRoutes(routesToUse, pathname, basename); - let newPartialMatches: AgnosticDataRouteMatch[] | null = null; + let newPartialMatches: DataRouteMatch[] | null = null; if (newMatches) { if (Object.keys(newMatches[0].params).length === 0) { @@ -3598,7 +3618,7 @@ export function createRouter(init: RouterInit): Router { // Perform partial matching if we didn't already do it above if (!newPartialMatches) { - newPartialMatches = matchRoutesImpl( + newPartialMatches = matchRoutesImpl( routesToUse, pathname, basename, @@ -3618,16 +3638,13 @@ export function createRouter(init: RouterInit): Router { } } - function compareMatches( - a: AgnosticDataRouteMatch[], - b: AgnosticDataRouteMatch[], - ) { + function compareMatches(a: DataRouteMatch[], b: DataRouteMatch[]) { return ( a.length === b.length && a.every((m, i) => m.route.id === b[i].route.id) ); } - function _internalSetRoutes(newRoutes: AgnosticDataRouteObject[]) { + function _internalSetRoutes(newRoutes: DataRouteObject[]) { manifest = {}; inFlightDataRoutes = convertRoutesToDataRoutes( newRoutes, @@ -3639,7 +3656,7 @@ export function createRouter(init: RouterInit): Router { function patchRoutes( routeId: string | null, - children: AgnosticRouteObject[], + children: RouteObject[], unstable_allowElementMutations = false, ): void { let isNonHMR = inFlightDataRoutes == null; @@ -3727,11 +3744,11 @@ export interface CreateStaticHandlerOptions { basename?: string; mapRouteProperties?: MapRoutePropertiesFunction; unstable_instrumentations?: Pick[]; - future?: {}; + future?: Partial; } export function createStaticHandler( - routes: AgnosticRouteObject[], + routes: RouteObject[], opts?: CreateStaticHandlerOptions, ): StaticHandler { invariant( @@ -3744,13 +3761,19 @@ export function createStaticHandler( let _mapRouteProperties = opts?.mapRouteProperties || defaultMapRouteProperties; let mapRouteProperties = _mapRouteProperties; + // Currently unused in the static handler, but available for additional flags in the future + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let future: FutureConfig = { + unstable_passThroughRequests: false, // unused in static handler + ...opts?.future, + }; // Leverage the existing mapRouteProperties logic to execute instrumentRoute // (if it exists) on all routes in the application if (opts?.unstable_instrumentations) { let instrumentations = opts.unstable_instrumentations; - mapRouteProperties = (route: AgnosticDataRouteObject) => { + mapRouteProperties = (route: DataRouteObject) => { return { ..._mapRouteProperties(route), ...getRouteInstrumentationUpdates( @@ -3805,11 +3828,12 @@ export function createStaticHandler( skipRevalidation, dataStrategy, generateMiddlewareResponse, + unstable_normalizePath, }: Parameters[1] = {}, ): Promise { - let url = new URL(request.url); + let normalizePath = unstable_normalizePath || defaultNormalizePath; let method = request.method; - let location = createLocation("", createPath(url), null, "default"); + let location = createLocation("", normalizePath(request), null, "default"); let matches = matchRoutes(dataRoutes, location, basename); requestContext = requestContext != null ? requestContext : new RouterContextProvider(); @@ -3888,6 +3912,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, + unstable_url: createDataFunctionUrl(request, location), unstable_pattern: getRoutePattern(matches), matches, params: matches[0].params, @@ -3901,7 +3926,7 @@ export function createStaticHandler( revalidationRequest: Request, opts: { filterMatchesToLoad?: - | ((match: AgnosticDataRouteMatch) => boolean) + | ((match: DataRouteMatch) => boolean) | undefined; } = {}, ) => { @@ -4080,11 +4105,12 @@ export function createStaticHandler( requestContext, dataStrategy, generateMiddlewareResponse, + unstable_normalizePath, }: Parameters[1] = {}, ): Promise { - let url = new URL(request.url); + let normalizePath = unstable_normalizePath || defaultNormalizePath; let method = request.method; - let location = createLocation("", createPath(url), null, "default"); + let location = createLocation("", normalizePath(request), null, "default"); let matches = matchRoutes(dataRoutes, location, basename); requestContext = requestContext != null ? requestContext : new RouterContextProvider(); @@ -4120,6 +4146,7 @@ export function createStaticHandler( let response = await runServerMiddlewarePipeline( { request, + unstable_url: createDataFunctionUrl(request, location), unstable_pattern: getRoutePattern(matches), matches, params: matches[0].params, @@ -4211,12 +4238,12 @@ export function createStaticHandler( async function queryImpl( request: Request, location: Location, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], requestContext: unknown, dataStrategy: DataStrategyFunction | null, skipLoaderErrorBubbling: boolean, - routeMatch: AgnosticDataRouteMatch | null, - filterMatchesToLoad: ((m: AgnosticDataRouteMatch) => boolean) | null, + routeMatch: DataRouteMatch | null, + filterMatchesToLoad: ((m: DataRouteMatch) => boolean) | null, skipRevalidation: boolean, ): Promise | Response> { invariant( @@ -4228,6 +4255,7 @@ export function createStaticHandler( if (isMutationMethod(request.method)) { let result = await submit( request, + location, matches, routeMatch || getTargetMatch(matches, location), requestContext, @@ -4242,6 +4270,7 @@ export function createStaticHandler( let result = await loadRouteData( request, + location, matches, requestContext, dataStrategy, @@ -4277,13 +4306,14 @@ export function createStaticHandler( async function submit( request: Request, - matches: AgnosticDataRouteMatch[], - actionMatch: AgnosticDataRouteMatch, + location: Location, + matches: DataRouteMatch[], + actionMatch: DataRouteMatch, requestContext: unknown, dataStrategy: DataStrategyFunction | null, skipLoaderErrorBubbling: boolean, isRouteRequest: boolean, - filterMatchesToLoad: ((m: AgnosticDataRouteMatch) => boolean) | null, + filterMatchesToLoad: ((m: DataRouteMatch) => boolean) | null, skipRevalidation: boolean, ): Promise | Response> { let result: DataResult; @@ -4306,6 +4336,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + location, matches, actionMatch, [], @@ -4314,6 +4345,7 @@ export function createStaticHandler( let results = await callDataStrategy( request, + location, dsMatches, isRouteRequest, requestContext, @@ -4417,6 +4449,7 @@ export function createStaticHandler( let handlerContext = await loadRouteData( loaderRequest, + location, matches, requestContext, dataStrategy, @@ -4443,6 +4476,7 @@ export function createStaticHandler( let handlerContext = await loadRouteData( loaderRequest, + location, matches, requestContext, dataStrategy, @@ -4466,12 +4500,13 @@ export function createStaticHandler( async function loadRouteData( request: Request, - matches: AgnosticDataRouteMatch[], + location: Location, + matches: DataRouteMatch[], requestContext: unknown, dataStrategy: DataStrategyFunction | null, skipLoaderErrorBubbling: boolean, - routeMatch: AgnosticDataRouteMatch | null, - filterMatchesToLoad: ((match: AgnosticDataRouteMatch) => boolean) | null, + routeMatch: DataRouteMatch | null, + filterMatchesToLoad: ((match: DataRouteMatch) => boolean) | null, pendingActionResult?: PendingActionResult, ): Promise< | Omit< @@ -4501,6 +4536,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + location, matches, routeMatch, [], @@ -4520,6 +4556,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + location, pattern, match, [], @@ -4532,6 +4569,7 @@ export function createStaticHandler( mapRouteProperties, manifest, request, + location, pattern, match, [], @@ -4561,6 +4599,7 @@ export function createStaticHandler( let results = await callDataStrategy( request, + location, dsMatches, isRouteRequest, requestContext, @@ -4590,6 +4629,7 @@ export function createStaticHandler( // pass around the manifest, mapRouteProperties, etc. async function callDataStrategy( request: Request, + location: Location, matches: DataStrategyMatch[], isRouteRequest: boolean, requestContext: unknown, @@ -4598,6 +4638,7 @@ export function createStaticHandler( let results = await callDataStrategyImpl( dataStrategy || defaultDataStrategy, request, + location, matches, null, requestContext, @@ -4664,7 +4705,7 @@ export function createStaticHandler( * @category Utils */ export function getStaticContextFromError( - routes: AgnosticDataRouteObject[], + routes: DataRouteObject[], handlerContext: StaticHandlerContext, error: any, boundaryId?: string, @@ -4704,16 +4745,25 @@ function isSubmissionNavigation( ); } +function defaultNormalizePath(request: Request): Path { + let url = new URL(request.url); + return { + pathname: url.pathname, + search: url.search, + hash: url.hash, + }; +} + function normalizeTo( location: Path, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], basename: string, to: To | null, fromRouteId?: string, relative?: RelativeRoutingType, ) { - let contextualMatches: AgnosticDataRouteMatch[]; - let activeRouteMatch: AgnosticDataRouteMatch | undefined; + let contextualMatches: DataRouteMatch[]; + let activeRouteMatch: DataRouteMatch | undefined; if (fromRouteId) { // Grab matches up to the calling route so our route-relative logic is // relative to the correct source route @@ -4926,7 +4976,7 @@ function getMatchesToLoad( manifest: RouteManifest, history: History, state: RouterState, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], submission: Submission | undefined, location: Location, lazyRoutePropertiesToSkip: string[], @@ -4936,7 +4986,7 @@ function getMatchesToLoad( fetchersQueuedForDeletion: Set, fetchLoadMatches: Map, fetchRedirectIds: Set, - routesToUse: AgnosticDataRouteObject[], + routesToUse: DataRouteObject[], basename: string | undefined, hasPatchRoutesOnNavigation: boolean, pendingActionResult?: PendingActionResult, @@ -5023,6 +5073,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, request, + location, pattern, match, lazyRoutePropertiesToSkip, @@ -5067,6 +5118,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, request, + location, pattern, match, lazyRoutePropertiesToSkip, @@ -5148,6 +5200,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, fetchRequest, + f.path, fetcherMatches, fetcherMatch, lazyRoutePropertiesToSkip, @@ -5162,6 +5215,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, fetchRequest, + f.path, fetcherMatches, fetcherMatch, lazyRoutePropertiesToSkip, @@ -5190,6 +5244,7 @@ function getMatchesToLoad( mapRouteProperties, manifest, fetchRequest, + f.path, fetcherMatches, fetcherMatch, lazyRoutePropertiesToSkip, @@ -5227,7 +5282,7 @@ function routeHasLoaderOrMiddleware(route: RouteObject) { // except for when we are loading a route due to `loader.hydrate=true`, in which // case we don't want to render a fallback function getRouteHydrationStatus( - route: AgnosticDataRouteObject, + route: DataRouteObject, loaderData: RouteData | null | undefined, errors: RouteData | null | undefined, ): { shouldLoad: boolean; renderFallback: boolean } { @@ -5262,8 +5317,8 @@ function getRouteHydrationStatus( function isNewLoader( currentLoaderData: RouteData, - currentMatch: AgnosticDataRouteMatch, - match: AgnosticDataRouteMatch, + currentMatch: DataRouteMatch, + match: DataRouteMatch, ) { let isNew = // [a] -> [a, b] @@ -5280,8 +5335,8 @@ function isNewLoader( } function isNewRouteInstance( - currentMatch: AgnosticDataRouteMatch, - match: AgnosticDataRouteMatch, + currentMatch: DataRouteMatch, + match: DataRouteMatch, ) { let currentPath = currentMatch.route.path; return ( @@ -5296,7 +5351,7 @@ function isNewRouteInstance( } function shouldRevalidateLoader( - loaderMatch: AgnosticDataRouteMatch, + loaderMatch: DataRouteMatch, arg: ShouldRevalidateFunctionArgs, ) { if (loaderMatch.route.shouldRevalidate) { @@ -5311,13 +5366,13 @@ function shouldRevalidateLoader( function patchRoutesImpl( routeId: string | null, - children: AgnosticRouteObject[], - routesToUse: AgnosticDataRouteObject[], + children: RouteObject[], + routesToUse: DataRouteObject[], manifest: RouteManifest, mapRouteProperties: MapRoutePropertiesFunction, allowElementMutations: boolean, ) { - let childrenToPatch: AgnosticDataRouteObject[]; + let childrenToPatch: DataRouteObject[]; if (routeId) { let route = manifest[routeId]; invariant( @@ -5335,10 +5390,10 @@ function patchRoutesImpl( // Don't patch in routes we already know about so that `patch` is idempotent // to simplify user-land code. This is useful because we re-call the // `patchRoutesOnNavigation` function for matched routes with params. - let uniqueChildren: AgnosticRouteObject[] = []; + let uniqueChildren: RouteObject[] = []; let existingChildren: { - existingRoute: AgnosticRouteObject; - newRoute: AgnosticRouteObject; + existingRoute: RouteObject; + newRoute: RouteObject; }[] = []; children.forEach((newRoute) => { let existingRoute = childrenToPatch.find((existingRoute) => @@ -5392,8 +5447,8 @@ function patchRoutesImpl( } function isSameRoute( - newRoute: AgnosticRouteObject, - existingRoute: AgnosticRouteObject, + newRoute: RouteObject, + existingRoute: RouteObject, ): boolean { // Most optimal check is by id if ( @@ -5434,8 +5489,8 @@ function isSameRoute( } const lazyRoutePropertyCache = new WeakMap< - AgnosticDataRouteObject, - Partial>> + DataRouteObject, + Partial>> >(); const loadLazyRouteProperty = ({ @@ -5444,8 +5499,8 @@ const loadLazyRouteProperty = ({ manifest, mapRouteProperties, }: { - key: keyof AgnosticDataRouteObject; - route: AgnosticDataRouteObject; + key: keyof DataRouteObject; + route: DataRouteObject; manifest: RouteManifest; mapRouteProperties: MapRoutePropertiesFunction; }): Promise | undefined => { @@ -5516,10 +5571,7 @@ const loadLazyRouteProperty = ({ return propertyPromise; }; -const lazyRouteFunctionCache = new WeakMap< - AgnosticDataRouteObject, - Promise ->(); +const lazyRouteFunctionCache = new WeakMap>(); /** * Execute route.lazy functions to lazily load route modules (loader, action, @@ -5527,7 +5579,7 @@ const lazyRouteFunctionCache = new WeakMap< * with dataRoutes so those get updated as well. */ function loadLazyRoute( - route: AgnosticDataRouteObject, + route: DataRouteObject, type: "loader" | "action", manifest: RouteManifest, mapRouteProperties: MapRoutePropertiesFunction, @@ -5684,7 +5736,7 @@ function isNonNullable(value: T): value is NonNullable { } function loadLazyMiddlewareForMatches( - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], manifest: RouteManifest, mapRouteProperties: MapRoutePropertiesFunction, ): Promise | void { @@ -5739,7 +5791,7 @@ function runServerMiddlewarePipeline( ) & { // Don't use `DataStrategyFunctionArgs` directly so we can we reduce these // back from `DataStrategyMatch` to regular matches for use in the staticHandler - matches: AgnosticDataRouteMatch[]; + matches: DataRouteMatch[]; }, handler: () => Promise, errorHandler: ( @@ -5833,7 +5885,7 @@ async function runMiddlewarePipeline( ) & { // Don't use `DataStrategyFunctionArgs` directly so we can we reduce these // back from `DataStrategyMatch` to regular matches for use in the staticHandler - matches: AgnosticDataRouteMatch[]; + matches: DataRouteMatch[]; }, // Handler to generate a Result in the leaf next() function handler: () => Promise, @@ -5852,18 +5904,13 @@ async function runMiddlewarePipeline( nextResult: { value: Result } | undefined, ) => Promise, ): Promise { - let { matches, request, params, context, unstable_pattern } = args; + let { matches, ...dataFnArgs } = args; let tuples = matches.flatMap((m) => m.route.middleware ? m.route.middleware.map((fn) => [m.route.id, fn]) : [], ) as [string, MiddlewareFunction][]; let result = await callRouteMiddleware( - { - request, - params, - context, - unstable_pattern, - }, + dataFnArgs, tuples, handler, processResult, @@ -5985,6 +6032,7 @@ function getDataStrategyMatch( mapRouteProperties: MapRoutePropertiesFunction, manifest: RouteManifest, request: Request, + path: To, unstable_pattern: string, match: DataRouteMatch, lazyRoutePropertiesToSkip: string[], @@ -6055,6 +6103,7 @@ function getDataStrategyMatch( ) { return callLoaderOrAction({ request, + path, unstable_pattern, match, lazyHandlerPromise: _lazyPromises?.handler, @@ -6072,8 +6121,9 @@ function getTargetedDataStrategyMatches( mapRouteProperties: MapRoutePropertiesFunction, manifest: RouteManifest, request: Request, - matches: AgnosticDataRouteMatch[], - targetMatch: AgnosticDataRouteMatch, + path: To, + matches: DataRouteMatch[], + targetMatch: DataRouteMatch, lazyRoutePropertiesToSkip: string[], scopedContext: unknown, shouldRevalidateArgs: DataStrategyMatch["shouldRevalidateArgs"] = null, @@ -6102,6 +6152,7 @@ function getTargetedDataStrategyMatches( mapRouteProperties, manifest, request, + path, getRoutePattern(matches), match, lazyRoutePropertiesToSkip, @@ -6115,6 +6166,7 @@ function getTargetedDataStrategyMatches( async function callDataStrategyImpl( dataStrategyImpl: DataStrategyFunction, request: Request, + path: To, matches: DataStrategyMatch[], fetcherKey: string | null, scopedContext: unknown, @@ -6128,8 +6180,12 @@ async function callDataStrategyImpl( // Send all matches here to allow for a middleware-type implementation. // handler will be a no-op for unneeded routes and we filter those results // back out below. - let dataStrategyArgs = { + let dataStrategyArgs: Omit< + DataStrategyFunctionArgs, + "fetcherKey" | "runClientMiddleware" + > = { request, + unstable_url: createDataFunctionUrl(request, path), unstable_pattern: getRoutePattern(matches), params: matches[0].params, context: scopedContext, @@ -6188,6 +6244,7 @@ async function callDataStrategyImpl( // Default logic for calling a loader/action is the user has no specified a dataStrategy async function callLoaderOrAction({ request, + path, unstable_pattern, match, lazyHandlerPromise, @@ -6196,8 +6253,9 @@ async function callLoaderOrAction({ scopedContext, }: { request: Request; + path: To; unstable_pattern: string; - match: AgnosticDataRouteMatch; + match: DataRouteMatch; lazyHandlerPromise: Promise | undefined; lazyRoutePromise: Promise | undefined; handlerOverride: Parameters[0]; @@ -6230,6 +6288,7 @@ async function callLoaderOrAction({ return handler( { request, + unstable_url: createDataFunctionUrl(request, path), unstable_pattern, params: match.params, context: scopedContext, @@ -6416,7 +6475,7 @@ function normalizeRelativeRoutingRedirectResponse( response: Response, request: Request, routeId: string, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], basename: string, ) { let location = response.headers.get("Location"); @@ -6529,6 +6588,33 @@ function createClientSideRequest( return new Request(url, init); } +// Create the unstable_url object to pass to loaders/actions/middleware. +// We strip the `?index` param because that is a React Router implementation detail. +function createDataFunctionUrl(request: Request, path: To): URL { + let url = new URL(request.url); + + let parsed = typeof path === "string" ? parsePath(path) : path; + url.pathname = parsed.pathname || "/"; + + if (parsed.search) { + let searchParams = new URLSearchParams(parsed.search); + + // Strip naked index param, preserve any other index params with values + let indexValues = searchParams.getAll("index"); + searchParams.delete("index"); + for (let value of indexValues.filter(Boolean)) { + searchParams.append("index", value); + } + url.search = searchParams.size ? `?${searchParams.toString()}` : ""; + } else { + url.search = ""; + } + + url.hash = parsed.hash || ""; + + return url; +} + function convertFormDataToSearchParams(formData: FormData): URLSearchParams { let searchParams = new URLSearchParams(); @@ -6551,7 +6637,7 @@ function convertSearchParamsToFormData( } function processRouteLoaderData( - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], results: Record, pendingActionResult: PendingActionResult | undefined, isStaticHandler = false, @@ -6657,7 +6743,7 @@ function processRouteLoaderData( function processLoaderData( state: RouterState, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], results: Record, pendingActionResult: PendingActionResult | undefined, revalidatingFetchers: RevalidatingFetcher[], @@ -6713,7 +6799,7 @@ function processLoaderData( function mergeLoaderData( loaderData: RouteData, newLoaderData: RouteData, - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], errors: RouteData | null | undefined, ): RouteData { // Start with all new entries that are not being reset @@ -6766,9 +6852,9 @@ function getActionDataForCommit( // route specified by routeId) for the closest ancestor error boundary, // defaulting to the root match function findNearestBoundary( - matches: AgnosticDataRouteMatch[], + matches: DataRouteMatch[], routeId?: string, -): AgnosticDataRouteMatch { +): DataRouteMatch { let eligibleMatches = routeId ? matches.slice(0, matches.findIndex((m) => m.route.id === routeId) + 1) : [...matches]; @@ -6778,9 +6864,9 @@ function findNearestBoundary( ); } -function getShortCircuitMatches(routes: AgnosticDataRouteObject[]): { - matches: AgnosticDataRouteMatch[]; - route: AgnosticDataRouteObject; +function getShortCircuitMatches(routes: DataRouteObject[]): { + matches: DataRouteMatch[]; + route: DataRouteObject; } { // Prefer a root layout route if present, otherwise shim in a route object let route = @@ -6998,10 +7084,7 @@ function hasNakedIndexQuery(search: string): boolean { return new URLSearchParams(search).getAll("index").some((v) => v === ""); } -function getTargetMatch( - matches: AgnosticDataRouteMatch[], - location: Location | string, -) { +function getTargetMatch(matches: DataRouteMatch[], location: Path | string) { let search = typeof location === "string" ? parsePath(location).search : location.search; if ( diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index 7a5c10b7c7..09345aaabe 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -1,3 +1,4 @@ +import type * as React from "react"; import type { MiddlewareEnabled } from "../types/future"; import type { Equal, Expect } from "../types/utils"; import type { Location, Path, To } from "./history"; @@ -269,6 +270,15 @@ type DefaultContext = MiddlewareEnabled extends true interface DataFunctionArgs { /** A {@link https://developer.mozilla.org/en-US/docs/Web/API/Request Fetch Request instance} which you can use to read headers (like cookies, and {@link https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams URLSearchParams} from the request. */ request: Request; + /** + * A URL instance representing the application location being navigated to or fetched. + * Without `future.unstable_passThroughRequests` enabled, this matches `request.url`. + * With `future.unstable_passThroughRequests` enabled, this is a normalized + * URL with React-Router-specific implementation details removed (`.data` + * suffixes, `index`/`_routes` search params). + * The URL includes the origin from the request for convenience. + */ + unstable_url: URL; /** * Matched un-interpolated route pattern for the current path (i.e., /blog/:slug). * Mostly useful as a identifier to aggregate on for logging/tracing/etc. @@ -362,11 +372,11 @@ export interface ShouldRevalidateFunctionArgs { /** This is the url the navigation started from. You can compare it with `nextUrl` to decide if you need to revalidate this route's data. */ currentUrl: URL; /** These are the {@link https://reactrouter.com/start/framework/routing#dynamic-segments dynamic route params} from the URL that can be compared to the `nextParams` to decide if you need to reload or not. Perhaps you're using only a partial piece of the param for data loading, you don't need to revalidate if a superfluous part of the param changed. */ - currentParams: AgnosticDataRouteMatch["params"]; + currentParams: DataRouteMatch["params"]; /** In the case of navigation, this the URL the user is requesting. Some revalidations are not navigation, so it will simply be the same as currentUrl. */ nextUrl: URL; /** In the case of navigation, these are the {@link https://reactrouter.com/start/framework/routing#dynamic-segments dynamic route params} from the next location the user is requesting. Some revalidations are not navigation, so it will simply be the same as currentParams. */ - nextParams: AgnosticDataRouteMatch["params"]; + nextParams: DataRouteMatch["params"]; /** The method (probably `"GET"` or `"POST"`) used in the form submission that triggered the revalidation. */ formMethod?: Submission["formMethod"]; /** The form action (``) that triggered the revalidation. */ @@ -423,8 +433,7 @@ export interface ShouldRevalidateFunction { (args: ShouldRevalidateFunctionArgs): boolean; } -export interface DataStrategyMatch - extends AgnosticRouteMatch { +export interface DataStrategyMatch extends RouteMatch { /** * @private */ @@ -527,30 +536,23 @@ export interface DataStrategyFunction { ): Promise>; } -export type AgnosticPatchRoutesOnNavigationFunctionArgs< - O extends AgnosticRouteObject = AgnosticRouteObject, - M extends AgnosticRouteMatch = AgnosticRouteMatch, -> = { +export type PatchRoutesOnNavigationFunctionArgs = { signal: AbortSignal; path: string; - matches: M[]; + matches: RouteMatch[]; fetcherKey: string | undefined; - patch: (routeId: string | null, children: O[]) => void; + patch: (routeId: string | null, children: RouteObject[]) => void; }; -export type AgnosticPatchRoutesOnNavigationFunction< - O extends AgnosticRouteObject = AgnosticRouteObject, - M extends AgnosticRouteMatch = AgnosticRouteMatch, -> = ( - opts: AgnosticPatchRoutesOnNavigationFunctionArgs, +export type PatchRoutesOnNavigationFunction = ( + opts: PatchRoutesOnNavigationFunctionArgs, ) => MaybePromise; /** - * Function provided by the framework-aware layers to set any framework-specific - * properties from framework-agnostic properties + * Function provided to set route-specific properties from route objects */ export interface MapRoutePropertiesFunction { - (route: AgnosticDataRouteObject): { + (route: DataRouteObject): { hasErrorBoundary: boolean; } & Record; } @@ -613,7 +615,7 @@ export function isUnsupportedLazyRouteFunctionKey( * lazy object to load route properties, which can add non-matching * related properties to a route */ -export type LazyRouteObject = { +export type LazyRouteObject = { [K in keyof R as K extends UnsupportedLazyRouteObjectKey ? never : K]?: () => Promise; @@ -623,46 +625,124 @@ export type LazyRouteObject = { * lazy() function to load a route definition, which can add non-matching * related properties to a route */ -export interface LazyRouteFunction { +export interface LazyRouteFunction { (): Promise< Omit & Partial> >; } -export type LazyRouteDefinition = +export type LazyRouteDefinition = | LazyRouteObject | LazyRouteFunction; /** * Base RouteObject with common props shared by all types of routes + * @internal */ -type AgnosticBaseRouteObject = { +export type BaseRouteObject = { + /** + * Whether the path should be case-sensitive. Defaults to `false`. + */ caseSensitive?: boolean; + /** + * The path pattern to match. If unspecified or empty, then this becomes a + * layout route. + */ path?: string; + /** + * The unique identifier for this route (for use with {@link DataRouter}s) + */ id?: string; + /** + * The route middleware. + * See [`middleware`](../../start/data/route-object#middleware). + */ middleware?: MiddlewareFunction[]; + /** + * The route loader. + * See [`loader`](../../start/data/route-object#loader). + */ loader?: LoaderFunction | boolean; + /** + * The route action. + * See [`action`](../../start/data/route-object#action). + */ action?: ActionFunction | boolean; + // TODO(v8): deprecate/remove hasErrorBoundary?: boolean; + /** + * The route shouldRevalidate function. + * See [`shouldRevalidate`](../../start/data/route-object#shouldRevalidate). + */ shouldRevalidate?: ShouldRevalidateFunction; + /** + * The route handle. + */ handle?: any; - lazy?: LazyRouteDefinition; + /** + * A function that returns a promise that resolves to the route object. + * Used for code-splitting routes. + * See [`lazy`](../../start/data/route-object#lazy). + */ + lazy?: LazyRouteDefinition; + /** + * The React Component to render when this route matches. + * Mutually exclusive with `element`. + */ + Component?: React.ComponentType | null; + /** + * The React element to render when this Route matches. + * Mutually exclusive with `Component`. + */ + element?: React.ReactNode | null; + /** + * The React Component to render at this route if an error occurs. + * Mutually exclusive with `errorElement`. + */ + ErrorBoundary?: React.ComponentType | null; + /** + * The React element to render at this route if an error occurs. + * Mutually exclusive with `ErrorBoundary`. + */ + errorElement?: React.ReactNode | null; + /** + * The React Component to render while this router is loading data. + * Mutually exclusive with `hydrateFallbackElement`. + */ + HydrateFallback?: React.ComponentType | null; + /** + * The React element to render while this router is loading data. + * Mutually exclusive with `HydrateFallback`. + */ + hydrateFallbackElement?: React.ReactNode | null; }; /** * Index routes must not have children */ -export type AgnosticIndexRouteObject = AgnosticBaseRouteObject & { +export type IndexRouteObject = BaseRouteObject & { + /** + * Child Route objects - not valid on index routes. + */ children?: undefined; + /** + * Whether this is an index route. + */ index: true; }; /** - * Non-index routes may have children, but cannot have index + * Non-index routes may have children, but cannot have `index` set to `true`. */ -export type AgnosticNonIndexRouteObject = AgnosticBaseRouteObject & { - children?: AgnosticRouteObject[]; +export type NonIndexRouteObject = BaseRouteObject & { + /** + * Child Route objects. + */ + children?: RouteObject[]; + /** + * Whether this is an index route - must be `false` or undefined on non-index routes. + */ index?: false; }; @@ -670,30 +750,23 @@ export type AgnosticNonIndexRouteObject = AgnosticBaseRouteObject & { * A route object represents a logical route, with (optionally) its child * routes organized in a tree-like structure. */ -export type AgnosticRouteObject = - | AgnosticIndexRouteObject - | AgnosticNonIndexRouteObject; +export type RouteObject = IndexRouteObject | NonIndexRouteObject; -export type AgnosticDataIndexRouteObject = AgnosticIndexRouteObject & { +export type DataIndexRouteObject = IndexRouteObject & { id: string; }; -export type AgnosticDataNonIndexRouteObject = AgnosticNonIndexRouteObject & { - children?: AgnosticDataRouteObject[]; +export type DataNonIndexRouteObject = NonIndexRouteObject & { + children?: DataRouteObject[]; id: string; }; /** * A data route object, which is just a RouteObject with a required unique ID */ -export type AgnosticDataRouteObject = - | AgnosticDataIndexRouteObject - | AgnosticDataNonIndexRouteObject; +export type DataRouteObject = DataIndexRouteObject | DataNonIndexRouteObject; -export type RouteManifest = Record< - string, - R | undefined ->; +export type RouteManifest = Record; // prettier-ignore type Regex_az = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" @@ -767,9 +840,9 @@ export type Params = { /** * A RouteMatch contains info about how a route matched a URL. */ -export interface AgnosticRouteMatch< +export interface RouteMatch< ParamKey extends string = string, - RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject, + RouteObjectType extends RouteObject = RouteObject, > { /** * The names and values of dynamic parameters in the URL. @@ -789,24 +862,21 @@ export interface AgnosticRouteMatch< route: RouteObjectType; } -export interface AgnosticDataRouteMatch - extends AgnosticRouteMatch {} +export interface DataRouteMatch extends RouteMatch {} -function isIndexRoute( - route: AgnosticRouteObject, -): route is AgnosticIndexRouteObject { +function isIndexRoute(route: RouteObject): route is IndexRouteObject { return route.index === true; } // Walk the route tree generating unique IDs where necessary, so we are working -// solely with AgnosticDataRouteObject's within the Router +// solely with DataRouteObject's within the Router export function convertRoutesToDataRoutes( - routes: AgnosticRouteObject[], + routes: RouteObject[], mapRouteProperties: MapRoutePropertiesFunction, parentPath: string[] = [], manifest: RouteManifest = {}, allowInPlaceMutations = false, -): AgnosticDataRouteObject[] { +): DataRouteObject[] { return routes.map((route, index) => { let treePath = [...parentPath, String(index)]; let id = typeof route.id === "string" ? route.id : treePath.join("-"); @@ -821,7 +891,7 @@ export function convertRoutesToDataRoutes( ); if (isIndexRoute(route)) { - let indexRoute: AgnosticDataIndexRouteObject = { + let indexRoute: DataIndexRouteObject = { ...route, id, }; @@ -831,7 +901,7 @@ export function convertRoutesToDataRoutes( ); return indexRoute; } else { - let pathOrLayoutRoute: AgnosticDataNonIndexRouteObject = { + let pathOrLayoutRoute: DataNonIndexRouteObject = { ...route, id, children: undefined, @@ -856,7 +926,7 @@ export function convertRoutesToDataRoutes( }); } -function mergeRouteUpdates( +function mergeRouteUpdates( route: T, updates: ReturnType, ): T { @@ -899,24 +969,22 @@ function mergeRouteUpdates( * Defaults to `/`. * @returns An array of matched routes, or `null` if no matches were found. */ -export function matchRoutes< - RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject, ->( +export function matchRoutes( routes: RouteObjectType[], locationArg: Partial | string, basename = "/", -): AgnosticRouteMatch[] | null { +): RouteMatch[] | null { return matchRoutesImpl(routes, locationArg, basename, false); } export function matchRoutesImpl< - RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject, + RouteObjectType extends RouteObject = RouteObject, >( routes: RouteObjectType[], locationArg: Partial | string, basename: string, allowPartial: boolean, -): AgnosticRouteMatch[] | null { +): RouteMatch[] | null { let location = typeof locationArg === "string" ? parsePath(locationArg) : locationArg; @@ -954,7 +1022,7 @@ export interface UIMatch { /** * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the matched route. */ - params: AgnosticRouteMatch["params"]; + params: RouteMatch["params"]; /** * The return value from the matched route's loader or clientLoader. This might * be `undefined` if this route's `loader` (or a deeper route's `loader`) threw @@ -977,7 +1045,7 @@ export interface UIMatch { } export function convertRouteMatchToUiMatch( - match: AgnosticDataRouteMatch, + match: DataRouteMatch, loaderData: RouteData, ): UIMatch { let { route, pathname, params } = match; @@ -991,26 +1059,20 @@ export function convertRouteMatchToUiMatch( }; } -interface RouteMeta< - RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject, -> { +interface RouteMeta { relativePath: string; caseSensitive: boolean; childrenIndex: number; route: RouteObjectType; } -interface RouteBranch< - RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject, -> { +interface RouteBranch { path: string; score: number; routesMeta: RouteMeta[]; } -function flattenRoutes< - RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject, ->( +function flattenRoutes( routes: RouteObjectType[], branches: RouteBranch[] = [], parentsMeta: RouteMeta[] = [], @@ -1222,17 +1284,17 @@ function compareIndexes(a: number[], b: number[]): number { function matchRouteBranch< ParamKey extends string = string, - RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject, + RouteObjectType extends RouteObject = RouteObject, >( branch: RouteBranch, pathname: string, allowPartial = false, -): AgnosticRouteMatch[] | null { +): RouteMatch[] | null { let { routesMeta } = branch; let matchedParams = {}; let matchedPathname = "/"; - let matches: AgnosticRouteMatch[] = []; + let matches: RouteMatch[] = []; for (let i = 0; i < routesMeta.length; ++i) { let meta = routesMeta[i]; let end = i === routesMeta.length - 1; @@ -1687,9 +1749,9 @@ function getInvalidPathError( // // -export function getPathContributingMatches< - T extends AgnosticRouteMatch = AgnosticRouteMatch, ->(matches: T[]) { +export function getPathContributingMatches( + matches: T[], +) { return matches.filter( (match, index) => index === 0 || (match.route.path && match.route.path.length > 0), @@ -1698,9 +1760,9 @@ export function getPathContributingMatches< // Return the array of pathnames for the current route matches - used to // generate the routePathnames input for resolveTo() -export function getResolveToMatches< - T extends AgnosticRouteMatch = AgnosticRouteMatch, ->(matches: T[]) { +export function getResolveToMatches( + matches: T[], +) { let pathMatches = getPathContributingMatches(matches); // Use the full pathname for the leaf match so we include splat values for "." links @@ -2063,7 +2125,7 @@ by the star-slash in the `getRoutePattern` regex and messes up the parsed commen for `isRouteErrorResponse` above. This comment seems to reset the parser. */ -export function getRoutePattern(matches: AgnosticRouteMatch[]) { +export function getRoutePattern(matches: RouteMatch[]) { return ( matches .map((m) => m.route.path) diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx index 865ef8832f..b32c9f3f15 100644 --- a/packages/react-router/lib/rsc/browser.tsx +++ b/packages/react-router/lib/rsc/browser.tsx @@ -2,11 +2,7 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; import { RouterProvider } from "../components"; -import { - RSCRouterContext, - type DataRouteMatch, - type DataRouteObject, -} from "../context"; +import { RSCRouterContext } from "../context"; import { FrameworkContext, setIsHydrated } from "../dom/ssr/components"; import type { FrameworkContextObject } from "../dom/ssr/entry"; import { createBrowserHistory, invariant } from "../router/history"; @@ -18,7 +14,8 @@ import type { RSCRenderPayload, } from "./server.rsc"; import type { - AgnosticDataRouteObject, + DataRouteMatch, + DataRouteObject, DataStrategyFunction, DataStrategyFunctionArgs, RouterContextProvider, @@ -824,6 +821,7 @@ export function RSCHydratedRouter({ v8_middleware: false, unstable_subResourceIntegrity: false, unstable_trailingSlashAwareDataRequests: true, // always on for RSC + unstable_passThroughRequests: true, // always on for RSC }, isSpaMode: false, ssr: true, @@ -1084,9 +1082,7 @@ function isExternalLocation(location: string) { return newLocation.origin !== window.location.origin; } -function cloneRoutes( - routes: AgnosticDataRouteObject[] | undefined, -): AgnosticDataRouteObject[] { +function cloneRoutes(routes: DataRouteObject[] | undefined): DataRouteObject[] { if (!routes) return undefined as any; return routes.map((route) => ({ ...route, @@ -1094,10 +1090,7 @@ function cloneRoutes( })) as any; } -function diffRoutes( - a: AgnosticDataRouteObject[], - b: AgnosticDataRouteObject[], -): boolean { +function diffRoutes(a: DataRouteObject[], b: DataRouteObject[]): boolean { if (a.length !== b.length) return true; return a.some((route, index) => { if ((route as any).element !== (b[index] as any).element) return true; diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index e19c12326d..7653c2a192 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -20,10 +20,11 @@ import { } from "../router/router"; import { type ActionFunction, - type AgnosticDataRouteMatch, type LoaderFunction, type Params, type ShouldRevalidateFunction, + type RouteMatch, + type RouteObject, type RouterContextProvider, type TrackedPromise, isAbsoluteUrl, @@ -39,7 +40,6 @@ import { import { getDocumentHeadersImpl } from "../server-runtime/headers"; import { SINGLE_FETCH_REDIRECT_STATUS } from "../dom/ssr/single-fetch"; import { throwIfPotentialCSRFAttack } from "../actions"; -import type { RouteMatch, RouteObject } from "../context"; import invariant from "../server-runtime/invariant"; import { @@ -67,6 +67,7 @@ import { createRedirectErrorDigest, createRouteErrorResponseDigest, } from "../errors"; +import { getNormalizedPath } from "../server-runtime/urls"; const Outlet: typeof OutletType = UNTYPED_Outlet; const WithComponentProps: typeof WithComponentPropsType = @@ -210,6 +211,8 @@ export type RSCRouteConfigEntry = RSCRouteConfigEntryBase & { export type RSCRouteConfig = Array; +type RSCRouteDataMatch = RouteMatch; + export type RSCRouteManifest = { clientAction?: ClientActionFunction; clientLoader?: ClientLoaderFunction; @@ -714,6 +717,7 @@ async function generateResourceResponse( return generateErrorResponse(error); } }, + unstable_normalizePath: (r) => getNormalizedPath(r, basename, null), }); return response; } catch (error) { @@ -805,6 +809,7 @@ async function generateRenderResponse( ...(routeIdsToLoad ? { filterMatchesToLoad: (m) => routeIdsToLoad!.includes(m.route.id) } : {}), + unstable_normalizePath: (r) => getNormalizedPath(r, basename, null), async generateMiddlewareResponse(query) { // If this is an RSC server action, process that and then call query as a // revalidation. If this is a RR Form/Fetcher submission, @@ -1130,7 +1135,7 @@ async function getRenderPayload( }); let matchesPromise = Promise.all( - staticContext.matches.map((match, i) => { + (staticContext.matches as RSCRouteDataMatch[]).map((match, i) => { let isBelowErrorBoundary = i > deepestRenderedRouteIdx; let parentId = parentIds[match.route.id]; return getRSCRouteMatch({ @@ -1167,24 +1172,23 @@ async function getRSCRouteMatch({ parentId, }: { staticContext: StaticHandlerContext; - match: AgnosticDataRouteMatch; + match: RSCRouteDataMatch; isBelowErrorBoundary: boolean; routeIdsToLoad: string[] | null; parentId: string | undefined; }) { - // @ts-expect-error - FIXME: Fix the types here - await explodeLazyRoute(match.route); - const Layout = (match.route as any).Layout || React.Fragment; - const Component = (match.route as any).Component; - const ErrorBoundary = (match.route as any).ErrorBoundary; - const HydrateFallback = (match.route as any).HydrateFallback; - const loaderData = staticContext.loaderData[match.route.id]; - const actionData = staticContext.actionData?.[match.route.id]; + const route = match.route; + await explodeLazyRoute(route); + const Layout = route.Layout || React.Fragment; + const Component = route.Component; + const ErrorBoundary = route.ErrorBoundary; + const HydrateFallback = route.HydrateFallback; + const loaderData = staticContext.loaderData[route.id]; + const actionData = staticContext.actionData?.[route.id]; const params = match.params; // TODO: DRY this up once it's fully fleshed out let element: React.ReactElement | undefined = undefined; - let shouldLoadRoute = - !routeIdsToLoad || routeIdsToLoad.includes(match.route.id); + let shouldLoadRoute = !routeIdsToLoad || routeIdsToLoad.includes(route.id); // Only bother rendering Server Components for routes that we're surfacing, // so nothing at/below an error boundary and prune routes if included in // `routeIdsToLoad`. This is specifically important when a middleware @@ -1213,7 +1217,7 @@ async function getRSCRouteMatch({ let error: unknown = undefined; if (ErrorBoundary && staticContext.errors) { - error = staticContext.errors[match.route.id]; + error = staticContext.errors[route.id]; } const errorElement = ErrorBoundary ? React.createElement( @@ -1247,33 +1251,37 @@ async function getRSCRouteMatch({ ) : undefined; + const hmrRoute = route as RSCRouteConfigEntry & { + __ensureClientRouteModuleForHMR?: unknown; + }; + return { - clientAction: (match.route as any).clientAction, - clientLoader: (match.route as any).clientLoader, + clientAction: route.clientAction, + clientLoader: route.clientLoader, element, errorElement, - handle: (match.route as any).handle, - hasAction: !!match.route.action, + handle: route.handle, + hasAction: !!route.action, hasComponent: !!Component, hasErrorBoundary: !!ErrorBoundary, - hasLoader: !!match.route.loader, + hasLoader: !!route.loader, hydrateFallbackElement, - id: match.route.id, - index: match.route.index, - links: (match.route as any).links, - meta: (match.route as any).meta, + id: route.id, + index: "index" in route ? route.index : undefined, + links: route.links, + meta: route.meta, params, parentId, - path: match.route.path, + path: route.path, pathname: match.pathname, pathnameBase: match.pathnameBase, - shouldRevalidate: (match.route as any).shouldRevalidate, + shouldRevalidate: route.shouldRevalidate, // Add an unused client-only export (if present) so HMR can support // switching between server-first and client-only routes during development - ...((match.route as any).__ensureClientRouteModuleForHMR + ...(hmrRoute.__ensureClientRouteModuleForHMR ? { - __ensureClientRouteModuleForHMR: (match.route as any) - .__ensureClientRouteModuleForHMR, + __ensureClientRouteModuleForHMR: + hmrRoute.__ensureClientRouteModuleForHMR, } : {}), }; diff --git a/packages/react-router/lib/rsc/server.ssr.tsx b/packages/react-router/lib/rsc/server.ssr.tsx index 845d04544f..1e802e58cd 100644 --- a/packages/react-router/lib/rsc/server.ssr.tsx +++ b/packages/react-router/lib/rsc/server.ssr.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { RSCRouterContext, type DataRouteObject } from "../context"; +import { RSCRouterContext } from "../context"; import { FrameworkContext } from "../dom/ssr/components"; import type { FrameworkContextObject } from "../dom/ssr/entry"; import { SINGLE_FETCH_REDIRECT_STATUS } from "../dom/ssr/single-fetch"; @@ -9,7 +9,7 @@ import { RSCRouterGlobalErrorBoundary } from "./errorBoundaries"; import { shouldHydrateRouteLoader } from "../dom/ssr/routes"; import type { RSCPayload } from "./server.rsc"; import { createRSCRouteModules } from "./route-modules"; -import { isRouteErrorResponse } from "../router/utils"; +import { isRouteErrorResponse, type DataRouteObject } from "../router/utils"; import { decodeRedirectErrorDigest, decodeRouteErrorResponseDigest, @@ -581,6 +581,7 @@ export function RSCStaticRouter({ getPayload }: RSCStaticRouterProps) { v8_middleware: false, unstable_subResourceIntegrity: false, unstable_trailingSlashAwareDataRequests: true, // always on for RSC + unstable_passThroughRequests: true, // always on for RSC }, isSpaMode: false, ssr: true, diff --git a/packages/react-router/lib/server-runtime/data.ts b/packages/react-router/lib/server-runtime/data.ts index db680dfd78..3d75870aaa 100644 --- a/packages/react-router/lib/server-runtime/data.ts +++ b/packages/react-router/lib/server-runtime/data.ts @@ -4,6 +4,7 @@ import type { LoaderFunctionArgs, ActionFunctionArgs, } from "../router/utils"; +import type { FutureConfig } from "../router/router"; import { isDataWithResponseInit, isRedirectStatusCode } from "../router/router"; /** @@ -21,9 +22,13 @@ export interface AppLoadContext { export async function callRouteHandler( handler: LoaderFunction | ActionFunction, args: LoaderFunctionArgs | ActionFunctionArgs, + future: FutureConfig, ) { let result = await handler({ - request: stripRoutesParam(stripIndexParam(args.request)), + request: future.unstable_passThroughRequests + ? args.request + : stripRoutesParam(stripIndexParam(args.request)), + unstable_url: args.unstable_url, params: args.params, context: args.context, unstable_pattern: args.unstable_pattern, @@ -42,11 +47,6 @@ export async function callRouteHandler( return result; } -// TODO: Document these search params better -// and stop stripping these in V2. These break -// support for running in a SW and also expose -// valuable info to data funcs that is being asked -// for such as "is this a data request?". function stripIndexParam(request: Request) { let url = new URL(request.url); let indexValues = url.searchParams.getAll("index"); diff --git a/packages/react-router/lib/server-runtime/headers.ts b/packages/react-router/lib/server-runtime/headers.ts index bef73e22c7..fa1988a172 100644 --- a/packages/react-router/lib/server-runtime/headers.ts +++ b/packages/react-router/lib/server-runtime/headers.ts @@ -1,6 +1,6 @@ import { splitCookiesString } from "set-cookie-parser"; -import type { DataRouteMatch } from "../context"; +import type { DataRouteMatch } from "../router/utils"; import type { StaticHandlerContext } from "../router/router"; import type { ServerRouteModule } from "../dom/ssr/routeModules"; import type { ServerBuild } from "./build"; diff --git a/packages/react-router/lib/server-runtime/routeMatching.ts b/packages/react-router/lib/server-runtime/routeMatching.ts index e6d38dab7d..c0f105c464 100644 --- a/packages/react-router/lib/server-runtime/routeMatching.ts +++ b/packages/react-router/lib/server-runtime/routeMatching.ts @@ -1,4 +1,4 @@ -import type { Params, AgnosticRouteObject } from "../router/utils"; +import type { Params, RouteObject } from "../router/utils"; import { matchRoutes } from "../router/utils"; import type { ServerRoute } from "./routes"; @@ -14,7 +14,7 @@ export function matchServerRoutes( basename?: string, ): RouteMatch[] | null { let matches = matchRoutes( - routes as unknown as AgnosticRouteObject[], + routes as unknown as RouteObject[], pathname, basename, ); diff --git a/packages/react-router/lib/server-runtime/routes.ts b/packages/react-router/lib/server-runtime/routes.ts index a445101e44..9eebd42011 100644 --- a/packages/react-router/lib/server-runtime/routes.ts +++ b/packages/react-router/lib/server-runtime/routes.ts @@ -1,5 +1,5 @@ import type { - AgnosticDataRouteObject, + DataRouteObject, LoaderFunctionArgs as RRLoaderFunctionArgs, ActionFunctionArgs as RRActionFunctionArgs, RouteManifest, @@ -70,7 +70,7 @@ export function createStaticHandlerDataRoutes( string, Omit[] > = groupRoutesByParentId(manifest), -): AgnosticDataRouteObject[] { +): DataRouteObject[] { return (routesByParentId[parentId] || []).map((route) => { let commonRoute = { // Always include root due to default boundaries @@ -131,13 +131,17 @@ export function createStaticHandlerDataRoutes( return result.data; } } - let val = await callRouteHandler(route.module.loader!, args); + let val = await callRouteHandler( + route.module.loader!, + args, + future, + ); return val; } : undefined, action: route.module.action ? (args: RRActionFunctionArgs) => - callRouteHandler(route.module.action!, args) + callRouteHandler(route.module.action!, args, future) : undefined, handle: route.module.handle, }; diff --git a/packages/react-router/lib/server-runtime/server.ts b/packages/react-router/lib/server-runtime/server.ts index ea1df0ddad..12f249210e 100644 --- a/packages/react-router/lib/server-runtime/server.ts +++ b/packages/react-router/lib/server-runtime/server.ts @@ -39,6 +39,7 @@ import { getManifestPath } from "../dom/ssr/fog-of-war"; import type { unstable_InstrumentRequestHandlerFunction } from "../router/instrumentation"; import { instrumentHandler } from "../router/instrumentation"; import { throwIfPotentialCSRFAttack } from "../actions"; +import { getNormalizedPath } from "./urls"; export type RequestHandler = ( request: Request, @@ -106,31 +107,12 @@ function derive(build: ServerBuild, mode?: string) { loadContext = initialContext || {}; } - let url = new URL(request.url); - - let normalizedBasename = build.basename || "/"; - let normalizedPath = url.pathname; - if (build.future.unstable_trailingSlashAwareDataRequests) { - if (normalizedPath.endsWith("/_.data")) { - // Handle trailing slash URLs: /about/_.data -> /about/ - normalizedPath = normalizedPath.replace(/_.data$/, ""); - } else { - normalizedPath = normalizedPath.replace(/\.data$/, ""); - } - } else { - if (stripBasename(normalizedPath, normalizedBasename) === "/_root.data") { - normalizedPath = normalizedBasename; - } else if (normalizedPath.endsWith(".data")) { - normalizedPath = normalizedPath.replace(/\.data$/, ""); - } - - if ( - stripBasename(normalizedPath, normalizedBasename) !== "/" && - normalizedPath.endsWith("/") - ) { - normalizedPath = normalizedPath.slice(0, -1); - } - } + let requestUrl = new URL(request.url); + let normalizedPathname = getNormalizedPath( + request, + build.basename, + build.future, + ).pathname; let isSpaMode = getBuildTimeHeader(request, "X-React-Router-SPA-Mode") === "yes"; @@ -139,17 +121,17 @@ function derive(build: ServerBuild, mode?: string) { // pre-rendered site would if (!build.ssr) { // Decode the URL path before checking against the prerender config - let decodedPath = decodeURI(normalizedPath); + let decodedPath = decodeURI(normalizedPathname); - if (normalizedBasename !== "/") { - let strippedPath = stripBasename(decodedPath, normalizedBasename); + if (build.basename && build.basename !== "/") { + let strippedPath = stripBasename(decodedPath, build.basename); if (strippedPath == null) { errorHandler( new ErrorResponseImpl( 404, "Not Found", `Refusing to prerender the \`${decodedPath}\` path because it does ` + - `not start with the basename \`${normalizedBasename}\``, + `not start with the basename \`${build.basename}\``, ), { context: loadContext, @@ -174,7 +156,7 @@ function derive(build: ServerBuild, mode?: string) { !build.prerender.includes(decodedPath) && !build.prerender.includes(decodedPath + "/") ) { - if (url.pathname.endsWith(".data")) { + if (requestUrl.pathname.endsWith(".data")) { // 404 on non-pre-rendered `.data` requests errorHandler( new ErrorResponseImpl( @@ -202,11 +184,11 @@ function derive(build: ServerBuild, mode?: string) { // Manifest request for fog of war let manifestUrl = getManifestPath( build.routeDiscovery.manifestPath, - normalizedBasename, + build.basename, ); - if (url.pathname === manifestUrl) { + if (requestUrl.pathname === manifestUrl) { try { - let res = await handleManifestRequest(build, routes, url); + let res = await handleManifestRequest(build, routes, requestUrl); return res; } catch (e) { handleError(e); @@ -214,19 +196,16 @@ function derive(build: ServerBuild, mode?: string) { } } - let matches = matchServerRoutes(routes, normalizedPath, build.basename); + let matches = matchServerRoutes(routes, normalizedPathname, build.basename); if (matches && matches.length > 0) { Object.assign(params, matches[0].params); } let response: Response; - if (url.pathname.endsWith(".data")) { - let handlerUrl = new URL(request.url); - handlerUrl.pathname = normalizedPath; - + if (requestUrl.pathname.endsWith(".data")) { let singleFetchMatches = matchServerRoutes( routes, - handlerUrl.pathname, + normalizedPathname, build.basename, ); @@ -235,7 +214,7 @@ function derive(build: ServerBuild, mode?: string) { build, staticHandler, request, - handlerUrl, + normalizedPathname, loadContext, handleError, ); @@ -281,7 +260,7 @@ function derive(build: ServerBuild, mode?: string) { handleError, ); } else { - let { pathname } = url; + let { pathname } = requestUrl; let criticalCss: CriticalCss | undefined = undefined; if (build.unstable_getCriticalCss) { @@ -443,10 +422,13 @@ async function handleSingleFetchRequest( build: ServerBuild, staticHandler: StaticHandler, request: Request, - handlerUrl: URL, + normalizedPath: string, loadContext: AppLoadContext | RouterContextProvider, handleError: (err: unknown) => void, ): Promise { + let handlerUrl = new URL(request.url); + handlerUrl.pathname = normalizedPath; + let response = request.method !== "GET" ? await singleFetchAction( @@ -511,6 +493,8 @@ async function handleDocumentRequest( } } : undefined, + unstable_normalizePath: (r) => + getNormalizedPath(r, build.basename, build.future), }); if (!isResponse(result)) { @@ -688,6 +672,8 @@ async function handleResourceRequest( } } : undefined, + unstable_normalizePath: (r) => + getNormalizedPath(r, build.basename, build.future), }); return handleQueryRouteResult(result); diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index 71e469b5fd..aecd30970f 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -24,6 +24,7 @@ import { ServerMode } from "./mode"; import { getDocumentHeaders } from "./headers"; import type { ServerBuild } from "./build"; import { throwIfPotentialCSRFAttack } from "../actions"; +import { getNormalizedPath } from "./urls"; // Add 304 for server side - that is not included in the client side logic // because the browser should fill those responses with the cached data @@ -54,13 +55,15 @@ export async function singleFetchAction( return handleQueryError(new Error("Bad Request"), 400); } - let handlerRequest = new Request(handlerUrl, { - method: request.method, - body: request.body, - headers: request.headers, - signal: request.signal, - ...(request.body ? { duplex: "half" } : undefined), - }); + let handlerRequest = build.future.unstable_passThroughRequests + ? request + : new Request(handlerUrl, { + method: request.method, + body: request.body, + headers: request.headers, + signal: request.signal, + ...(request.body ? { duplex: "half" } : undefined), + }); let result = await staticHandler.query(handlerRequest, { requestContext: loadContext, @@ -76,6 +79,8 @@ export async function singleFetchAction( } } : undefined, + unstable_normalizePath: (r) => + getNormalizedPath(r, build.basename, build.future), }); return handleQueryResult(result); @@ -147,10 +152,12 @@ export async function singleFetchLoaders( let loadRouteIds = routesParam ? new Set(routesParam.split(",")) : null; try { - let handlerRequest = new Request(handlerUrl, { - headers: request.headers, - signal: request.signal, - }); + let handlerRequest = build.future.unstable_passThroughRequests + ? request + : new Request(handlerUrl, { + headers: request.headers, + signal: request.signal, + }); let result = await staticHandler.query(handlerRequest, { requestContext: loadContext, @@ -166,6 +173,8 @@ export async function singleFetchLoaders( } } : undefined, + unstable_normalizePath: (r) => + getNormalizedPath(r, build.basename, build.future), }); return handleQueryResult(result); diff --git a/packages/react-router/lib/server-runtime/urls.ts b/packages/react-router/lib/server-runtime/urls.ts new file mode 100644 index 0000000000..f71cc97719 --- /dev/null +++ b/packages/react-router/lib/server-runtime/urls.ts @@ -0,0 +1,52 @@ +import type { FutureConfig } from "../dom/ssr/entry"; +import type { Path } from "../router/history"; +import { stripBasename } from "../router/utils"; + +export function getNormalizedPath( + request: Request, + basename: string | undefined, + future: FutureConfig | null, +): Path { + basename = basename || "/"; + + let url = new URL(request.url); + let pathname = url.pathname; + + // Strip .data suffix + if (future?.unstable_trailingSlashAwareDataRequests) { + if (pathname.endsWith("/_.data")) { + // Handle trailing slash URLs: /about/_.data -> /about/ + pathname = pathname.replace(/_\.data$/, ""); + } else { + pathname = pathname.replace(/\.data$/, ""); + } + } else { + if (stripBasename(pathname, basename) === "/_root.data") { + pathname = basename; + } else if (pathname.endsWith(".data")) { + pathname = pathname.replace(/\.data$/, ""); + } + + if (stripBasename(pathname, basename) !== "/" && pathname.endsWith("/")) { + pathname = pathname.slice(0, -1); + } + } + + // Strip _routes param + let searchParams = new URLSearchParams(url.search); + searchParams.delete("_routes"); + let search = searchParams.toString(); + if (search) { + search = `?${search}`; + } + + // Don't touch index params here - they're needed for router matching and are + // stripped when creating the loader/action args + + return { + pathname, + search, + // No hashes on the server + hash: "", + }; +} diff --git a/packages/react-router/lib/types/route-data.ts b/packages/react-router/lib/types/route-data.ts index 52eefee088..b4bacc336d 100644 --- a/packages/react-router/lib/types/route-data.ts +++ b/packages/react-router/lib/types/route-data.ts @@ -2,6 +2,7 @@ import type { ClientLoaderFunctionArgs, ClientActionFunctionArgs, } from "../dom/ssr/routeModules"; +import type { Path } from "../router/history"; import type { DataWithResponseInit, RouterContextProvider, @@ -77,6 +78,15 @@ export type ClientDataFunctionArgs = { * @note Because client data functions are called before a network request is made, the Request object does not include the headers which the browser automatically adds. React Router infers the "content-type" header from the enc-type of the form that performed the submission. **/ request: Request; + /** + * A URL instance representing the application location being navigated to or fetched. + * Without `future.unstable_passThroughRequests` enabled, this matches `request.url`. + * With `future.unstable_passThroughRequests` enabled, this is a normalized + * URL with React-Router-specific implementation details removed (`.data` + * pathnames, `index`/`_routes` search params). + * The URL includes the origin from the request for convenience. + */ + unstable_url: URL; /** * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. * @example @@ -111,6 +121,15 @@ export type ClientDataFunctionArgs = { export type ServerDataFunctionArgs = { /** A {@link https://developer.mozilla.org/en-US/docs/Web/API/Request Fetch Request instance} which you can use to read the url, method, headers (such as cookies), and request body from the request. */ request: Request; + /** + * A URL instance representing the application location being navigated to or fetched. + * Without `future.unstable_passThroughRequests` enabled, this matches `request.url`. + * With `future.unstable_passThroughRequests` enabled, this is a normalized + * URL with React-Router-specific implementation details removed (`.data` + * pathnames, `index`/`_routes` search params). + * The URL includes the origin from the request for convenience. + */ + unstable_url: URL; /** * {@link https://reactrouter.com/start/framework/routing#dynamic-segments Dynamic route params} for the current route. * @example diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 098fc66e24..bc26854688 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,9 +115,6 @@ importers: babel-plugin-dev-expression: specifier: ^0.2.3 version: 0.2.3(@babel/core@7.27.7) - chalk: - specifier: ^4.1.2 - version: 4.1.2 dox: specifier: ^1.0.0 version: 1.0.0 @@ -147,7 +144,7 @@ importers: version: 7.34.1(eslint@8.57.0) eslint-plugin-react-hooks: specifier: next - version: 7.1.0-canary-24d8716e-20260123(eslint@8.57.0) + version: 7.1.0-canary-e0cc7202-20260227(eslint@8.57.0) fast-glob: specifier: 3.2.11 version: 3.2.11 @@ -160,6 +157,9 @@ importers: jsonfile: specifier: ^6.1.0 version: 6.1.0 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 prettier: specifier: ^3.6.2 version: 3.6.2 @@ -828,9 +828,6 @@ importers: arg: specifier: ^5.0.1 version: 5.0.2 - chalk: - specifier: ^4.1.2 - version: 4.1.2 execa: specifier: 5.1.1 version: 5.1.1 @@ -840,6 +837,9 @@ importers: log-update: specifier: ^5.0.1 version: 5.0.1 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 proxy-agent: specifier: ^6.3.0 version: 6.4.0 @@ -4799,6 +4799,7 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.0, please upgrade better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} @@ -5615,11 +5616,11 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 - eslint-plugin-react-hooks@7.1.0-canary-24d8716e-20260123: - resolution: {integrity: sha512-Ku4UmX2ZuQtAUHu+9IqLcyKc0uqG8nAMXgpD0ycZUZaZ4nOneQP/qD0riPM6M/PUkDNF5xNLNc5Sde1mDkR9ig==} + eslint-plugin-react-hooks@7.1.0-canary-e0cc7202-20260227: + resolution: {integrity: sha512-Kg4EiP6olCKf9zrf3TGaMfyQfUOADsQDFa6q3Cfv+Fr47dQhOtbq6FkkyNZJEb+yz8kGrJJmIPKb+0Q2f+FrZw==} engines: {node: '>=18'} peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 eslint-plugin-react@7.34.1: resolution: {integrity: sha512-N97CxlouPT1AHt8Jn0mhhN2RrADlUAsk1/atcT2KyA/l9Q/E6ll7OIGwNumFmWfZ9skV3XXccYS19h80rHtgkw==} @@ -5986,16 +5987,17 @@ packages: glob@10.3.10: resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.0.3: resolution: {integrity: sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -7787,6 +7789,7 @@ packages: react-server-dom-webpack@19.2.3: resolution: {integrity: sha512-ifo7aqqdNJyV6U2zuvvWX4rRQ51pbleuUFNG7ZYhIuSuWZzQPbfmYv11GNsyJm/3uGNbt8buJ9wmoISn/uOAfw==} engines: {node: '>=0.10.0'} + deprecated: High Security Vulnerability in React Server Components peerDependencies: react: ^19.2.3 react-dom: ^19.2.3 @@ -13635,7 +13638,7 @@ snapshots: dependencies: eslint: 8.57.0 - eslint-plugin-react-hooks@7.1.0-canary-24d8716e-20260123(eslint@8.57.0): + eslint-plugin-react-hooks@7.1.0-canary-e0cc7202-20260227(eslint@8.57.0): dependencies: '@babel/core': 7.27.7 '@babel/parser': 7.27.7 diff --git a/scripts/playground.js b/scripts/playground.js index b987ecaef0..20ea5648b0 100644 --- a/scripts/playground.js +++ b/scripts/playground.js @@ -4,7 +4,7 @@ let { existsSync, readdirSync } = require("node:fs"); let { cp } = require("node:fs/promises"); let path = require("node:path"); let prompts = require("prompts"); -let chalk = require("chalk"); +let pc = require("picocolors"); copyPlayground(); @@ -40,8 +40,8 @@ async function copyPlayground() { console.log( [ "", - chalk.green`Created local copy of "${templateName}"`, - chalk.green`To start playground, run:`, + pc.green(`Created local copy of "${templateName}"`), + pc.green(`To start playground, run:`), "", `cd ${relativeDestDir}`, "pnpm dev", diff --git a/scripts/version.js b/scripts/version.js index 71d96c6c16..6fc21a9a94 100644 --- a/scripts/version.js +++ b/scripts/version.js @@ -1,6 +1,6 @@ const fs = require("node:fs"); const { execSync } = require("child_process"); -const chalk = require("chalk"); +const pc = require("picocolors"); const semver = require("semver"); const { @@ -40,20 +40,18 @@ async function run() { packageName = pkg.name; pkg.version = version; }); - console.log( - chalk.green(` Updated ${packageName} to version ${version}`), - ); + console.log(pc.green(` Updated ${packageName} to version ${version}`)); } // 3. Commit and tag if (!skipGit) { execSync(`git commit --all --message="Version ${version}"`); execSync(`git tag -a -m "Version ${version}" v${version}`); - console.log(chalk.green(` Committed and tagged version ${version}`)); + console.log(pc.green(` Committed and tagged version ${version}`)); } } catch (error) { console.log(); - console.error(chalk.red(` ${error.message}`)); + console.error(pc.red(` ${error.message}`)); console.log(); return 1; }