Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Stabilize useNavigate/useSubmit/fetcher.submit for data routers #10336

Merged
merged 22 commits into from
Apr 14, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/react-router-dom-v5-compat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export {
unstable_usePrompt,
useRevalidator,
useRouteError,
useRouteId,
useRouteLoaderData,
useSubmit,
UNSAFE_DataRouterContext,
Expand Down
75 changes: 75 additions & 0 deletions packages/react-router-dom/__tests__/data-browser-router-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
defer,
useLocation,
useMatches,
useSearchParams,
createRoutesFromElements,
} from "react-router-dom";

Expand Down Expand Up @@ -4411,6 +4412,80 @@
</div>"
`);
});

it("useFetcher is stable across across location changes", async () => {
let router = createBrowserRouter(
[
{
path: "/",
Component() {
const [, setSearchParams] = useSearchParams();
let [count, setCount] = React.useState(0);
let fetcherCount = React.useRef(0);
let fetcher = useFetcher();
React.useEffect(() => {
fetcherCount.current++;
}, [fetcher.submit]);
return (
<>
<button
onClick={() => {
setCount(count + 1);
setSearchParams({
random: Math.random().toString(),
});
}}
>
Click
</button>
<p>
{count}-{fetcherCount.current}
</p>
</>
);
},
},
],
{
window: getWindow("/"),
}
);

let { container } = render(<RouterProvider router={router} />);

expect(getHtml(container)).toMatchInlineSnapshot(`
"<div>
<button>
Click
</button>
<p>
0
-
0
</p>
</div>"
`);

fireEvent.click(screen.getByText("Click"));
fireEvent.click(screen.getByText("Click"));
fireEvent.click(screen.getByText("Click"));
fireEvent.click(screen.getByText("Click"));
fireEvent.click(screen.getByText("Click"));
await waitFor(() => screen.getByText(/5-1/));

expect(getHtml(container)).toMatchInlineSnapshot(`
"<div>
<button>
Click
</button>
<p>
5
-
1
</p>
</div>"
`);
});
});

describe("errors", () => {
Expand Down Expand Up @@ -5089,7 +5164,7 @@
});
}

async function waitForRouterInitialize(router) {

Check warning on line 5167 in packages/react-router-dom/__tests__/data-browser-router-test.tsx

View workflow job for this annotation

GitHub Actions / 🧪 Test

'waitForRouterInitialize' is defined but never used
return await new Promise((resolve) => {
let unsubscribe = router.subscribe((updatedState) => {
if (updatedState.initialized) {
Expand Down
48 changes: 31 additions & 17 deletions packages/react-router-dom/dom.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { FormEncType, HTMLFormMethod } from "@remix-run/router";
import type { RelativeRoutingType } from "react-router";
import type {
FormEncType,
HTMLFormMethod,
RelativeRoutingType,
} from "@remix-run/router";
import { stripBasename } from "@remix-run/router";

export const defaultMethod: HTMLFormMethod = "get";
const defaultEncType: FormEncType = "application/x-www-form-urlencoded";
Expand Down Expand Up @@ -157,16 +161,16 @@ export function getFormSubmissionInfo(
| URLSearchParams
| { [name: string]: string }
| null,
defaultAction: string,
options: SubmitOptions
options: SubmitOptions,
basename: string
): {
url: URL;
action: string | null;
method: string;
encType: string;
formData: FormData;
} {
let method: string;
let action: string;
let action: string | null = null;
let encType: string;
let formData: FormData;

Expand All @@ -175,8 +179,16 @@ export function getFormSubmissionInfo(
options as any
).submissionTrigger;

if (options.action) {
action = options.action;
} else {
// When grabbing the action from the element, it will have had the basename
// prefixed to ensure non-JS scenarios work, so strip it since we'll
// re-prefix in the router
let attr = target.getAttribute("action");
action = attr ? stripBasename(attr, basename) : null;
}
method = options.method || target.getAttribute("method") || defaultMethod;
Comment on lines +179 to +187
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few things going on here:

  • We now fallback to action:null if nothing is available, which lets the router handle differing behavior for a missing action on submission navigations via <Form>
  • We want our <form action>/<button formaction> values to contain the basename if it exists for non-JS, but since the router will handle prepending the basename now we strip it back off on our resolved action
  • If a user if providing an action in options it's not expected to have a basename (same as <Form action>)

action = options.action || target.getAttribute("action") || defaultAction;
encType =
options.encType || target.getAttribute("enctype") || defaultEncType;

Expand All @@ -200,16 +212,21 @@ export function getFormSubmissionInfo(

// <button>/<input type="submit"> may override attributes of <form>

if (options.action) {
action = options.action;
} else {
// When grabbing the action from the element, it will have had the basename
// prefixed to ensure non-JS scenarios work, so strip it since we'll
// re-prefix in the router
let attr = target.getAttribute("action") || form.getAttribute("action");
action = attr ? stripBasename(attr, basename) : null;
}

method =
options.method ||
target.getAttribute("formmethod") ||
form.getAttribute("method") ||
defaultMethod;
action =
options.action ||
target.getAttribute("formaction") ||
form.getAttribute("action") ||
defaultAction;
encType =
options.encType ||
target.getAttribute("formenctype") ||
Expand All @@ -230,7 +247,7 @@ export function getFormSubmissionInfo(
);
} else {
method = options.method || defaultMethod;
action = options.action || defaultAction;
action = options.action || null;
encType = options.encType || defaultEncType;

if (target instanceof FormData) {
Expand All @@ -250,8 +267,5 @@ export function getFormSubmissionInfo(
}
}

let { protocol, host } = window.location;
let url = new URL(action, `${protocol}//${host}`);

return { url, method: method.toLowerCase(), encType, formData };
return { action, method: method.toLowerCase(), encType, formData };
}
50 changes: 35 additions & 15 deletions packages/react-router-dom/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
useNavigate,
useNavigation,
useResolvedPath,
useRouteId,
unstable_useBlocker as useBlocker,
UNSAFE_DataRouterContext as DataRouterContext,
UNSAFE_DataRouterStateContext as DataRouterStateContext,
Expand Down Expand Up @@ -168,6 +169,7 @@ export {
useResolvedPath,
useRevalidator,
useRouteError,
useRouteId,
useRouteLoaderData,
useRoutes,
} from "react-router";
Expand Down Expand Up @@ -205,7 +207,7 @@ declare global {

interface DOMRouterOpts {
basename?: string;
future?: FutureConfig;
future?: Partial<Omit<FutureConfig, "v7_prependBasename">>;
hydrationData?: HydrationState;
window?: Window;
}
Expand All @@ -216,7 +218,10 @@ export function createBrowserRouter(
): RemixRouter {
return createRouter({
basename: opts?.basename,
future: opts?.future,
future: {
...opts?.future,
v7_prependBasename: true,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Always turn this on for RR - it used to be RR doing the prepending, now it's the router

},
history: createBrowserHistory({ window: opts?.window }),
hydrationData: opts?.hydrationData || parseHydrationData(),
routes,
Expand All @@ -230,7 +235,10 @@ export function createHashRouter(
): RemixRouter {
return createRouter({
basename: opts?.basename,
future: opts?.future,
future: {
...opts?.future,
v7_prependBasename: true,
},
history: createHashHistory({ window: opts?.window }),
hydrationData: opts?.hydrationData || parseHydrationData(),
routes,
Expand Down Expand Up @@ -946,9 +954,13 @@ export function useSubmit(): SubmitFunction {
return useSubmitImpl();
}

function useSubmitImpl(fetcherKey?: string, routeId?: string): SubmitFunction {
function useSubmitImpl(
fetcherKey?: string,
fetcherRouteId?: string
): SubmitFunction {
let { router } = useDataRouterContext(DataRouterHook.UseSubmitImpl);
let defaultAction = useFormAction();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what causes fetcher.submit to be unstable, since it depends on useLocation. Now we only depend on the current routeId and we let the router do the relative path resolving

let { basename } = React.useContext(NavigationContext);
let currentRouteId = useRouteId();

return React.useCallback(
(target, options = {}) => {
Expand All @@ -959,31 +971,39 @@ function useSubmitImpl(fetcherKey?: string, routeId?: string): SubmitFunction {
);
}

let { method, encType, formData, url } = getFormSubmissionInfo(
let { action, method, encType, formData } = getFormSubmissionInfo(
target,
defaultAction,
options
options,
basename
);

let href = url.pathname + url.search;
// Base options shared between fetch() and navigate()
let opts = {
replace: options.replace,
preventScrollReset: options.preventScrollReset,
formData,
formMethod: method as HTMLFormMethod,
formEncType: encType as FormEncType,
};

if (fetcherKey) {
invariant(routeId != null, "No routeId available for useFetcher()");
router.fetch(fetcherKey, routeId, href, opts);
invariant(
fetcherRouteId != null,
"No routeId available for useFetcher()"
);
router.fetch(fetcherKey, fetcherRouteId, action, opts);
} else {
router.navigate(href, opts);
router.navigate(action, {
...opts,
replace: options.replace,
fromRouteId: currentRouteId,
});
}
},
[defaultAction, router, fetcherKey, routeId]
[router, basename, fetcherKey, fetcherRouteId, currentRouteId]
);
}

// TODO: Deprecate entirely in favor of router.resolvePath()?
export function useFormAction(
action?: string,
{ relative }: { relative?: RelativeRoutingType } = {}
Expand Down Expand Up @@ -1116,7 +1136,7 @@ export function useFetcher<TData = any>(): FetcherWithComponents<TData> {
// fetcher is no longer around.
return () => {
if (!router) {
console.warn(`No fetcher available to clean up from useFetcher()`);
console.warn(`No router available to clean up from useFetcher()`);
return;
}
router.deleteFetcher(fetcherKey);
Expand Down
6 changes: 5 additions & 1 deletion packages/react-router-dom/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
StaticHandlerContext,
CreateStaticHandlerOptions as RouterCreateStaticHandlerOptions,
UNSAFE_RouteManifest as RouteManifest,
FutureConfig,
} from "@remix-run/router";
import {
IDLE_BLOCKER,
Expand Down Expand Up @@ -224,7 +225,10 @@ export function createStaticHandler(

export function createStaticRouter(
routes: RouteObject[],
context: StaticHandlerContext
context: StaticHandlerContext,
opts?: {
brophdawg11 marked this conversation as resolved.
Show resolved Hide resolved
future?: Partial<FutureConfig>;
}
): RemixRouter {
let manifest: RouteManifest = {};
let dataRoutes = convertRoutesToDataRoutes(
Expand Down
1 change: 1 addition & 0 deletions packages/react-router-native/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export {
useResolvedPath,
useRevalidator,
useRouteError,
useRouteId,
useRouteLoaderData,
useRoutes,
} from "react-router";
Expand Down
15 changes: 9 additions & 6 deletions packages/react-router/__tests__/data-memory-router-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3346,11 +3346,6 @@ function MemoryNavigate({
}) {
let dataRouterContext = React.useContext(DataRouterContext);

let basename = dataRouterContext?.basename;
if (basename && basename !== "/") {
to = to === "/" ? basename : joinPaths([basename, to]);
}

let onClickHandler = React.useCallback(
async (event: React.MouseEvent) => {
event.preventDefault();
Expand All @@ -3363,9 +3358,17 @@ function MemoryNavigate({
[dataRouterContext, to, formMethod, formData]
);

// Only prepend the basename to the rendered href, send the non-prefixed `to`
// value into the router since it will prepend the basename
let basename = dataRouterContext?.basename;
let href = to;
if (basename && basename !== "/") {
href = to === "/" ? basename : joinPaths([basename, to]);
}

return formData ? (
<form onClick={onClickHandler} children={children} />
) : (
<a href={to} onClick={onClickHandler} children={children} />
<a href={href} onClick={onClickHandler} children={children} />
);
}