diff --git a/.changeset/stupid-games-build.md b/.changeset/stupid-games-build.md new file mode 100644 index 00000000000..99770801029 --- /dev/null +++ b/.changeset/stupid-games-build.md @@ -0,0 +1,6 @@ +--- +"@remix-run/react": patch +"@remix-run/server-runtime": patch +--- + +Ignore pathless layout routes in action matches diff --git a/integration/form-test.ts b/integration/form-test.ts index 4db956db6b3..5b0c08cb7be 100644 --- a/integration/form-test.ts +++ b/integration/form-test.ts @@ -400,6 +400,47 @@ test.describe("Forms", () => { ) } `, + + "app/routes/pathless-layout-parent.jsx": js` + import { json } from '@remix-run/server-runtime' + import { Form, Outlet, useActionData } from '@remix-run/react' + + export async function action({ request }) { + return json({ submitted: true }); + } + export default function () { + let data = useActionData(); + return ( + <> +
+

Pathless Layout Parent

+ +
+ +

{data?.submitted === true ? 'Submitted - Yes' : 'Submitted - No'}

+ + ); + } + `, + + "app/routes/pathless-layout-parent/__pathless.jsx": js` + import { Outlet } from '@remix-run/react'; + + export default function () { + return ( + <> +

Pathless Layout

+ + + ); + } + `, + + "app/routes/pathless-layout-parent/__pathless/index.jsx": js` + export default function () { + return

Pathless Layout Index

+ } + `, }, }); @@ -942,4 +983,24 @@ test.describe("Forms", () => { ); } }); + + test("pathless layout routes are ignored in form actions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/pathless-layout-parent"); + let html = await app.getHtml(); + expect(html).toMatch("Pathless Layout Parent"); + expect(html).toMatch("Pathless Layout "); + expect(html).toMatch("Pathless Layout Index"); + + let el = getElement(html, `form`); + expect(el.attr("action")).toMatch("/pathless-layout-parent"); + + expect(await app.getHtml()).toMatch("Submitted - No"); + // This submission should ignore the index route and the pathless layout + // route above it and hit the action in routes/pathless-layout-parent.jsx + await app.clickSubmitButton("/pathless-layout-parent"); + expect(await app.getHtml()).toMatch("Submitted - Yes"); + }); }); diff --git a/packages/remix-react/transition.ts b/packages/remix-react/transition.ts index 5ef8949b916..9bafb79584b 100644 --- a/packages/remix-react/transition.ts +++ b/packages/remix-react/transition.ts @@ -590,7 +590,7 @@ export function createTransitionManager(init: TransitionManagerInit) { invariant(matches, "No matches found"); if (fetchControllers.has(key)) abortFetcher(key); - let match = getFetcherRequestMatch( + let match = getRequestMatch( new URL(href, window.location.href), matches ); @@ -644,17 +644,28 @@ export function createTransitionManager(init: TransitionManagerInit) { return false; } - function getFetcherRequestMatch( - url: URL, - matches: RouteMatch[] - ) { + // Return the correct single match for a route (used for submission + // navigations and and fetchers) + // - ?index should try to match the leaf index route + // - otherwise it should match the deepest "path contributing" match, which + // ignores index and pathless routes + function getRequestMatch(url: URL, matches: ClientMatch[]) { let match = matches.slice(-1)[0]; - if (!isIndexRequestUrl(url) && match.route.index) { - return matches.slice(-2)[0]; + if (isIndexRequestUrl(url) && match.route.index) { + return match; } - return match; + return getPathContributingMatches(matches).slice(-1)[0]; + } + + // Filter index and pathless routes when looking for submission targets + function getPathContributingMatches(matches: ClientMatch[]) { + return matches.filter( + (match, index) => + index === 0 || + (!match.route.index && match.route.path && match.route.path.length > 0) + ); } async function handleActionFetchSubmission( @@ -1071,14 +1082,10 @@ export function createTransitionManager(init: TransitionManagerInit) { // to run on layout/index routes. We do not want to mutate the eventual // matches used for revalidation let actionMatches = matches; - if ( - !isIndexRequestUrl(createUrl(submission.action)) && - actionMatches[matches.length - 1].route.index - ) { - actionMatches = actionMatches.slice(0, -1); - } - - let leafMatch = actionMatches.slice(-1)[0]; + let leafMatch = getRequestMatch( + createUrl(submission.action), + actionMatches + ); let result = await callAction(submission, leafMatch, controller.signal); if (controller.signal.aborted) { diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index f5ee5b200cd..edc3311f696 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -708,11 +708,19 @@ function isIndexRequestUrl(url: URL) { function getRequestMatch(url: URL, matches: RouteMatch[]) { let match = matches.slice(-1)[0]; - if (!isIndexRequestUrl(url) && match.route.id.endsWith("/index")) { - return matches.slice(-2)[0]; + if (isIndexRequestUrl(url) && match.route.id.endsWith("/index")) { + return match; } - return match; + return getPathContributingMatches(matches).slice(-1)[0]; +} + +function getPathContributingMatches(matches: RouteMatch[]) { + return matches.filter( + (match, index) => + index === 0 || + (!match.route.index && match.route.path && match.route.path.length > 0) + ); } function getDeepestRouteIdWithBoundary(