Skip to content

Commit c4e9607

Browse files
authored
Fix usage of Navigate in strict mode when using a data router (#10435)
1 parent 5e195ec commit c4e9607

File tree

4 files changed

+392
-12
lines changed

4 files changed

+392
-12
lines changed

.changeset/navigate-strict-mode.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
Fix usage of `<Navigate>` in strict mode when using a data router

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,10 @@
108108
"none": "45 kB"
109109
},
110110
"packages/react-router/dist/react-router.production.min.js": {
111-
"none": "13.1 kB"
111+
"none": "13.3 kB"
112112
},
113113
"packages/react-router/dist/umd/react-router.production.min.js": {
114-
"none": "15.4 kB"
114+
"none": "15.6 kB"
115115
},
116116
"packages/react-router-dom/dist/react-router-dom.production.min.js": {
117117
"none": "11.8 kB"

packages/react-router/__tests__/navigate-test.tsx

Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,371 @@ describe("<Navigate>", () => {
485485
</div>"
486486
`);
487487
});
488+
489+
it("handles sync relative navigations in StrictMode using a data router", async () => {
490+
const router = createMemoryRouter([
491+
{
492+
path: "/",
493+
children: [
494+
{
495+
index: true,
496+
// This is a relative navigation from the current location of /a.
497+
// Ensure we don't route from / -> /b -> /b/b
498+
Component: () => <Navigate to={"b"} replace />,
499+
},
500+
{
501+
path: "b",
502+
element: <h1>Page B</h1>,
503+
},
504+
],
505+
},
506+
]);
507+
508+
let { container } = render(
509+
<React.StrictMode>
510+
<RouterProvider router={router} />
511+
</React.StrictMode>
512+
);
513+
514+
await waitFor(() => screen.getByText("Page B"));
515+
516+
expect(getHtml(container)).toMatchInlineSnapshot(`
517+
"<div>
518+
<h1>
519+
Page B
520+
</h1>
521+
</div>"
522+
`);
523+
});
524+
525+
it("handles async relative navigations in StrictMode using a data router", async () => {
526+
const router = createMemoryRouter(
527+
[
528+
{
529+
path: "/a",
530+
children: [
531+
{
532+
index: true,
533+
// This is a relative navigation from the current location of /a.
534+
// Ensure we don't route from /a -> /a/b -> /a/b/b
535+
Component: () => <Navigate to={"b"} replace />,
536+
},
537+
{
538+
path: "b",
539+
async loader() {
540+
await new Promise((r) => setTimeout(r, 10));
541+
return null;
542+
},
543+
element: <h1>Page B</h1>,
544+
},
545+
],
546+
},
547+
],
548+
{ initialEntries: ["/a"] }
549+
);
550+
551+
let { container } = render(
552+
<React.StrictMode>
553+
<RouterProvider router={router} />
554+
</React.StrictMode>
555+
);
556+
557+
await waitFor(() => screen.getByText("Page B"));
558+
559+
expect(getHtml(container)).toMatchInlineSnapshot(`
560+
"<div>
561+
<h1>
562+
Page B
563+
</h1>
564+
</div>"
565+
`);
566+
});
567+
568+
it("handles setState in render in StrictMode using a data router (sync loader)", async () => {
569+
let renders: number[] = [];
570+
const router = createMemoryRouter([
571+
{
572+
path: "/",
573+
children: [
574+
{
575+
index: true,
576+
Component() {
577+
let [count, setCount] = React.useState(0);
578+
if (count === 0) {
579+
setCount(1);
580+
}
581+
return <Navigate to={"b"} replace state={{ count }} />;
582+
},
583+
},
584+
{
585+
path: "b",
586+
Component() {
587+
let { state } = useLocation() as { state: { count: number } };
588+
renders.push(state.count);
589+
return (
590+
<>
591+
<h1>Page B</h1>
592+
<p>{state.count}</p>
593+
</>
594+
);
595+
},
596+
},
597+
],
598+
},
599+
]);
600+
601+
let navigateSpy = jest.spyOn(router, "navigate");
602+
603+
let { container } = render(
604+
<React.StrictMode>
605+
<RouterProvider router={router} />
606+
</React.StrictMode>
607+
);
608+
609+
await waitFor(() => screen.getByText("Page B"));
610+
611+
expect(getHtml(container)).toMatchInlineSnapshot(`
612+
"<div>
613+
<h1>
614+
Page B
615+
</h1>
616+
<p>
617+
1
618+
</p>
619+
</div>"
620+
`);
621+
expect(navigateSpy).toHaveBeenCalledTimes(2);
622+
expect(navigateSpy.mock.calls[0]).toMatchObject([
623+
{ pathname: "/b" },
624+
{ state: { count: 1 } },
625+
]);
626+
expect(navigateSpy.mock.calls[1]).toMatchObject([
627+
{ pathname: "/b" },
628+
{ state: { count: 1 } },
629+
]);
630+
expect(renders).toEqual([1, 1]);
631+
});
632+
633+
it("handles setState in effect in StrictMode using a data router (sync loader)", async () => {
634+
let renders: number[] = [];
635+
const router = createMemoryRouter([
636+
{
637+
path: "/",
638+
children: [
639+
{
640+
index: true,
641+
Component() {
642+
// When state managed by react and changes during render, we'll
643+
// only "see" the value from the first pass through here in our
644+
// effects
645+
let [count, setCount] = React.useState(0);
646+
React.useEffect(() => {
647+
if (count === 0) {
648+
setCount(1);
649+
}
650+
}, [count]);
651+
return <Navigate to={"b"} replace state={{ count }} />;
652+
},
653+
},
654+
{
655+
path: "b",
656+
Component() {
657+
let { state } = useLocation() as { state: { count: number } };
658+
renders.push(state.count);
659+
return (
660+
<>
661+
<h1>Page B</h1>
662+
<p>{state.count}</p>
663+
</>
664+
);
665+
},
666+
},
667+
],
668+
},
669+
]);
670+
671+
let navigateSpy = jest.spyOn(router, "navigate");
672+
673+
let { container } = render(
674+
<React.StrictMode>
675+
<RouterProvider router={router} />
676+
</React.StrictMode>
677+
);
678+
679+
await waitFor(() => screen.getByText("Page B"));
680+
681+
expect(getHtml(container)).toMatchInlineSnapshot(`
682+
"<div>
683+
<h1>
684+
Page B
685+
</h1>
686+
<p>
687+
0
688+
</p>
689+
</div>"
690+
`);
691+
expect(navigateSpy).toHaveBeenCalledTimes(2);
692+
expect(navigateSpy.mock.calls[0]).toMatchObject([
693+
{ pathname: "/b" },
694+
{ state: { count: 0 } },
695+
]);
696+
expect(navigateSpy.mock.calls[1]).toMatchObject([
697+
{ pathname: "/b" },
698+
{ state: { count: 0 } },
699+
]);
700+
expect(renders).toEqual([0, 0]);
701+
});
702+
703+
it("handles setState in render in StrictMode using a data router (async loader)", async () => {
704+
let renders: number[] = [];
705+
const router = createMemoryRouter([
706+
{
707+
path: "/",
708+
children: [
709+
{
710+
index: true,
711+
Component() {
712+
let [count, setCount] = React.useState(0);
713+
if (count === 0) {
714+
setCount(1);
715+
}
716+
return <Navigate to={"b"} replace state={{ count }} />;
717+
},
718+
},
719+
{
720+
path: "b",
721+
async loader() {
722+
await new Promise((r) => setTimeout(r, 10));
723+
return null;
724+
},
725+
Component() {
726+
let { state } = useLocation() as { state: { count: number } };
727+
renders.push(state.count);
728+
return (
729+
<>
730+
<h1>Page B</h1>
731+
<p>{state.count}</p>
732+
</>
733+
);
734+
},
735+
},
736+
],
737+
},
738+
]);
739+
740+
let navigateSpy = jest.spyOn(router, "navigate");
741+
742+
let { container } = render(
743+
<React.StrictMode>
744+
<RouterProvider router={router} />
745+
</React.StrictMode>
746+
);
747+
748+
await waitFor(() => screen.getByText("Page B"));
749+
750+
expect(getHtml(container)).toMatchInlineSnapshot(`
751+
"<div>
752+
<h1>
753+
Page B
754+
</h1>
755+
<p>
756+
1
757+
</p>
758+
</div>"
759+
`);
760+
expect(navigateSpy).toHaveBeenCalledTimes(2);
761+
expect(navigateSpy.mock.calls[0]).toMatchObject([
762+
{ pathname: "/b" },
763+
{ state: { count: 1 } },
764+
]);
765+
expect(navigateSpy.mock.calls[1]).toMatchObject([
766+
{ pathname: "/b" },
767+
{ state: { count: 1 } },
768+
]);
769+
// /a/b rendered with the same state value both times
770+
expect(renders).toEqual([1, 1]);
771+
});
772+
773+
it("handles setState in effect in StrictMode using a data router (async loader)", async () => {
774+
let renders: number[] = [];
775+
const router = createMemoryRouter([
776+
{
777+
path: "/",
778+
children: [
779+
{
780+
index: true,
781+
Component() {
782+
// When state managed by react and changes during render, we'll
783+
// only "see" the value from the first pass through here in our
784+
// effects
785+
let [count, setCount] = React.useState(0);
786+
React.useEffect(() => {
787+
if (count === 0) {
788+
setCount(1);
789+
}
790+
}, [count]);
791+
return <Navigate to={"b"} replace state={{ count }} />;
792+
},
793+
},
794+
{
795+
path: "b",
796+
async loader() {
797+
await new Promise((r) => setTimeout(r, 10));
798+
return null;
799+
},
800+
Component() {
801+
let { state } = useLocation() as { state: { count: number } };
802+
renders.push(state.count);
803+
return (
804+
<>
805+
<h1>Page B</h1>
806+
<p>{state.count}</p>
807+
</>
808+
);
809+
},
810+
},
811+
],
812+
},
813+
]);
814+
815+
let navigateSpy = jest.spyOn(router, "navigate");
816+
817+
let { container } = render(
818+
<React.StrictMode>
819+
<RouterProvider router={router} />
820+
</React.StrictMode>
821+
);
822+
823+
await waitFor(() => screen.getByText("Page B"));
824+
825+
expect(getHtml(container)).toMatchInlineSnapshot(`
826+
"<div>
827+
<h1>
828+
Page B
829+
</h1>
830+
<p>
831+
1
832+
</p>
833+
</div>"
834+
`);
835+
expect(navigateSpy).toHaveBeenCalledTimes(3);
836+
expect(navigateSpy.mock.calls[0]).toMatchObject([
837+
{ pathname: "/b" },
838+
{ state: { count: 0 } },
839+
]);
840+
expect(navigateSpy.mock.calls[1]).toMatchObject([
841+
{ pathname: "/b" },
842+
{ state: { count: 0 } },
843+
]);
844+
// StrictMode only applies the double-effect execution on component mount,
845+
// not component update
846+
expect(navigateSpy.mock.calls[2]).toMatchObject([
847+
{ pathname: "/b" },
848+
{ state: { count: 1 } },
849+
]);
850+
// /a/b rendered with the latest state value both times
851+
expect(renders).toEqual([1, 1]);
852+
});
488853
});
489854

490855
function getHtml(container: HTMLElement) {

0 commit comments

Comments
 (0)