Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: remix-run/react-router
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: react-router@6.6.2
Choose a base ref
...
head repository: remix-run/react-router
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: 74979cb5f84092d83adcf5cda5bf281b3450683c
Choose a head ref

Commits on Jan 7, 2023

  1. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    ae925ba View commit details

Commits on Jan 8, 2023

  1. docs(components/await): fix typo (#9835)

    * docs(components/await): fix typo
    
    * chore: sign CLA
    damianstasik authored Jan 8, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    50a6f10 View commit details

Commits on Jan 9, 2023

  1. test(react-router-dom): streamline jsdom submitter bug workaround (#9824

    )
    
    Work around the submitter bug in just one place, and link to my jsdom PR
    which will fix it, so that the workaround can be removed sooner rather
    than later 🤞
    
    This workaround refactor also establishes a pattern for other jsdom bug
    polyfills which will be landing in forthcoming RR PRs (the bugs aren't
    relevant in the current test suite, but will be in the PRs 😅)
    jenseng authored Jan 9, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    c50b5ac View commit details
  2. Copy the full SHA
    cc9a5c4 View commit details
  3. Copy the full SHA
    78a72c3 View commit details

Commits on Jan 11, 2023

  1. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    4390f8b View commit details
  2. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    4108c98 View commit details
  3. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    482b6a2 View commit details
  4. Fix issue templates

    brophdawg11 committed Jan 11, 2023
    Copy the full SHA
    7b7498e View commit details
  5. Copy the full SHA
    0eeab90 View commit details
  6. Copy the full SHA
    953948a View commit details
  7. defer - expose internals and solidify subscriptions (#9760)

    - export DeferredData class
    - add settledKey to DeferredData subscription callback
    - expose pendingKeys and deferredKeys as public API on DeferredData
    - allow multiple DeferredData subscriptions
    - allow unsubscribe from DeferredData
    - mark API unsafe
    
    Co-authored-by: Matt Brophy <matt@brophy.org>
    jacob-ebey and brophdawg11 authored Jan 11, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    ce2bb3f View commit details
  8. Fix URL creation with memory history (#9814)

    * Fix URL creation with memory history
    
    * Create history-aware URLs
    
    * add changeset
    
    * Fix hash test
    brophdawg11 authored Jan 11, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    b154367 View commit details
  9. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    de3c96a View commit details
  10. Copy the full SHA
    6585c83 View commit details
  11. Enter prerelease mode

    brophdawg11 committed Jan 11, 2023
    Copy the full SHA
    7951edc View commit details
  12. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    c0f1b98 View commit details
  13. Copy the full SHA
    b82ea4b View commit details
  14. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    6836155 View commit details
  15. ci(release): sync with remix (#9813)

    * ci(release): sync with remix
    
    * ci: sync with remix
    
    * Delete postrelease.yml
    
    * ci: sync latest changes around getting previous release
    
    (cherry picked from commit a80a62d)
    mcansh committed Jan 11, 2023

    Verified

    This commit was signed with the committer’s verified signature. The key has expired.
    mcansh Logan McAnsh
    Copy the full SHA
    9640d01 View commit details
  16. Fix scroll restoration when redirecting in an action (#9886)

    * Fix scroll restoration when redirecting in an action (#9815)
    * Add preventScrollReset prop to Form
    
    Co-authored-by: Johann R <johann.rakotoharisoa@gmail.com>
    brophdawg11 and jrakotoharisoa authored Jan 11, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    0582210 View commit details
  17. chore: Update version for release (pre) (#9887)

    Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
    mcansh and github-actions[bot] authored Jan 11, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    7049c41 View commit details

Commits on Jan 13, 2023

  1. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    44c03c6 View commit details
  2. chore: Update version for release (pre) (#9899)

    Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
    mcansh and github-actions[bot] authored Jan 13, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    e3ea0fe View commit details
  3. @remix-run/router: Add support for navigation blocking (#9709)

    * feat(router): add support for history blocking APIs
    * feat(react-router): add `unstable_useBlocker` hook
    * feat(react-router-dom): add `capture` option to `useBeforeUnload`
    
    Co-authored-by: Matt Brophy <matt@brophy.org>
    chaance and brophdawg11 authored Jan 13, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    bb7590a View commit details
  4. chore: format

    remix-run-bot committed Jan 13, 2023
    Copy the full SHA
    2cd8266 View commit details
  5. Copy the full SHA
    0079767 View commit details
  6. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    fdfa53c View commit details
  7. Copy the full SHA
    a929a00 View commit details

Commits on Jan 17, 2023

  1. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    3f19248 View commit details
  2. Fix 404 bug with same-origin absolute redirects (#9913)

    * Fix 404 bug with same-origin absolute redirects
    
    * Remove unused import
    brophdawg11 authored Jan 17, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    bf46fb6 View commit details
  3. chore: Update version for release (pre) (#9918)

    Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
    mcansh and github-actions[bot] authored Jan 17, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    b49a49e View commit details
  4. Update changelogs

    brophdawg11 committed Jan 17, 2023
    Copy the full SHA
    4326424 View commit details

Commits on Jan 18, 2023

  1. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    0529002 View commit details
  2. chore: Update version for release (pre) (#9934)

    Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
    mcansh and github-actions[bot] authored Jan 18, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    a30dbaf View commit details
  3. Exit prerelease mode

    brophdawg11 committed Jan 18, 2023
    Copy the full SHA
    1d1f455 View commit details
  4. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    74979cb View commit details
Showing with 6,524 additions and 990 deletions.
  1. +9 −7 .github/ISSUE_TEMPLATE/bug_report.yml
  2. +9 −3 .github/ISSUE_TEMPLATE/config.yml
  3. +21 −0 .github/ISSUE_TEMPLATE/documentation_isse.yml
  4. +0 −32 .github/ISSUE_TEMPLATE/feature_request.yml
  5. +0 −17 .github/workflows/postrelease.yml
  6. +50 −21 .github/workflows/release.yml
  7. +2 −1 .gitignore
  8. +1 −1 README.md
  9. +4 −0 contributors.yml
  10. +0 −1 docs/components/await.md
  11. +1 −1 docs/route/error-element.md
  12. +355 −33 examples/data-router/src/app.tsx
  13. +0 −399 examples/data-router/src/routes.tsx
  14. +5 −0 examples/navigation-blocking/.gitignore
  15. +4 −0 examples/navigation-blocking/.stackblitzrc
  16. +15 −0 examples/navigation-blocking/README.md
  17. +12 −0 examples/navigation-blocking/index.html
  18. +2,456 −0 examples/navigation-blocking/package-lock.json
  19. +23 −0 examples/navigation-blocking/package.json
  20. +141 −0 examples/navigation-blocking/src/app.tsx
  21. +9 −0 examples/navigation-blocking/src/main.tsx
  22. +1 −0 examples/navigation-blocking/src/vite-env.d.ts
  23. +21 −0 examples/navigation-blocking/tsconfig.json
  24. +36 −0 examples/navigation-blocking/vite.config.ts
  25. +6 −4 package.json
  26. +8 −0 packages/react-router-dom-v5-compat/CHANGELOG.md
  27. +2 −2 packages/react-router-dom-v5-compat/package.json
  28. +16 −0 packages/react-router-dom/CHANGELOG.md
  29. +8 −71 packages/react-router-dom/__tests__/data-browser-router-test.tsx
  30. +26 −0 packages/react-router-dom/__tests__/polyfills/SubmitEvent.submitter.ts
  31. +2 −0 packages/react-router-dom/__tests__/setup.ts
  32. +1,004 −0 packages/react-router-dom/__tests__/use-blocker-test.tsx
  33. +6 −0 packages/react-router-dom/dom.ts
  34. +56 −7 packages/react-router-dom/index.tsx
  35. +3 −3 packages/react-router-dom/package.json
  36. +7 −0 packages/react-router-dom/server.tsx
  37. +7 −0 packages/react-router-native/CHANGELOG.md
  38. +3 −0 packages/react-router-native/index.tsx
  39. +2 −2 packages/react-router-native/package.json
  40. +13 −0 packages/react-router/CHANGELOG.md
  41. +55 −0 packages/react-router/__tests__/generatePath-test.tsx
  42. +6 −0 packages/react-router/index.ts
  43. +3 −5 packages/react-router/lib/components.tsx
  44. +33 −0 packages/react-router/lib/hooks.tsx
  45. +2 −2 packages/react-router/package.json
  46. +16 −0 packages/router/CHANGELOG.md
  47. +1 −0 packages/router/__tests__/TestSequences/GoBack.ts
  48. +2 −0 packages/router/__tests__/TestSequences/GoForward.ts
  49. +0 −4 packages/router/__tests__/browser-test.ts
  50. +0 −19 packages/router/__tests__/custom-environment.js
  51. +0 −4 packages/router/__tests__/hash-base-test.ts
  52. +0 −4 packages/router/__tests__/hash-test.ts
  53. +493 −0 packages/router/__tests__/navigation-blocking-test.ts
  54. +180 −0 packages/router/__tests__/router-memory-test.ts
  55. +713 −164 packages/router/__tests__/router-test.ts
  56. +16 −0 packages/router/__tests__/setup.ts
  57. +89 −35 packages/router/history.ts
  58. +3 −3 packages/router/index.ts
  59. +1 −1 packages/router/package.json
  60. +361 −50 packages/router/router.ts
  61. +89 −43 packages/router/utils.ts
  62. +8 −2 rollup.config.js
  63. +15 −26 scripts/release/comment.ts
  64. +2 −1 scripts/release/constants.ts
  65. +38 −0 scripts/release/find-release-from-changeset.js
  66. +48 −22 scripts/release/github.ts
  67. +6 −0 scripts/release/utils.ts
16 changes: 9 additions & 7 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: 🐛 Bug Report
description: Something is wrong with React Router.
description: Something is wrong with React Router
title: "[Bug]: "
labels:
- bug
@@ -11,18 +11,20 @@ body:
Do you need some help?
======================
The issue tracker is meant for feature requests and bug reports only. This isn't the best place for
support or usage questions. Questions here don't have as much visibility as they do elsewhere. Before
you ask a question, here are some resources to get help first:
The issue tracker is meant for bug reports only. This isn't the best place for support
or usage questions. Questions here don't have as much visibility as they do elsewhere.
Before you ask a question, here are some resources to get help first:
- Read the docs: https://reactrouter.com
- Check out the list of frequently asked questions: https://reactrouter.com/start/faq
- Explore examples: https://reactrouter.com/start/examples
- Ask in chat: https://rmx.as/discord
- Look for/ask questions on Stack Overflow: https://stackoverflow.com/questions/tagged/react-router
- Ask in chat: https://discord.gg/6RyV8n8yyM
### Test Case Starter:
https://stackblitz.com/github/remix-run/react-router/tree/main/examples/basic?file=src/App.tsx
### Test Case Starters:
* [Using `<RouterProvider>`](https://stackblitz.com/github/remix-run/react-router/tree/main/examples/data-router?file=src/App.tsx)
* [Using `<BrowserRouter>`](https://stackblitz.com/github/remix-run/react-router/tree/main/examples/basic?file=src/App.tsx)
- type: input
attributes:
label: What version of React Router are you using?
12 changes: 9 additions & 3 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 🤔 Support/Usage Question
url: https://stackoverflow.com/questions/tagged/react-router
about: This is a bug tracker, not a support system. For usage questions, please use Stack Overflow where there are a lot more people ready to help you out. Thanks!
- name: 💡 Feature Request
url: https://github.com/remix-run/react-router/discussions/new?category=proposals
about: If you've got an idea for a new feature in React Router, please open a new Discussion with the `Proposals` label
- name: 🤔 Usage Question (Github Discussions)
url: https://github.com/remix-run/remix/discussions/new?category=q-a
about: Open a Discussion in GitHub wih the `Q&A` label
- name: 💬 Remix Discord Channel
url: https://rmx.as/discord
about: Interact with other people using React Router and Remix 📀
21 changes: 21 additions & 0 deletions .github/ISSUE_TEMPLATE/documentation_isse.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: 📚 Documentation Issue
description: Something is wrong with the React Router docs
title: "[Docs]: "
labels:
- docs
body:
- type: markdown
attributes:
value: |
Thank you for contributing!
For documentation updates - we would happily accept PRs, so feel free to update and
open a PR to the `main` branch. Otherwise let us know in this issue what you felt
was missing or incorrect.
- type: textarea
attributes:
label: Describe what's incorrect/missing in the documentation
description: A concise description of what you expected to see in the docs
validations:
required: true
32 changes: 0 additions & 32 deletions .github/ISSUE_TEMPLATE/feature_request.yml

This file was deleted.

17 changes: 0 additions & 17 deletions .github/workflows/postrelease.yml

This file was deleted.

71 changes: 50 additions & 21 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,39 +1,39 @@
name: 🕊 Release
name: 🦋 Changesets Release
on:
push:
branches:
- release
- "release-*"
- "!release-experimental"
- "!release-experimental-*"

concurrency: ${{ github.workflow }}-${{ github.ref }}

env:
CI: true
- "!release-manual"
- "!release-manual-*"

jobs:
release:
name: 🦋 Changesets Release
if: github.repository == 'remix-run/react-router'
runs-on: ubuntu-latest

outputs:
publishedPackages: ${{ steps.changesets.outputs.publishedPackages }}
published: ${{ steps.changesets.outputs.published }}
steps:
- name: 🛑 Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0

- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
fetch-depth: 0

- name: ⎔ Setup Node
- name: ⎔ Setup node
uses: actions/setup-node@v3
with:
node-version-file: ".nvmrc"
cache: yarn
cache: "yarn"

- name: 📥 Install dependencies
# even though this is called "npm-install" it does use yarn to install
# because we have a yarn.lock and caches efficiently.
uses: bahmutov/npm-install@v1
- name: 📥 Install deps
run: yarn --frozen-lockfile

- name: 🔐 Setup npm auth
run: |
@@ -52,16 +52,45 @@ jobs:
version: yarn run version
commit: "chore: Update version for release"
title: "chore: Update version for release"
publish: yarn release
publish: yarn run release
createGithubReleases: false
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN_SO_OTHER_ACTIONS_RUN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

# comment:
# needs: [release]
# name: 📝 Comment on related issues and pull requests
# if: github.repository == 'remix-run/react-router'
# uses: remix-run/react-router/.github/workflows/release-comments.yml@main
# with:
# ref: ${{ github.ref }}
findPackage:
name: 🦋 Find Package
needs: [release]
runs-on: ubuntu-latest
if: github.repository == 'remix-run/react-router' && needs.release.outputs.published == 'true'
outputs:
package: ${{ steps.findPackage.outputs.package }}
steps:
- name: 🛑 Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0

- name: ⬇️ Checkout repo
uses: actions/checkout@v3

- name: ⎔ Setup node
uses: actions/setup-node@v3
with:
node-version: 16
cache: "npm"

- id: findPackage
run: |
package=$(node ./scripts/release/find-release-from-changeset.js)
echo "package=${package}" >> $GITHUB_OUTPUT
env:
packageVersionToFollow: "react-router"
publishedPackages: ${{ needs.release.outputs.publishedPackages }}

comment:
name: 📝 Comment on related issues and pull requests
if: github.repository == 'remix-run/react-router' && needs.findPackage.outputs.package != ''
needs: [release, findPackage]
uses: ./.github/workflows/release-comments.yml
with:
ref: refs/tags/${{ needs.findPackage.outputs.package }}
packageVersionToFollow: "react-router"
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -22,4 +22,5 @@ node_modules/
/packages/react-router-dom-v5-compat/react-router-dom

.eslintcache
/.env
/.env
/NOTES.md
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@

[npm-badge]: https://img.shields.io/npm/v/react-router-dom.svg?style=flat-square
[npm]: https://www.npmjs.org/package/react-router-dom
[build-badge]: https://img.shields.io/github/workflow/status/remix-run/react-router/test/dev?style=flat-square
[build-badge]: https://img.shields.io/github/actions/workflow/status/remix-run/react-router/test.yml?branch=dev&style=square
[build]: https://github.com/remix-run/react-router/actions/workflows/test.yml

React Router is a lightweight, fully-featured routing library for the [React](https://reactjs.org) JavaScript library. React Router runs everywhere that React runs; on the web, on the server (using node.js), and on React Native.
4 changes: 4 additions & 0 deletions contributors.yml
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@
- adil62
- afzalsayed96
- Ajayff4
- akamfoad
- alany411
- alexlbr
- AmRo045
@@ -33,6 +34,7 @@
- codeape2
- coryhouse
- cvbuelow
- damianstasik
- danielberndt
- dauletbaev
- david-crespo
@@ -74,6 +76,7 @@
- JakubDrozd
- janpaepke
- jasonpaulos
- jenseng
- JesusTheHun
- jimniels
- jmargeta
@@ -93,6 +96,7 @@
- latin-1
- lequangdongg
- liuhanqu
- lkwr
- lopezac
- lordofthecactus
- loun4
1 change: 0 additions & 1 deletion docs/components/await.md
Original file line number Diff line number Diff line change
@@ -140,7 +140,6 @@ function Book() {
>
<Reviews />
</Await>
/>
</React.Suspense>
</div>
);
2 changes: 1 addition & 1 deletion docs/route/error-element.md
Original file line number Diff line number Diff line change
@@ -64,7 +64,7 @@ Here's a "not found" case in a [loader][loader]:
if (res.status === 404) {
throw new Response("Not Found", { status: 404 });
}
const home = res.json();
const home = await res.json();
const descriptionHtml = parseMarkdown(
data.descriptionMarkdown
);
388 changes: 355 additions & 33 deletions examples/data-router/src/app.tsx

Large diffs are not rendered by default.

399 changes: 0 additions & 399 deletions examples/data-router/src/routes.tsx

This file was deleted.

5 changes: 5 additions & 0 deletions examples/navigation-blocking/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
4 changes: 4 additions & 0 deletions examples/navigation-blocking/.stackblitzrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"installDependencies": true,
"startCommand": "npm run dev"
}
15 changes: 15 additions & 0 deletions examples/navigation-blocking/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
title: Navigation Blocking
toc: false
order: 1
---

# Navigation Blocking

This example demonstrates using `unstable_useBlocker` to prevent navigating away from a page where you might lose user-entered form data. A potentially better UX for this is storing user-entered information in `sessionStorage` and pre-populating the form on return.

## Preview

Open this example on [StackBlitz](https://stackblitz.com):

[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router/tree/main/examples/navigation-blocking?file=src/App.tsx)
12 changes: 12 additions & 0 deletions examples/navigation-blocking/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React Router - Navigation Blocking</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
2,456 changes: 2,456 additions & 0 deletions examples/navigation-blocking/package-lock.json

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions examples/navigation-blocking/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "navigation-blocking",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"serve": "vite preview"
},
"dependencies": {
"react": "18.1.0",
"react-dom": "18.1.0",
"react-router-dom": "^6.7.0-pre.3"
},
"devDependencies": {
"@rollup/plugin-replace": "4.0.0",
"@types/node": "17.0.32",
"@types/react": "18.0.9",
"@types/react-dom": "18.0.3",
"@vitejs/plugin-react": "1.3.2",
"typescript": "4.6.4",
"vite": "2.9.9"
}
}
141 changes: 141 additions & 0 deletions examples/navigation-blocking/src/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import React from "react";
import type { unstable_Blocker as Blocker } from "react-router-dom";
import {
createBrowserRouter,
createRoutesFromElements,
Form,
json,
Link,
Outlet,
Route,
RouterProvider,
unstable_useBlocker as useBlocker,
useLocation,
} from "react-router-dom";

let router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<Layout />}>
<Route index element={<h2>Index</h2>} />
<Route path="one" element={<h2>One</h2>} />
<Route path="two" element={<h2>Two</h2>} />
<Route
path="three"
action={() => json({ ok: true })}
element={
<>
<h2>Three</h2>
<ImportantForm />
</>
}
/>
<Route path="four" element={<h2>Four</h2>} />
<Route path="five" element={<h2>Five</h2>} />
</Route>
)
);

if (import.meta.hot) {
import.meta.hot.dispose(() => router.dispose());
}

export default function App() {
return <RouterProvider router={router} />;
}

function Layout() {
let [historyIndex, setHistoryIndex] = React.useState(
window.history.state?.idx
);
let location = useLocation();

// Expose the underlying history index in the UI for debugging
React.useEffect(() => {
setHistoryIndex(window.history.state?.idx);
}, [location]);

// Give us meaningful document titles for popping back/forward more than 1 entry
React.useEffect(() => {
document.title = location.pathname;
}, [location]);

return (
<>
<h1>Navigation Blocking Example</h1>
<nav>
<Link to="/">Index</Link>&nbsp;&nbsp;
<Link to="/one">One</Link>&nbsp;&nbsp;
<Link to="/two">Two</Link>&nbsp;&nbsp;
<Link to="/three">Three (Form with blocker)</Link>&nbsp;&nbsp;
<Link to="/four">Four</Link>&nbsp;&nbsp;
<Link to="/five">Five</Link>&nbsp;&nbsp;
</nav>
<p>
Current location (index): {location.pathname} ({historyIndex})
</p>
<Outlet />
</>
);
}

function ImportantForm() {
let [value, setValue] = React.useState("");
let isBlocked = value !== "";
let blocker = useBlocker(isBlocked);

// Reset the blocker if the user cleans the form
React.useEffect(() => {
if (blocker.state === "blocked" && !isBlocked) {
blocker.reset();
}
}, [blocker, isBlocked]);

return (
<>
<p>
Is the form dirty?{" "}
{isBlocked ? (
<span style={{ color: "red" }}>Yes</span>
) : (
<span style={{ color: "green" }}>No</span>
)}
</p>

<Form method="post">
<label>
Enter some important data:
<input
name="data"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</label>
<button type="submit">Save</button>
</Form>

{blocker ? <ConfirmNavigation blocker={blocker} /> : null}
</>
);
}

function ConfirmNavigation({ blocker }: { blocker: Blocker }) {
if (blocker.state === "blocked") {
return (
<>
<p style={{ color: "red" }}>
Blocked the last navigation to {blocker.location.pathname}
</p>
<button onClick={() => blocker.proceed?.()}>Let me through</button>
<button onClick={() => blocker.reset?.()}>Keep me here</button>
</>
);
}

if (blocker.state === "proceeding") {
return (
<p style={{ color: "orange" }}>Proceeding through blocked navigation</p>
);
}

return <p style={{ color: "green" }}>Blocker is currently unblocked</p>;
}
9 changes: 9 additions & 0 deletions examples/navigation-blocking/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./app";

ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
1 change: 1 addition & 0 deletions examples/navigation-blocking/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="vite/client" />
21 changes: 21 additions & 0 deletions examples/navigation-blocking/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"baseUrl": ".",
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react",
"importsNotUsedAsValues": "error"
},
"include": ["./src"]
}
36 changes: 36 additions & 0 deletions examples/navigation-blocking/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import rollupReplace from "@rollup/plugin-replace";

// https://vitejs.dev/config/
export default defineConfig({
plugins: [
rollupReplace({
preventAssignment: true,
values: {
__DEV__: JSON.stringify(true),
"process.env.NODE_ENV": JSON.stringify("development"),
},
}),
react(),
],
resolve: process.env.USE_SOURCE
? {
alias: {
"@remix-run/router": path.resolve(
__dirname,
"../../packages/router/index.ts"
),
"react-router": path.resolve(
__dirname,
"../../packages/react-router/index.ts"
),
"react-router-dom": path.resolve(
__dirname,
"../../packages/react-router-dom/index.tsx"
),
},
}
: {},
});
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -25,6 +25,7 @@
"release": "changeset publish",
"size": "filesize",
"test": "jest",
"test:inspect": "node --inspect-brk ./node_modules/.bin/jest",
"changeset": "changeset",
"version": "changeset version",
"postversion": "node scripts/postversion.mjs",
@@ -68,6 +69,7 @@
"@types/semver": "^7.3.8",
"@typescript-eslint/eslint-plugin": "^4.28.3",
"@typescript-eslint/parser": "^4.28.3",
"abort-controller": "^3.0.0",
"babel-eslint": "^10.1.0",
"babel-jest": "^26.6.3",
"babel-plugin-dev-expression": "^0.2.2",
@@ -107,19 +109,19 @@
},
"filesize": {
"packages/router/dist/router.umd.min.js": {
"none": "38 kB"
"none": "41 kB"
},
"packages/react-router/dist/react-router.production.min.js": {
"none": "12.5 kB"
"none": "13 kB"
},
"packages/react-router/dist/umd/react-router.production.min.js": {
"none": "15 kB"
},
"packages/react-router-dom/dist/react-router-dom.production.min.js": {
"none": "11 kB"
"none": "11.5 kB"
},
"packages/react-router-dom/dist/umd/react-router-dom.production.min.js": {
"none": "16.5 kB"
"none": "17 kB"
}
}
}
8 changes: 8 additions & 0 deletions packages/react-router-dom-v5-compat/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# `react-router-dom-v5-compat`

## 6.7.0

### Patch Changes

- Updated dependencies:
- `react-router@6.7.0`
- `react-router-dom@6.7.0`

## 6.6.2

### Patch Changes
4 changes: 2 additions & 2 deletions packages/react-router-dom-v5-compat/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-router-dom-v5-compat",
"version": "6.6.2",
"version": "6.7.0",
"description": "Migration path to React Router v6 from v4/5",
"keywords": [
"react",
@@ -24,7 +24,7 @@
"types": "./dist/index.d.ts",
"dependencies": {
"history": "^5.3.0",
"react-router": "6.6.2"
"react-router": "6.7.0"
},
"peerDependencies": {
"react": ">=16.8",
16 changes: 16 additions & 0 deletions packages/react-router-dom/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# `react-router-dom`

## 6.7.0

### Minor Changes

- Add `unstable_useBlocker` hook for blocking navigations within the app's location origin ([#9709](https://github.com/remix-run/react-router/pull/9709))
- Add `unstable_usePrompt` hook for blocking navigations within the app's location origin ([#9932](https://github.com/remix-run/react-router/pull/9932))
- Add `preventScrollReset` prop to `<Form>` ([#9886](https://github.com/remix-run/react-router/pull/9886))

### Patch Changes

- Added pass-through event listener options argument to `useBeforeUnload` ([#9709](https://github.com/remix-run/react-router/pull/9709))
- Streamline jsdom bug workaround in tests ([#9824](https://github.com/remix-run/react-router/pull/9824))
- Updated dependencies:
- `@remix-run/router@1.3.0`
- `react-router@6.7.0`

## 6.6.2

### Patch Changes
79 changes: 8 additions & 71 deletions packages/react-router-dom/__tests__/data-browser-router-test.tsx
Original file line number Diff line number Diff line change
@@ -1506,14 +1506,7 @@ function testDomRouter(
function Comp() {
let location = useLocation();
return (
<Form
onSubmit={(e) => {
// jsdom doesn't handle submitter so we add it here
// See https://github.com/jsdom/jsdom/issues/3117
// @ts-expect-error
e.nativeEvent.submitter = e.currentTarget.querySelector("button");
}}
>
<Form>
<p>{location.pathname + location.search}</p>
<input name="a" defaultValue="1" />
<button type="submit" name="b" value="2">
@@ -1587,15 +1580,7 @@ function testDomRouter(
let location = useLocation();
let data = useActionData() as string | undefined;
return (
<Form
method="post"
onSubmit={(e) => {
// jsdom doesn't handle submitter so we add it here
// See https://github.com/jsdom/jsdom/issues/3117
// @ts-expect-error
e.nativeEvent.submitter = e.currentTarget.querySelector("button");
}}
>
<Form method="post">
<p>{location.pathname + location.search}</p>
{data && <p>{data}</p>}
<input name="a" defaultValue="1" />
@@ -1683,16 +1668,7 @@ function testDomRouter(
let navigation = useNavigation();
return (
<div>
<Form
method="post"
onSubmit={(e) => {
// jsdom doesn't handle submitter so we add it here
// See https://github.com/jsdom/jsdom/issues/3117
// @ts-expect-error
e.nativeEvent.submitter =
e.currentTarget.querySelector("button");
}}
>
<Form method="post">
<input name="test" value="value" />
<button type="submit" formMethod="get">
Submit Form
@@ -2501,16 +2477,7 @@ function testDomRouter(

function FormPage() {
return (
<Form
method="post"
onSubmit={(e) => {
// jsdom doesn't handle submitter so we add it here
// See https://github.com/jsdom/jsdom/issues/3117
// @ts-expect-error
e.nativeEvent.submitter =
e.currentTarget.querySelector("button");
}}
>
<Form method="post">
<input name="a" defaultValue="1" />
<input name="b" defaultValue="2" />
<button name="c" value="3" type="submit">
@@ -2538,16 +2505,7 @@ function testDomRouter(
function FormPage() {
let submit = useSubmit();
return (
<Form
method="post"
onSubmit={(e) => {
// jsdom doesn't handle submitter so we add it here
// See https://github.com/jsdom/jsdom/issues/3117
// @ts-expect-error
e.nativeEvent.submitter =
e.currentTarget.querySelector("button");
}}
>
<Form method="post">
<input name="a" defaultValue="1" />
<input name="b" defaultValue="2" />
<button
@@ -2581,16 +2539,7 @@ function testDomRouter(

function FormPage() {
return (
<Form
method="post"
onSubmit={(e) => {
// jsdom doesn't handle submitter so we add it here
// See https://github.com/jsdom/jsdom/issues/3117
// @ts-expect-error
e.nativeEvent.submitter =
e.currentTarget.querySelector("button");
}}
>
<Form method="post">
<input name="a" defaultValue="1" />
<input name="b" defaultValue="2" />
<button name="b" value="3" type="submit">
@@ -2617,16 +2566,7 @@ function testDomRouter(
function FormPage() {
let submit = useSubmit();
return (
<Form
method="post"
onSubmit={(e) => {
// jsdom doesn't handle submitter so we add it here
// See https://github.com/jsdom/jsdom/issues/3117
// @ts-expect-error
e.nativeEvent.submitter =
e.currentTarget.querySelector("button");
}}
>
<Form method="post">
<input name="a" defaultValue="1" />
<input name="b" defaultValue="2" />
<button
@@ -3104,9 +3044,6 @@ function testDomRouter(
</TestDataRouter>
);

// Note: jsdom doesn't properly attach event.submitter for
// <button type="submit"> clicks, so we have to use an input to drive
// this. See https://github.com/jsdom/jsdom/issues/3117
function Comp() {
let fetcher = useFetcher();
return (
@@ -4263,7 +4200,7 @@ function createDeferred() {

function getWindowImpl(initialUrl: string, isHash = false): Window {
// Need to use our own custom DOM in order to get a working history
const dom = new JSDOM(`<!DOCTYPE html>`, { url: "https://remix.run/" });
const dom = new JSDOM(`<!DOCTYPE html>`, { url: "http://localhost/" });
dom.window.history.replaceState(null, "", (isHash ? "#" : "") + initialUrl);
return dom.window as unknown as Window;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Polyfill jsdom SubmitEvent.submitter, until https://github.com/jsdom/jsdom/pull/3481 is merged
if (
typeof SubmitEvent === "undefined" ||
!SubmitEvent.prototype.hasOwnProperty("submitter")
) {
let maybeSubmitter;
window.addEventListener(
"click",
(event) => {
if ((event.target as any)?.form) maybeSubmitter = event.target;
setImmediate(() => {
// if this click doesn't imminently trigger a submit event, then forget it
maybeSubmitter = undefined;
});
},
{ capture: true }
);
window.addEventListener(
"submit",
(event: any) => {
if (maybeSubmitter?.form === event.target)
event.submitter = maybeSubmitter;
},
{ capture: true }
);
}
2 changes: 2 additions & 0 deletions packages/react-router-dom/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { fetch, Request, Response } from "@remix-run/web-fetch";

import "./polyfills/SubmitEvent.submitter";

// https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#configuring-your-testing-environment
globalThis.IS_REACT_ACT_ENVIRONMENT = true;

1,004 changes: 1,004 additions & 0 deletions packages/react-router-dom/__tests__/use-blocker-test.tsx

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions packages/react-router-dom/dom.ts
Original file line number Diff line number Diff line change
@@ -138,6 +138,12 @@ export interface SubmitOptions {
* hierarchy and want to instead route based on /-delimited URL segments
*/
relative?: RelativeRoutingType;

/**
* In browser-based environments, prevent resetting scroll after this
* navigation when using the <ScrollRestoration> component
*/
preventScrollReset?: boolean;
}

export function getFormSubmissionInfo(
63 changes: 56 additions & 7 deletions packages/react-router-dom/index.tsx
Original file line number Diff line number Diff line change
@@ -18,6 +18,7 @@ import {
useNavigate,
useNavigation,
useResolvedPath,
unstable_useBlocker as useBlocker,
UNSAFE_DataRouterContext as DataRouterContext,
UNSAFE_DataRouterStateContext as DataRouterStateContext,
UNSAFE_NavigationContext as NavigationContext,
@@ -76,6 +77,8 @@ export type {
ActionFunction,
ActionFunctionArgs,
AwaitProps,
unstable_Blocker,
unstable_BlockerFunction,
DataRouteMatch,
DataRouteObject,
Fetcher,
@@ -142,6 +145,7 @@ export {
useActionData,
useAsyncError,
useAsyncValue,
unstable_useBlocker,
useHref,
useInRouterContext,
useLoaderData,
@@ -593,6 +597,12 @@ export interface FormProps extends React.FormHTMLAttributes<HTMLFormElement> {
*/
relative?: RelativeRoutingType;

/**
* Prevent the scroll position from resetting to the top of the viewport on
* completion of the navigation when using the <ScrollRestoration> component
*/
preventScrollReset?: boolean;

/**
* A function to call when the form is submitted. If you call
* `event.preventDefault()` then this form will not do anything.
@@ -640,6 +650,7 @@ const FormImpl = React.forwardRef<HTMLFormElement, FormImplProps>(
fetcherKey,
routeId,
relative,
preventScrollReset,
...props
},
forwardedRef
@@ -664,6 +675,7 @@ const FormImpl = React.forwardRef<HTMLFormElement, FormImplProps>(
method: submitMethod,
replace,
relative,
preventScrollReset,
});
};

@@ -906,6 +918,7 @@ function useSubmitImpl(fetcherKey?: string, routeId?: string): SubmitFunction {
let href = url.pathname + url.search;
let opts = {
replace: options.replace,
preventScrollReset: options.preventScrollReset,
formData,
formMethod: method as FormMethod,
formEncType: encType as FormEncType,
@@ -1000,8 +1013,9 @@ export type FetcherWithComponents<TData> = Fetcher<TData> & {
Form: ReturnType<typeof createFetcherForm>;
submit: (
target: SubmitTarget,
// Fetchers cannot replace because they are not navigation events
options?: Omit<SubmitOptions, "replace">
// Fetchers cannot replace/preventScrollReset because they are not
// navigation events
options?: Omit<SubmitOptions, "replace" | "preventScrollReset">
) => void;
load: (href: string) => void;
};
@@ -1165,7 +1179,7 @@ function useScrollRestoration({
}
}

// Opt out of scroll reset if this link requested it
// Don't reset if this navigation opted out
if (preventScrollReset === true) {
return;
}
@@ -1185,15 +1199,50 @@ function useScrollRestoration({
* `React.useCallback()`.
*/
export function useBeforeUnload(
callback: (event: BeforeUnloadEvent) => any
callback: (event: BeforeUnloadEvent) => any,
options?: { capture?: boolean }
): void {
let { capture } = options || {};
React.useEffect(() => {
window.addEventListener("beforeunload", callback);
let opts = capture != null ? { capture } : undefined;
window.addEventListener("beforeunload", callback, opts);
return () => {
window.removeEventListener("beforeunload", callback);
window.removeEventListener("beforeunload", callback, opts);
};
}, [callback]);
}, [callback, capture]);
}

/**
* Wrapper around useBlocker to show a window.confirm prompt to users instead
* of building a custom UI with useBlocker.
*
* Warning: This has *a lot of rough edges* and behaves very differently (and
* very incorrectly in some cases) across browsers if user click addition
* back/forward navigations while the confirm is open. Use at your own risk.
*/
function usePrompt({ when, message }: { when: boolean; message: string }) {
let blocker = useBlocker(when);

React.useEffect(() => {
if (blocker.state === "blocked" && !when) {
blocker.reset();
}
}, [blocker, when]);

React.useEffect(() => {
if (blocker.state === "blocked") {
let proceed = window.confirm(message);
if (proceed) {
setTimeout(blocker.proceed, 0);
} else {
blocker.reset();
}
}
}, [blocker, message]);
}

export { usePrompt as unstable_usePrompt };

//#endregion

////////////////////////////////////////////////////////////////////////////////
6 changes: 3 additions & 3 deletions packages/react-router-dom/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-router-dom",
"version": "6.6.2",
"version": "6.7.0",
"description": "Declarative routing for React web applications",
"keywords": [
"react",
@@ -23,8 +23,8 @@
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"dependencies": {
"@remix-run/router": "1.2.1",
"react-router": "6.6.2"
"@remix-run/router": "1.3.0",
"react-router": "6.7.0"
},
"devDependencies": {
"react": "^18.2.0",
7 changes: 7 additions & 0 deletions packages/react-router-dom/server.tsx
Original file line number Diff line number Diff line change
@@ -263,6 +263,7 @@ export function createStaticRouter(
preventScrollReset: false,
revalidation: "idle" as RevalidationState,
fetchers: new Map(),
blockers: new Map(),
};
},
get routes() {
@@ -297,6 +298,12 @@ export function createStaticRouter(
dispose() {
throw msg("dispose");
},
getBlocker() {
throw msg("getBlocker");
},
deleteBlocker() {
throw msg("deleteBlocker");
},
_internalFetchControllers: new Map(),
_internalActiveDeferreds: new Map(),
};
7 changes: 7 additions & 0 deletions packages/react-router-native/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# `react-router-native`

## 6.7.0

### Patch Changes

- Updated dependencies:
- `react-router@6.7.0`

## 6.6.2

### Patch Changes
3 changes: 3 additions & 0 deletions packages/react-router-native/index.tsx
Original file line number Diff line number Diff line change
@@ -23,6 +23,8 @@ export type {
ActionFunction,
ActionFunctionArgs,
AwaitProps,
unstable_Blocker,
unstable_BlockerFunction,
DataRouteMatch,
DataRouteObject,
Fetcher,
@@ -89,6 +91,7 @@ export {
useActionData,
useAsyncError,
useAsyncValue,
unstable_useBlocker,
useHref,
useInRouterContext,
useLoaderData,
4 changes: 2 additions & 2 deletions packages/react-router-native/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-router-native",
"version": "6.6.2",
"version": "6.7.0",
"description": "Declarative routing for React Native applications",
"keywords": [
"react",
@@ -22,7 +22,7 @@
"types": "./dist/index.d.ts",
"dependencies": {
"@ungap/url-search-params": "^0.1.4",
"react-router": "6.6.2"
"react-router": "6.7.0"
},
"devDependencies": {
"react": "^18.2.0",
13 changes: 13 additions & 0 deletions packages/react-router/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# `react-router`

## 6.7.0

### Minor Changes

- Add `unstable_useBlocker` hook for blocking navigations within the app's location origin ([#9709](https://github.com/remix-run/react-router/pull/9709))

### Patch Changes

- Fix `generatePath` when optional params are present ([#9764](https://github.com/remix-run/react-router/pull/9764))
- Update `<Await>` to accept `ReactNode` as children function return result ([#9896](https://github.com/remix-run/react-router/pull/9896))
- Updated dependencies:
- `@remix-run/router@1.3.0`

## 6.6.2

### Patch Changes
55 changes: 55 additions & 0 deletions packages/react-router/__tests__/generatePath-test.tsx
Original file line number Diff line number Diff line change
@@ -25,6 +25,7 @@ describe("generatePath", () => {
expect(generatePath("*", { "*": "routing/grades" })).toBe(
"routing/grades"
);
expect(generatePath("/*", {})).toBe("/");
});
});

@@ -49,6 +50,60 @@ describe("generatePath", () => {
});
});

describe("with optional params", () => {
it("adds optional dynamic params where appropriate", () => {
let path = "/:one?/:two?/:three?";
expect(generatePath(path, { one: "uno" })).toBe("/uno");
expect(generatePath(path, { one: "uno", two: "dos" })).toBe("/uno/dos");
expect(
generatePath(path, {
one: "uno",
two: "dos",
three: "tres",
})
).toBe("/uno/dos/tres");
expect(generatePath(path, { one: "uno", three: "tres" })).toBe(
"/uno/tres"
);
expect(generatePath(path, { two: "dos" })).toBe("/dos");
expect(generatePath(path, { two: "dos", three: "tres" })).toBe(
"/dos/tres"
);
});

it("strips optional aspects of static segments", () => {
expect(generatePath("/one?/two?/:three?", {})).toBe("/one/two");
expect(generatePath("/one?/two?/:three?", { three: "tres" })).toBe(
"/one/two/tres"
);
});

it("handles intermixed segments", () => {
let path = "/one?/:two?/three/:four/*";
expect(generatePath(path, { four: "cuatro" })).toBe("/one/three/cuatro");
expect(
generatePath(path, {
two: "dos",
four: "cuatro",
})
).toBe("/one/dos/three/cuatro");
expect(
generatePath(path, {
two: "dos",
four: "cuatro",
"*": "splat",
})
).toBe("/one/dos/three/cuatro/splat");
expect(
generatePath(path, {
two: "dos",
four: "cuatro",
"*": "splat/and/then/some",
})
).toBe("/one/dos/three/cuatro/splat/and/then/some");
});
});

it("throws only on on missing named parameters, but not missing splat params", () => {
expect(() => generatePath(":foo")).toThrow();
expect(() => generatePath("/:foo")).toThrow();
6 changes: 6 additions & 0 deletions packages/react-router/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type {
ActionFunction,
ActionFunctionArgs,
Blocker,
BlockerFunction,
Fetcher,
HydrationState,
JsonFunction,
@@ -82,6 +84,7 @@ import {
} from "./lib/context";
import type { NavigateFunction } from "./lib/hooks";
import {
useBlocker,
useHref,
useInRouterContext,
useLocation,
@@ -114,6 +117,8 @@ export type {
ActionFunction,
ActionFunctionArgs,
AwaitProps,
Blocker as unstable_Blocker,
BlockerFunction as unstable_BlockerFunction,
DataRouteMatch,
DataRouteObject,
Fetcher,
@@ -179,6 +184,7 @@ export {
useActionData,
useAsyncError,
useAsyncValue,
useBlocker as unstable_useBlocker,
useHref,
useInRouterContext,
useLoaderData,
8 changes: 3 additions & 5 deletions packages/react-router/lib/components.tsx
Original file line number Diff line number Diff line change
@@ -394,7 +394,7 @@ export function Routes({
}

export interface AwaitResolveRenderFunction {
(data: Awaited<any>): React.ReactElement;
(data: Awaited<any>): React.ReactNode;
}

export interface AwaitProps {
@@ -531,10 +531,8 @@ function ResolveAwait({
children: React.ReactNode | AwaitResolveRenderFunction;
}) {
let data = useAsyncValue();
if (typeof children === "function") {
return children(data);
}
return <>{children}</>;
let toRender = typeof children === "function" ? children(data) : children;
return <>{toRender}</>;
}

///////////////////////////////////////////////////////////////////////////////
33 changes: 33 additions & 0 deletions packages/react-router/lib/hooks.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as React from "react";
import type {
Blocker,
BlockerFunction,
Location,
ParamParseKey,
Params,
@@ -650,6 +652,7 @@ export function _renderMatches(
}

enum DataRouterHook {
UseBlocker = "useBlocker",
UseRevalidator = "useRevalidator",
}

@@ -818,6 +821,36 @@ export function useAsyncError(): unknown {
return value?._error;
}

// useBlocker() is a singleton for now since we don't have any compelling use
// cases for multi-blocker yet
let blockerKey = "blocker-singleton";

/**
* Allow the application to block navigations within the SPA and present the
* user a confirmation dialog to confirm the navigation. Mostly used to avoid
* using half-filled form data. This does not handle hard-reloads or
* cross-origin navigations.
*/
export function useBlocker(shouldBlock: boolean | BlockerFunction): Blocker {
let { router } = useDataRouterContext(DataRouterHook.UseBlocker);

let blockerFunction = React.useCallback<BlockerFunction>(
(args) => {
return typeof shouldBlock === "function"
? !!shouldBlock(args)
: !!shouldBlock;
},
[shouldBlock]
);

let blocker = router.getBlocker(blockerKey, blockerFunction);

// Cleanup on unmount
React.useEffect(() => () => router.deleteBlocker(blockerKey), [router]);

return blocker;
}

const alreadyWarned: Record<string, boolean> = {};

function warningOnce(key: string, cond: boolean, message: string) {
4 changes: 2 additions & 2 deletions packages/react-router/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-router",
"version": "6.6.2",
"version": "6.7.0",
"description": "Declarative routing for React",
"keywords": [
"react",
@@ -23,7 +23,7 @@
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"dependencies": {
"@remix-run/router": "1.2.1"
"@remix-run/router": "1.3.0"
},
"devDependencies": {
"react": "^18.2.0"
16 changes: 16 additions & 0 deletions packages/router/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# `@remix-run/router`

## 1.3.0

### Minor Changes

- Added support for navigation blocking APIs ([#9709](https://github.com/remix-run/react-router/pull/9709))
- Expose deferred information from `createStaticHandler` ([#9760](https://github.com/remix-run/react-router/pull/9760))

### Patch Changes

- Improved absolute redirect url detection in actions/loaders ([#9829](https://github.com/remix-run/react-router/pull/9829))
- Fix URL creation with memory histories ([#9814](https://github.com/remix-run/react-router/pull/9814))
- Fix `generatePath` when optional params are present ([#9764](https://github.com/remix-run/react-router/pull/9764))
- Fix scroll reset if a submission redirects ([#9886](https://github.com/remix-run/react-router/pull/9886))
- Fix 404 bug with same-origin absolute redirects ([#9913](https://github.com/remix-run/react-router/pull/9913))
- Support `OPTIONS` requests in `staticHandler.queryRoute` ([#9914](https://github.com/remix-run/react-router/pull/9914))

## 1.2.1

### Patch Changes
1 change: 1 addition & 0 deletions packages/router/__tests__/TestSequences/GoBack.ts
Original file line number Diff line number Diff line change
@@ -31,6 +31,7 @@ export default async function GoBack(history: History) {
});
expect(spy).toHaveBeenCalledWith({
action: "POP",
delta: expect.any(Number),
location: {
hash: "",
key: expect.any(String),
2 changes: 2 additions & 0 deletions packages/router/__tests__/TestSequences/GoForward.ts
Original file line number Diff line number Diff line change
@@ -31,6 +31,7 @@ export default async function GoForward(history: History) {
});
expect(spy).toHaveBeenCalledWith({
action: "POP",
delta: expect.any(Number),
location: {
hash: "",
key: expect.any(String),
@@ -58,6 +59,7 @@ export default async function GoForward(history: History) {
});
expect(spy).toHaveBeenCalledWith({
action: "POP",
delta: expect.any(Number),
location: {
hash: "",
key: expect.any(String),
4 changes: 0 additions & 4 deletions packages/router/__tests__/browser-test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
/**
* @jest-environment ./__tests__/custom-environment.js
*/

/* eslint-disable jest/expect-expect */

import { JSDOM } from "jsdom";
19 changes: 0 additions & 19 deletions packages/router/__tests__/custom-environment.js

This file was deleted.

4 changes: 0 additions & 4 deletions packages/router/__tests__/hash-base-test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
/**
* @jest-environment ./__tests__/custom-environment.js
*/

import { JSDOM } from "jsdom";

import type { HashHistory } from "@remix-run/router";
4 changes: 0 additions & 4 deletions packages/router/__tests__/hash-test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
/**
* @jest-environment ./__tests__/custom-environment.js
*/

/* eslint-disable jest/expect-expect */

import { JSDOM } from "jsdom";
493 changes: 493 additions & 0 deletions packages/router/__tests__/navigation-blocking-test.ts

Large diffs are not rendered by default.

180 changes: 180 additions & 0 deletions packages/router/__tests__/router-memory-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* @jest-environment node
*/

import { createMemoryHistory, createRouter } from "../index";

// This suite of tests specifically runs in the node jest environment to catch
// issues when window is not present
describe("a memory router", () => {
it("initializes properly", async () => {
let router = createRouter({
routes: [
{
path: "/",
},
],
history: createMemoryHistory(),
});
expect(router.state).toEqual({
historyAction: "POP",
loaderData: {},
actionData: null,
errors: null,
location: {
hash: "",
key: expect.any(String),
pathname: "/",
search: "",
state: null,
},
matches: [
{
params: {},
pathname: "/",
pathnameBase: "/",
route: {
id: "0",
path: "/",
},
},
],
initialized: true,
navigation: {
location: undefined,
state: "idle",
},
preventScrollReset: false,
restoreScrollPosition: null,
revalidation: "idle",
fetchers: new Map(),
blockers: new Map(),
});
router.dispose();
});

it("can create Requests without window", async () => {
let loaderSpy = jest.fn();
let router = createRouter({
routes: [
{
path: "/",
},
{
path: "/a",
loader: loaderSpy,
},
],
history: createMemoryHistory(),
});

router.navigate("/a");
expect(loaderSpy.mock.calls[0][0].request.url).toBe("http://localhost/a");
router.dispose();
});

it("can create URLs without window", async () => {
let shouldRevalidateSpy = jest.fn();

let router = createRouter({
routes: [
{
path: "/",
loader: () => "ROOT",
shouldRevalidate: shouldRevalidateSpy,
children: [
{
index: true,
},
{
path: "a",
},
],
},
],
history: createMemoryHistory(),
hydrationData: { loaderData: { "0": "ROOT" } },
});

router.navigate("/a");
expect(shouldRevalidateSpy.mock.calls[0][0].currentUrl.toString()).toBe(
"http://localhost/"
);
expect(shouldRevalidateSpy.mock.calls[0][0].nextUrl.toString()).toBe(
"http://localhost/a"
);
router.dispose();
});

it("properly handles same-origin absolute URLs", async () => {
let router = createRouter({
routes: [
{
path: "/",
children: [
{
index: true,
},
{
path: "a",
loader: () =>
new Response(null, {
status: 302,
headers: {
Location: "http://localhost/b",
},
}),
},
{
path: "b",
},
],
},
],
history: createMemoryHistory(),
});

await router.navigate("/a");
expect(router.state.location).toMatchObject({
hash: "",
pathname: "/b",
search: "",
});
});

it("properly handles protocol-less same-origin absolute URLs", async () => {
let router = createRouter({
routes: [
{
path: "/",
children: [
{
index: true,
},
{
path: "a",
loader: () =>
new Response(null, {
status: 302,
headers: {
Location: "//localhost/b",
},
}),
},
{
path: "b",
},
],
},
],
history: createMemoryHistory(),
});

await router.navigate("/a");
expect(router.state.location).toMatchObject({
hash: "",
pathname: "/b",
search: "",
});
});
});
877 changes: 713 additions & 164 deletions packages/router/__tests__/router-test.ts

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions packages/router/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import {
TextEncoder as NodeTextEncoder,
TextDecoder as NodeTextDecoder,
} from "util";
import { fetch, Request, Response } from "@remix-run/web-fetch";
import { AbortController as NodeAbortController } from "abort-controller";

if (!globalThis.fetch) {
// Built-in lib.dom.d.ts expects `fetch(Request | string, ...)` but the web
@@ -13,3 +18,14 @@ if (!globalThis.fetch) {
// @ts-expect-error
globalThis.Response = Response;
}

if (!globalThis.AbortController) {
// @ts-expect-error
globalThis.AbortController = NodeAbortController;
}

if (!globalThis.TextEncoder || !globalThis.TextDecoder) {
globalThis.TextEncoder = NodeTextEncoder;
// @ts-expect-error
globalThis.TextDecoder = NodeTextDecoder;
}
124 changes: 89 additions & 35 deletions packages/router/history.ts
Original file line number Diff line number Diff line change
@@ -81,6 +81,11 @@ export interface Update {
* The new location.
*/
location: Location;

/**
* The delta between this location and the former location in the history stack
*/
delta: number;
}

/**
@@ -125,6 +130,13 @@ export interface History {
*/
createHref(to: To): string;

/**
* Returns a URL for the given `to` value
*
* @param to - The destination URL
*/
createURL(to: To): URL;

/**
* Encode a location the same way window.history would do (no-op for memory
* history) so we ensure our PUSH/REPLACE navigations for data routers
@@ -174,6 +186,7 @@ export interface History {
type HistoryState = {
usr: any;
key?: string;
idx: number;
};

const PopStateEventType = "popstate";
@@ -255,6 +268,10 @@ export function createMemoryHistory(
return location;
}

function createHref(to: To) {
return typeof to === "string" ? to : createPath(to);
}

let history: MemoryHistory = {
get index() {
return index;
@@ -265,8 +282,9 @@ export function createMemoryHistory(
get location() {
return getCurrentLocation();
},
createHref(to) {
return typeof to === "string" ? to : createPath(to);
createHref,
createURL(to) {
return new URL(createHref(to), "http://localhost");
},
encodeLocation(to: To) {
let path = typeof to === "string" ? parsePath(to) : to;
@@ -282,22 +300,24 @@ export function createMemoryHistory(
index += 1;
entries.splice(index, entries.length, nextLocation);
if (v5Compat && listener) {
listener({ action, location: nextLocation });
listener({ action, location: nextLocation, delta: 1 });
}
},
replace(to, state) {
action = Action.Replace;
let nextLocation = createMemoryLocation(to, state);
entries[index] = nextLocation;
if (v5Compat && listener) {
listener({ action, location: nextLocation });
listener({ action, location: nextLocation, delta: 0 });
}
},
go(delta) {
action = Action.Pop;
index = clampIndex(index + delta);
let nextIndex = clampIndex(index + delta);
let nextLocation = entries[nextIndex];
index = nextIndex;
if (listener) {
listener({ action, location: getCurrentLocation() });
listener({ action, location: nextLocation, delta });
}
},
listen(fn: Listener) {
@@ -485,10 +505,11 @@ function createKey() {
/**
* For browser-based histories, we combine the state and key into an object
*/
function getHistoryState(location: Location): HistoryState {
function getHistoryState(location: Location, index: number): HistoryState {
return {
usr: location.state,
key: location.key,
idx: index,
};
}

@@ -558,24 +579,6 @@ export function parsePath(path: string): Partial<Path> {
return parsedPath;
}

export function createClientSideURL(location: Location | string): URL {
// window.location.origin is "null" (the literal string value) in Firefox
// under certain conditions, notably when serving from a local HTML file
// See https://bugzilla.mozilla.org/show_bug.cgi?id=878297
let base =
typeof window !== "undefined" &&
typeof window.location !== "undefined" &&
window.location.origin !== "null"
? window.location.origin
: window.location.href;
let href = typeof location === "string" ? location : createPath(location);
invariant(
base,
`No window.location.(origin|href) available to create URL for href: ${href}`
);
return new URL(href, base);
}

export interface UrlHistory extends History {}

export type UrlHistoryOptions = {
@@ -594,10 +597,43 @@ function getUrlBasedHistory(
let action = Action.Pop;
let listener: Listener | null = null;

let index = getIndex()!;
// Index should only be null when we initialize. If not, it's because the
// user called history.pushState or history.replaceState directly, in which
// case we should log a warning as it will result in bugs.
if (index == null) {
index = 0;
globalHistory.replaceState({ ...globalHistory.state, idx: index }, "");
}

function getIndex(): number {
let state = globalHistory.state || { idx: null };
return state.idx;
}

function handlePop() {
action = Action.Pop;
if (listener) {
listener({ action, location: history.location });
let nextAction = Action.Pop;
let nextIndex = getIndex();

if (nextIndex != null) {
let delta = nextIndex - index;
action = nextAction;
index = nextIndex;
if (listener) {
listener({ action, location: history.location, delta });
}
} else {
warning(
false,
// TODO: Write up a doc that explains our blocking strategy in detail
// and link to it here so people can understand better what is going on
// and how to avoid it.
`You are trying to block a POP navigation to a location that was not ` +
`created by @remix-run/router. The block will fail silently in ` +
`production, but in general you should do all navigation with the ` +
`router (instead of using window.history.pushState directly) ` +
`to avoid this situation.`
);
}
}

@@ -606,7 +642,8 @@ function getUrlBasedHistory(
let location = createLocation(history.location, to, state);
if (validateLocation) validateLocation(location, to);

let historyState = getHistoryState(location);
index = getIndex() + 1;
let historyState = getHistoryState(location, index);
let url = history.createHref(location);

// try...catch because iOS limits us to 100 pushState calls :/
@@ -619,7 +656,7 @@ function getUrlBasedHistory(
}

if (v5Compat && listener) {
listener({ action, location: history.location });
listener({ action, location: history.location, delta: 1 });
}
}

@@ -628,15 +665,33 @@ function getUrlBasedHistory(
let location = createLocation(history.location, to, state);
if (validateLocation) validateLocation(location, to);

let historyState = getHistoryState(location);
index = getIndex();
let historyState = getHistoryState(location, index);
let url = history.createHref(location);
globalHistory.replaceState(historyState, "", url);

if (v5Compat && listener) {
listener({ action, location: history.location });
listener({ action, location: history.location, delta: 0 });
}
}

function createURL(to: To): URL {
// window.location.origin is "null" (the literal string value) in Firefox
// under certain conditions, notably when serving from a local HTML file
// See https://bugzilla.mozilla.org/show_bug.cgi?id=878297
let base =
window.location.origin !== "null"
? window.location.origin
: window.location.href;

let href = typeof to === "string" ? to : createPath(to);
invariant(
base,
`No window.location.(origin|href) available to create URL for href: ${href}`
);
return new URL(href, base);
}

let history: History = {
get action() {
return action;
@@ -659,11 +714,10 @@ function getUrlBasedHistory(
createHref(to) {
return createHref(window, to);
},
createURL,
encodeLocation(to) {
// Encode a Location the same way window.location would
let url = createClientSideURL(
typeof to === "string" ? to : createPath(to)
);
let url = createURL(to);
return {
pathname: url.pathname,
search: url.search,
6 changes: 3 additions & 3 deletions packages/router/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { convertRoutesToDataRoutes, getPathContributingMatches } from "./utils";

export type {
ActionFunction,
ActionFunctionArgs,
@@ -58,6 +56,7 @@ export type {
Path,
To,
} from "./history";

export {
Action,
createBrowserHistory,
@@ -79,6 +78,7 @@ export * from "./router";

/** @internal */
export {
DeferredData as UNSAFE_DeferredData,
convertRoutesToDataRoutes as UNSAFE_convertRoutesToDataRoutes,
getPathContributingMatches as UNSAFE_getPathContributingMatches,
};
} from "./utils";
2 changes: 1 addition & 1 deletion packages/router/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@remix-run/router",
"version": "1.2.1",
"version": "1.3.0",
"description": "Nested/Data-driven/Framework-agnostic Routing",
"keywords": [
"remix",
411 changes: 361 additions & 50 deletions packages/router/router.ts

Large diffs are not rendered by default.

132 changes: 89 additions & 43 deletions packages/router/utils.ts
Original file line number Diff line number Diff line change
@@ -31,6 +31,8 @@ export interface SuccessResult {
export interface DeferredResult {
type: ResultType.deferred;
deferredData: DeferredData;
statusCode?: number;
headers?: Headers;
}

/**
@@ -198,7 +200,9 @@ type _PathParam<Path extends string> =
? _PathParam<L> | _PathParam<R>
: // find params after `:`
Path extends `:${infer Param}`
? Param
? Param extends `${infer Optional}?`
? Optional
: Param
: // otherwise, there aren't any params present
never;

@@ -614,7 +618,7 @@ function matchRouteBranch<
export function generatePath<Path extends string>(
originalPath: Path,
params: {
[key in PathParam<Path>]: string;
[key in PathParam<Path>]: string | null;
} = {} as any
): string {
let path = originalPath;
@@ -629,27 +633,49 @@ export function generatePath<Path extends string>(
path = path.replace(/\*$/, "/*") as Path;
}

return path
.replace(/^:(\w+)/g, (_, key: PathParam<Path>) => {
invariant(params[key] != null, `Missing ":${key}" param`);
return params[key]!;
})
.replace(/\/:(\w+)/g, (_, key: PathParam<Path>) => {
invariant(params[key] != null, `Missing ":${key}" param`);
return `/${params[key]!}`;
})
.replace(/(\/?)\*/, (_, prefix, __, str) => {
const star = "*" as PathParam<Path>;

if (params[star] == null) {
// If no splat was provided, trim the trailing slash _unless_ it's
// the entire path
return str === "/*" ? "/" : "";
}

// Apply the splat
return `${prefix}${params[star]}`;
});
return (
path
.replace(
/^:(\w+)(\??)/g,
(_, key: PathParam<Path>, optional: string | undefined) => {
let param = params[key];
if (optional === "?") {
return param == null ? "" : param;
}
if (param == null) {
invariant(false, `Missing ":${key}" param`);
}
return param;
}
)
.replace(
/\/:(\w+)(\??)/g,
(_, key: PathParam<Path>, optional: string | undefined) => {
let param = params[key];
if (optional === "?") {
return param == null ? "" : `/${param}`;
}
if (param == null) {
invariant(false, `Missing ":${key}" param`);
}
return `/${param}`;
}
)
// Remove any optional markers from optional static segments
.replace(/\?/g, "")
.replace(/(\/?)\*/, (_, prefix, __, str) => {
const star = "*" as PathParam<Path>;

if (params[star] == null) {
// If no splat was provided, trim the trailing slash _unless_ it's
// the entire path
return str === "/*" ? "/" : "";
}

// Apply the splat
return `${prefix}${params[star]}`;
})
);
}

/**
@@ -874,7 +900,7 @@ export function warning(cond: any, message: string): void {
if (typeof console !== "undefined") console.warn(message);

try {
// Welcome to debugging React Router!
// Welcome to debugging @remix-run/router!
//
// This error is thrown as a convenience so you can more easily
// find the source for a warning that appears in the console by
@@ -1131,14 +1157,17 @@ export interface TrackedPromise extends Promise<any> {
export class AbortedDeferredError extends Error {}

export class DeferredData {
private pendingKeys: Set<string | number> = new Set<string | number>();
private pendingKeysSet: Set<string> = new Set<string>();
private controller: AbortController;
private abortPromise: Promise<void>;
private unlistenAbortSignal: () => void;
private subscriber?: (aborted: boolean) => void = undefined;
private subscribers: Set<(aborted: boolean, settledKey?: string) => void> =
new Set();
data: Record<string, unknown>;
init?: ResponseInit;
deferredKeys: string[] = [];

constructor(data: Record<string, unknown>) {
constructor(data: Record<string, unknown>, responseInit?: ResponseInit) {
invariant(
data && typeof data === "object" && !Array.isArray(data),
"defer() only accepts plain objects"
@@ -1162,17 +1191,20 @@ export class DeferredData {
}),
{}
);

this.init = responseInit;
}

private trackPromise(
key: string | number,
key: string,
value: Promise<unknown> | unknown
): TrackedPromise | unknown {
if (!(value instanceof Promise)) {
return value;
}

this.pendingKeys.add(key);
this.deferredKeys.push(key);
this.pendingKeysSet.add(key);

// We store a little wrapper promise that will be extended with
// _data/_error props upon resolve/reject
@@ -1191,7 +1223,7 @@ export class DeferredData {

private onSettle(
promise: TrackedPromise,
key: string | number,
key: string,
error: unknown,
data?: unknown
): unknown {
@@ -1204,34 +1236,37 @@ export class DeferredData {
return Promise.reject(error);
}

this.pendingKeys.delete(key);
this.pendingKeysSet.delete(key);

if (this.done) {
// Nothing left to abort!
this.unlistenAbortSignal();
}

const subscriber = this.subscriber;
if (error) {
Object.defineProperty(promise, "_error", { get: () => error });
subscriber && subscriber(false);
this.emit(false, key);
return Promise.reject(error);
}

Object.defineProperty(promise, "_data", { get: () => data });
subscriber && subscriber(false);
this.emit(false, key);
return data;
}

subscribe(fn: (aborted: boolean) => void) {
this.subscriber = fn;
private emit(aborted: boolean, settledKey?: string) {
this.subscribers.forEach((subscriber) => subscriber(aborted, settledKey));
}

subscribe(fn: (aborted: boolean, settledKey?: string) => void) {
this.subscribers.add(fn);
return () => this.subscribers.delete(fn);
}

cancel() {
this.controller.abort();
this.pendingKeys.forEach((v, k) => this.pendingKeys.delete(k));
let subscriber = this.subscriber;
subscriber && subscriber(true);
this.pendingKeysSet.forEach((v, k) => this.pendingKeysSet.delete(k));
this.emit(true);
}

async resolveData(signal: AbortSignal) {
@@ -1252,7 +1287,7 @@ export class DeferredData {
}

get done() {
return this.pendingKeys.size === 0;
return this.pendingKeysSet.size === 0;
}

get unwrappedData() {
@@ -1269,6 +1304,10 @@ export class DeferredData {
{}
);
}

get pendingKeys() {
return Array.from(this.pendingKeysSet);
}
}

function isTrackedPromise(value: any): value is TrackedPromise {
@@ -1288,9 +1327,16 @@ function unwrapTrackedPromise(value: any) {
return value._data;
}

export function defer(data: Record<string, unknown>) {
return new DeferredData(data);
}
export type DeferFunction = (
data: Record<string, unknown>,
init?: number | ResponseInit
) => DeferredData;

export const defer: DeferFunction = (data, init = {}) => {
let responseInit = typeof init === "number" ? { status: init } : init;

return new DeferredData(data, responseInit);
};

export type RedirectFunction = (
url: string,
10 changes: 8 additions & 2 deletions rollup.config.js
Original file line number Diff line number Diff line change
@@ -2,9 +2,15 @@ const fs = require("fs");
const path = require("path");

module.exports = function rollup(options) {
return fs
.readdirSync("packages")
return [
"router",
"react-router",
"react-router-dom",
"react-router-dom-v5-compat",
"react-router-native",
]
.flatMap((dir) => {
// if (dir !== "router") return null;
let configPath = path.join("packages", dir, "rollup.config.js");
try {
fs.readFileSync(configPath);
41 changes: 15 additions & 26 deletions scripts/release/comment.ts
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ import {
OWNER,
REPO,
PR_FILES_STARTS_WITH,
IS_NIGHTLY_RELEASE,
IS_STABLE_RELEASE,
AWAITING_RELEASE_LABEL,
} from "./constants";
import {
@@ -53,7 +53,7 @@ async function commentOnIssuesAndPrsAboutRelease() {
let prLabels = pr.labels.map((label) => label.name);
let prIsAwaitingRelease = prLabels.includes(AWAITING_RELEASE_LABEL);

if (!IS_NIGHTLY_RELEASE && prIsAwaitingRelease) {
if (IS_STABLE_RELEASE && prIsAwaitingRelease) {
promises.push(
removeLabel({ owner: OWNER, repo: REPO, issue: pr.number })
);
@@ -73,27 +73,19 @@ async function commentOnIssuesAndPrsAboutRelease() {

issuesCommentedOn.add(issue.number);
let issueUrl = getGitHubUrl("issue", issue.number);
console.log(`commenting on issue ${issueUrl}`);

if (IS_NIGHTLY_RELEASE || !prIsAwaitingRelease) {
console.log(`commenting on ${issueUrl}`);
promises.push(
commentOnIssue({
owner: OWNER,
repo: REPO,
issue: issue.number,
version: VERSION,
})
);
} else {
console.log(`commenting on and closing ${issueUrl}`);
promises.push(
commentOnIssue({
owner: OWNER,
repo: REPO,
issue: issue.number,
version: VERSION,
})
);
promises.push(
commentOnIssue({
owner: OWNER,
repo: REPO,
issue: issue.number,
version: VERSION,
})
);

if (IS_STABLE_RELEASE) {
console.log(`closing issue ${issueUrl}`);
promises.push(
closeIssue({ owner: OWNER, repo: REPO, issue: issue.number })
);
@@ -104,10 +96,7 @@ async function commentOnIssuesAndPrsAboutRelease() {
let result = await Promise.allSettled(promises);
let rejected = result.filter((r) => r.status === "rejected");
if (rejected.length > 0) {
console.log(
"🚨 failed to comment on some issues/prs - the most likely reason is they were issues that were turned into discussions, which don't have an api to comment with"
);
console.log(rejected);
console.error("🚨 failed to comment on some issues/prs", rejected);
}
}

3 changes: 2 additions & 1 deletion scripts/release/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cleanupRef, cleanupTagName, isNightly } from "./utils";
import { cleanupRef, cleanupTagName, isNightly, isStable } from "./utils";

if (!process.env.DEFAULT_BRANCH) {
throw new Error("DEFAULT_BRANCH is required");
@@ -32,3 +32,4 @@ export const NIGHTLY_BRANCH = process.env.NIGHTLY_BRANCH;
export const PR_FILES_STARTS_WITH = ["packages/"];
export const IS_NIGHTLY_RELEASE = isNightly(VERSION);
export const AWAITING_RELEASE_LABEL = "awaiting release";
export const IS_STABLE_RELEASE = isStable(VERSION);
38 changes: 38 additions & 0 deletions scripts/release/find-release-from-changeset.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
*
* @param {string | undefined} publishedPackages
* @param {string | undefined} packageVersionToFollow
* @returns {string | undefined}
*/
function findReleaseFromChangeset(publishedPackages, packageVersionToFollow) {
if (!publishedPackages) {
throw new Error("No published packages found");
}

let packages = JSON.parse(publishedPackages);

if (!Array.isArray(packages)) {
throw new Error("Published packages is not an array");
}

/** @see https://github.com/changesets/action#outputs */
/** @type { { name: string; version: string }[] } */
let typed = packages.filter((pkg) => "name" in pkg && "version" in pkg);

let found = typed.find((pkg) => pkg.name === packageVersionToFollow);

if (!found) {
throw new Error(
`${packageVersionToFollow} was not found in the published packages`
);
}

let result = `${found.name}@${found.version}`;
console.log(result);
return result;
}

findReleaseFromChangeset(
process.env.publishedPackages,
process.env.packageVersionToFollow
);
70 changes: 48 additions & 22 deletions scripts/release/github.ts
Original file line number Diff line number Diff line change
@@ -7,9 +7,12 @@ import {
DEFAULT_BRANCH,
PACKAGE_VERSION_TO_FOLLOW,
AWAITING_RELEASE_LABEL,
IS_NIGHTLY_RELEASE,
IS_STABLE_RELEASE,
} from "./constants";
import { gql, graphqlWithAuth, octokit } from "./octokit";
import type { MinimalTag } from "./utils";
import { isNightly, isStable } from "./utils";
import { cleanupTagName } from "./utils";
import { checkIfStringStartsWith } from "./utils";

@@ -140,34 +143,32 @@ function getPreviousTagFromCurrentTag(

return { tag: tagName, date, isPrerelease };
})
.filter((v: any): v is MinimalTag => typeof v !== "undefined");
.filter((v: any): v is MinimalTag => typeof v !== "undefined")
.filter((tag) => {
if (IS_STABLE_RELEASE) return isStable(tag.tag);
let isNightlyTag = isNightly(tag.tag);
if (IS_NIGHTLY_RELEASE) return isNightlyTag;
return !isNightlyTag;
})
.sort((a, b) => {
if (IS_NIGHTLY_RELEASE) {
return b.date.getTime() - a.date.getTime();
}

return semver.rcompare(a.tag, b.tag);
});

let currentTagIndex = validTags.findIndex((tag) => tag.tag === currentTag);
let currentTagInfo: MinimalTag | undefined = validTags.at(currentTagIndex);
let previousTagInfo: MinimalTag | undefined;

if (!currentTagInfo) {
throw new Error(`Could not find last tag ${currentTag}`);
}

// if the currentTag was a stable tag, then we want to find the previous stable tag
if (!currentTagInfo.isPrerelease) {
validTags = validTags
.filter((tag) => !tag.isPrerelease)
.sort((a, b) => semver.rcompare(a.tag, b.tag));

currentTagIndex = validTags.findIndex((tag) => tag.tag === currentTag);
currentTagInfo = validTags.at(currentTagIndex);
if (!currentTagInfo) {
throw new Error(`Could not find last stable tag ${currentTag}`);
}
throw new Error(`Could not find tag ${currentTag}`);
}

previousTagInfo = validTags.at(currentTagIndex + 1);
if (!previousTagInfo) {
throw new Error(
`Could not find previous prerelease tag from ${currentTag}`
);
throw new Error(`Could not find previous tag from ${currentTag}`);
}

return {
@@ -232,21 +233,35 @@ interface GitHubGraphqlTag {
interface GitHubGraphqlTagResponse {
repository: {
refs: {
pageInfo: {
hasNextPage: boolean;
endCursor: string;
};
nodes: Array<GitHubGraphqlTag>;
};
};
}

async function getTags(owner: string, repo: string) {
async function getTags(
owner: string,
repo: string,
endCursor?: string,
nodes: Array<GitHubGraphqlTag> = []
): Promise<GitHubGraphqlTag[]> {
let response: GitHubGraphqlTagResponse = await graphqlWithAuth(
gql`
query GET_TAGS($owner: String!, $repo: String!) {
query GET_TAGS($owner: String!, $repo: String!, $endCursor: String) {
repository(owner: $owner, name: $repo) {
refs(
refPrefix: "refs/tags/"
first: 100
orderBy: { field: TAG_COMMIT_DATE, direction: DESC }
after: $endCursor
) {
pageInfo {
hasNextPage
endCursor
}
nodes {
name
target {
@@ -267,15 +282,26 @@ async function getTags(owner: string, repo: string) {
}
}
`,
{ owner, repo }
{ owner, repo, endCursor }
);

return response.repository.refs.nodes.filter((node) => {
let filtered = response.repository.refs.nodes.filter((node) => {
return (
node.name.startsWith(PACKAGE_VERSION_TO_FOLLOW) ||
node.name.startsWith("v0.0.0-nightly-")
);
});

if (response.repository.refs.pageInfo.hasNextPage) {
console.log("has next page", response.repository.refs.pageInfo.endCursor);

return getTags(owner, repo, response.repository.refs.pageInfo.endCursor, [
...nodes,
...filtered,
]);
}

return [...nodes, ...filtered];
}

export async function getIssuesClosedByPullRequests(
6 changes: 6 additions & 0 deletions scripts/release/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as semver from "semver";

import { GITHUB_REPOSITORY, PACKAGE_VERSION_TO_FOLLOW } from "./constants";

export function checkIfStringStartsWith(
@@ -34,3 +36,7 @@ export function cleanupRef(ref: string) {
export function isNightly(tagName: string) {
return tagName.startsWith("v0.0.0-nightly-");
}

export function isStable(tagName: string) {
return semver.prerelease(tagName) === null;
}