@@ -899,6 +899,301 @@ test.describe("Middleware", () => {
899
899
900
900
appFixture . close ( ) ;
901
901
} ) ;
902
+
903
+ test ( "calls clientMiddleware once when multiple server requests happen" , async ( {
904
+ page,
905
+ } ) => {
906
+ let fixture = await createFixture ( {
907
+ files : {
908
+ "react-router.config.ts" : reactRouterConfig ( { } ) ,
909
+ "vite.config.ts" : js `
910
+ import { defineConfig } from "vite";
911
+ import { reactRouter } from "@react-router/dev/vite";
912
+
913
+ export default defineConfig({
914
+ build: { manifest: true, minify: false },
915
+ plugins: [reactRouter()],
916
+ });
917
+ ` ,
918
+ "app/entry.client.tsx" : js `
919
+ import { HydratedRouter } from "react-router/dom";
920
+ import { startTransition, StrictMode } from "react";
921
+ import { hydrateRoot } from "react-dom/client";
922
+
923
+ startTransition(() => {
924
+ hydrateRoot(
925
+ document,
926
+ <StrictMode>
927
+ <HydratedRouter context={{
928
+ parent: { value: 0 },
929
+ child: { value: 0 }
930
+ }} />
931
+ </StrictMode>
932
+ );
933
+ });
934
+ ` ,
935
+ "app/routes/_index.tsx" : js `
936
+ import { Link } from 'react-router'
937
+ export default function Component({ loaderData }) {
938
+ return <Link to="/parent/child">Go to /parent/child</Link>;
939
+ }
940
+ ` ,
941
+ "app/routes/parent.tsx" : js `
942
+ import { Outlet } from 'react-router';
943
+ export function loader() {
944
+ return 'PARENT'
945
+ }
946
+ export const clientMiddleware = [
947
+ ({ context }) => { context.parent.value++ },
948
+ ];
949
+
950
+ export async function clientLoader({ serverLoader, context }) {
951
+ return {
952
+ serverData: await serverLoader(),
953
+ context
954
+ }
955
+ }
956
+
957
+ export default function Component({ loaderData }) {
958
+ return (
959
+ <>
960
+ <h2 data-parent>{JSON.stringify(loaderData)}</h2>
961
+ <Outlet/>
962
+ </>
963
+ );
964
+ }
965
+ ` ,
966
+ "app/routes/parent.child.tsx" : js `
967
+ export function loader() {
968
+ return 'CHILD'
969
+ }
970
+ export const clientMiddleware = [
971
+ ({ context }) => { context.child.value++ },
972
+ ];
973
+
974
+ export async function clientLoader({ serverLoader, context }) {
975
+ return {
976
+ serverData: await serverLoader(),
977
+ context
978
+ }
979
+ }
980
+
981
+ export default function Component({ loaderData }) {
982
+ return <h3 data-child>{JSON.stringify(loaderData)}</h3>;
983
+ }
984
+ ` ,
985
+ } ,
986
+ } ) ;
987
+
988
+ let appFixture = await createAppFixture ( fixture ) ;
989
+
990
+ let requests : string [ ] = [ ] ;
991
+ page . on ( "request" , ( request : PlaywrightRequest ) => {
992
+ if ( request . url ( ) . includes ( ".data" ) ) {
993
+ requests . push ( request . url ( ) ) ;
994
+ }
995
+ } ) ;
996
+
997
+ let app = new PlaywrightFixture ( appFixture , page ) ;
998
+ await app . goto ( "/" ) ;
999
+ ( await page . $ ( 'a[href="/parent/child"]' ) ) ?. click ( ) ;
1000
+ await page . waitForSelector ( "[data-child]" ) ;
1001
+
1002
+ // 2 separate server requests made
1003
+ expect ( requests ) . toEqual ( [
1004
+ expect . stringContaining ( "/parent/child.data?_routes=routes%2Fparent" ) ,
1005
+ expect . stringContaining (
1006
+ "/parent/child.data?_routes=routes%2Fparent.child"
1007
+ ) ,
1008
+ ] ) ;
1009
+
1010
+ // But client middlewares only ran once
1011
+ let json = ( await page . locator ( "[data-parent]" ) . textContent ( ) ) as string ;
1012
+ expect ( JSON . parse ( json ) ) . toEqual ( {
1013
+ serverData : "PARENT" ,
1014
+ context : {
1015
+ parent : { value : 1 } ,
1016
+ child : { value : 1 } ,
1017
+ } ,
1018
+ } ) ;
1019
+ json = ( await page . locator ( "[data-child]" ) . textContent ( ) ) as string ;
1020
+ expect ( JSON . parse ( json ) ) . toEqual ( {
1021
+ serverData : "CHILD" ,
1022
+ context : {
1023
+ parent : { value : 1 } ,
1024
+ child : { value : 1 } ,
1025
+ } ,
1026
+ } ) ;
1027
+
1028
+ appFixture . close ( ) ;
1029
+ } ) ;
1030
+
1031
+ test ( "calls clientMiddleware once when multiple server requests happen and some routes opt out" , async ( {
1032
+ page,
1033
+ } ) => {
1034
+ let fixture = await createFixture ( {
1035
+ files : {
1036
+ "react-router.config.ts" : reactRouterConfig ( { } ) ,
1037
+ "vite.config.ts" : js `
1038
+ import { defineConfig } from "vite";
1039
+ import { reactRouter } from "@react-router/dev/vite";
1040
+
1041
+ export default defineConfig({
1042
+ build: { manifest: true, minify: false },
1043
+ plugins: [reactRouter()],
1044
+ });
1045
+ ` ,
1046
+ "app/entry.client.tsx" : js `
1047
+ import { HydratedRouter } from "react-router/dom";
1048
+ import { startTransition, StrictMode } from "react";
1049
+ import { hydrateRoot } from "react-dom/client";
1050
+
1051
+ startTransition(() => {
1052
+ hydrateRoot(
1053
+ document,
1054
+ <StrictMode>
1055
+ <HydratedRouter context={{
1056
+ parent: { value: 0 },
1057
+ child: { value: 0 },
1058
+ index: { value: 0 }
1059
+ }} />
1060
+ </StrictMode>
1061
+ );
1062
+ });
1063
+ ` ,
1064
+ "app/routes/_index.tsx" : js `
1065
+ import { Link } from 'react-router'
1066
+ export default function Component({ loaderData }) {
1067
+ return <Link to="/parent/child">Go to /parent/child</Link>;
1068
+ }
1069
+ ` ,
1070
+ "app/routes/parent.tsx" : js `
1071
+ import { Outlet } from 'react-router';
1072
+ export function loader() {
1073
+ return 'PARENT'
1074
+ }
1075
+ export const clientMiddleware = [
1076
+ ({ context }) => { context.parent.value++ },
1077
+ ];
1078
+ export default function Component({ loaderData }) {
1079
+ return (
1080
+ <>
1081
+ <h2 data-parent>{loaderData}</h2>
1082
+ <Outlet/>
1083
+ </>
1084
+ );
1085
+ }
1086
+ export function shouldRevalidate() {
1087
+ return false;
1088
+ }
1089
+ ` ,
1090
+ "app/routes/parent.child.tsx" : js `
1091
+ import { Outlet } from 'react-router';
1092
+ export function loader() {
1093
+ return 'CHILD'
1094
+ }
1095
+ export const clientMiddleware = [
1096
+ ({ context }) => { context.child.value++ },
1097
+ ];
1098
+ export default function Component({ loaderData }) {
1099
+ return (
1100
+ <>
1101
+ <h3 data-child>{loaderData}</h3>
1102
+ <Outlet/>
1103
+ </>
1104
+ );
1105
+ }
1106
+ ` ,
1107
+ "app/routes/parent.child._index.tsx" : js `
1108
+ import { Form } from 'react-router';
1109
+ export function action() {
1110
+ return 'INDEX ACTION'
1111
+ }
1112
+ export function loader() {
1113
+ return 'INDEX'
1114
+ }
1115
+ export const clientMiddleware = [
1116
+ ({ context }) => { context.index.value++ },
1117
+ ];
1118
+ export async function clientLoader({ serverLoader, context }) {
1119
+ return {
1120
+ serverData: await serverLoader(),
1121
+ context
1122
+ }
1123
+ }
1124
+ export default function Component({ loaderData, actionData }) {
1125
+ return (
1126
+ <>
1127
+ <h4 data-index>{JSON.stringify(loaderData)}</h4>
1128
+ <Form method="post">
1129
+ <button type="submit">Submit</button>
1130
+ </Form>
1131
+ {actionData ? <p data-action>{JSON.stringify(actionData)}</p> : null}
1132
+ </>
1133
+ );
1134
+ }
1135
+ ` ,
1136
+ } ,
1137
+ } ) ;
1138
+
1139
+ let appFixture = await createAppFixture ( fixture ) ;
1140
+
1141
+ let requests : string [ ] = [ ] ;
1142
+ page . on ( "request" , ( request : PlaywrightRequest ) => {
1143
+ if ( request . method ( ) === "GET" && request . url ( ) . includes ( ".data" ) ) {
1144
+ requests . push ( request . url ( ) ) ;
1145
+ }
1146
+ } ) ;
1147
+
1148
+ let app = new PlaywrightFixture ( appFixture , page ) ;
1149
+ await app . goto ( "/" ) ;
1150
+ ( await page . $ ( 'a[href="/parent/child"]' ) ) ?. click ( ) ;
1151
+ await page . waitForSelector ( "[data-child]" ) ;
1152
+ expect ( await page . locator ( "[data-parent]" ) . textContent ( ) ) . toBe ( "PARENT" ) ;
1153
+ expect ( await page . locator ( "[data-child]" ) . textContent ( ) ) . toBe ( "CHILD" ) ;
1154
+ expect (
1155
+ JSON . parse ( ( await page . locator ( "[data-index]" ) . textContent ( ) ) ! )
1156
+ ) . toEqual ( {
1157
+ serverData : "INDEX" ,
1158
+ context : {
1159
+ parent : { value : 1 } ,
1160
+ child : { value : 1 } ,
1161
+ index : { value : 1 } ,
1162
+ } ,
1163
+ } ) ;
1164
+
1165
+ requests = [ ] ; // clear before form submission
1166
+ ( await page . $ ( 'button[type="submit"]' ) ) ?. click ( ) ;
1167
+ await page . waitForSelector ( "[data-action]" ) ;
1168
+
1169
+ // 2 separate server requests made
1170
+ expect ( requests ) . toEqual ( [
1171
+ // index gets it's own due to clientLoader
1172
+ expect . stringMatching (
1173
+ / \/ p a r e n t \/ c h i l d \. d a t a \? _ r o u t e s = r o u t e s % 2 F p a r e n t \. c h i l d \. _ i n d e x $ /
1174
+ ) ,
1175
+ // This is the normal request but only included parent.child because parent opted out
1176
+ expect . stringMatching (
1177
+ / \/ p a r e n t \/ c h i l d \. d a t a \? _ r o u t e s = r o u t e s % 2 F p a r e n t \. c h i l d $ /
1178
+ ) ,
1179
+ ] ) ;
1180
+
1181
+ // But client middlewares only ran once for the action and once for the revalidation
1182
+ expect ( await page . locator ( "[data-parent]" ) . textContent ( ) ) . toBe ( "PARENT" ) ;
1183
+ expect ( await page . locator ( "[data-child]" ) . textContent ( ) ) . toBe ( "CHILD" ) ;
1184
+ expect (
1185
+ JSON . parse ( ( await page . locator ( "[data-index]" ) . textContent ( ) ) ! )
1186
+ ) . toEqual ( {
1187
+ serverData : "INDEX" ,
1188
+ context : {
1189
+ parent : { value : 3 } ,
1190
+ child : { value : 3 } ,
1191
+ index : { value : 3 } ,
1192
+ } ,
1193
+ } ) ;
1194
+
1195
+ appFixture . close ( ) ;
1196
+ } ) ;
902
1197
} ) ;
903
1198
904
1199
test . describe ( "Server Middleware" , ( ) => {
@@ -1529,6 +1824,79 @@ test.describe("Middleware", () => {
1529
1824
appFixture . close ( ) ;
1530
1825
} ) ;
1531
1826
1827
+ test ( "bubbles errors on the way down up to at least the highest route with a loader" , async ( {
1828
+ page,
1829
+ } ) => {
1830
+ let fixture = await createFixture (
1831
+ {
1832
+ files : {
1833
+ "vite.config.ts" : js `
1834
+ import { defineConfig } from "vite";
1835
+ import { reactRouter } from "@react-router/dev/vite";
1836
+
1837
+ export default defineConfig({
1838
+ build: { manifest: true, minify: false },
1839
+ plugins: [reactRouter()],
1840
+ });
1841
+ ` ,
1842
+ "app/routes/_index.tsx" : js `
1843
+ import { Link } from 'react-router'
1844
+ export default function Component({ loaderData }) {
1845
+ return <Link to="/a/b">Link</Link>
1846
+ }
1847
+ ` ,
1848
+ "app/routes/a.tsx" : js `
1849
+ import { Outlet } from 'react-router'
1850
+ export default function Component() {
1851
+ return <Outlet/>
1852
+ }
1853
+ export function ErrorBoundary({ error }) {
1854
+ return <><h1>A Error Boundary</h1><pre>{error.message}</pre></>
1855
+ }
1856
+ ` ,
1857
+ "app/routes/a.b.tsx" : js `
1858
+ import { Outlet } from 'react-router'
1859
+ export function loader() {
1860
+ return null;
1861
+ }
1862
+ export default function Component() {
1863
+ return <Outlet/>
1864
+ }
1865
+ ` ,
1866
+ "app/routes/a.b.c.tsx" : js `
1867
+ import { Outlet } from 'react-router'
1868
+ export default function Component() {
1869
+ return <Outlet/>
1870
+ }
1871
+ export function ErrorBoundary({ error }) {
1872
+ return <><h1>C Error Boundary</h1><pre>{error.message}</pre></>
1873
+ }
1874
+ ` ,
1875
+ "app/routes/a.b.c.d.tsx" : js `
1876
+ import { Outlet } from 'react-router'
1877
+ export const middleware = [() => { throw new Error("broken!") }]
1878
+ export default function Component() {
1879
+ return <Outlet/>
1880
+ }
1881
+ ` ,
1882
+ } ,
1883
+ } ,
1884
+ UNSAFE_ServerMode . Development
1885
+ ) ;
1886
+
1887
+ let appFixture = await createAppFixture (
1888
+ fixture ,
1889
+ UNSAFE_ServerMode . Development
1890
+ ) ;
1891
+
1892
+ let app = new PlaywrightFixture ( appFixture , page ) ;
1893
+ await app . goto ( "/a/b/c/d" ) ;
1894
+ expect ( await page . locator ( "h1" ) . textContent ( ) ) . toBe ( "A Error Boundary" ) ;
1895
+ expect ( await page . locator ( "pre" ) . textContent ( ) ) . toBe ( "broken!" ) ;
1896
+
1897
+ appFixture . close ( ) ;
1898
+ } ) ;
1899
+
1532
1900
test ( "only calls middleware as deep as needed for granular data requests" , async ( {
1533
1901
page,
1534
1902
} ) => {
0 commit comments