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

Add fetcher data layer #10961

Merged
merged 3 commits into from
Oct 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/fetcher-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router-dom": patch
---

Adds a fetcher context to `RouterProvider` that holds completed fetcher data, in preparation for the upcoming future flag that will change the fetcher persistence/cleanup behavior
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,10 @@
"none": "16.3 kB"
},
"packages/react-router-dom/dist/react-router-dom.production.min.js": {
"none": "15.9 kB"
"none": "16.5 kB"
},
"packages/react-router-dom/dist/umd/react-router-dom.production.min.js": {
"none": "22.1 kB"
"none": "22.7 kB"
}
}
}
144 changes: 110 additions & 34 deletions packages/react-router-dom/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,20 @@ if (__DEV__) {

export { ViewTransitionContext as UNSAFE_ViewTransitionContext };

// TODO: (v7) Change the useFetcher data from `any` to `unknown`
type FetchersContextObject = {
fetcherData: Map<string, any>;
register: (key: string) => void;
unregister: (key: string) => void;
};
Comment on lines +353 to +357
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fetcher data lives in the React layer now, and we use ref counting via register/unregister to know when to remove from fetcherData


const FetchersContext = React.createContext<FetchersContextObject | null>(null);
if (__DEV__) {
FetchersContext.displayName = "Fetchers";
}

export { FetchersContext as UNSAFE_FetchersContext };

//#endregion

////////////////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -427,6 +441,7 @@ export function RouterProvider({
router,
future,
}: RouterProviderProps): React.ReactElement {
let { fetcherContext, fetcherData } = useFetcherDataLayer();
let [state, setStateImpl] = React.useState(router.state);
let [pendingState, setPendingState] = React.useState<RouterState>();
let [vtContext, setVtContext] = React.useState<ViewTransitionContextObject>({
Expand Down Expand Up @@ -457,6 +472,12 @@ export function RouterProvider({
newState: RouterState,
{ unstable_viewTransitionOpts: viewTransitionOpts }
) => {
newState.fetchers.forEach((fetcher, key) => {
if (fetcher.data !== undefined) {
fetcherData.current.set(key, fetcher.data);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fetcher data hand-off from the router to the React data layer

}
});

if (
!viewTransitionOpts ||
router.window == null ||
Expand Down Expand Up @@ -484,7 +505,7 @@ export function RouterProvider({
});
}
},
[optInStartTransition, transition, renderDfd, router.window]
[router.window, transition, renderDfd, fetcherData, optInStartTransition]
);

// Need to use a layout effect here so we are subscribed early enough to
Expand Down Expand Up @@ -587,20 +608,22 @@ export function RouterProvider({
<>
<DataRouterContext.Provider value={dataRouterContext}>
<DataRouterStateContext.Provider value={state}>
<ViewTransitionContext.Provider value={vtContext}>
<Router
basename={basename}
location={state.location}
navigationType={state.historyAction}
navigator={navigator}
>
{state.initialized ? (
<DataRoutes routes={router.routes} state={state} />
) : (
fallbackElement
)}
</Router>
</ViewTransitionContext.Provider>
<FetchersContext.Provider value={fetcherContext}>
<ViewTransitionContext.Provider value={vtContext}>
<Router
basename={basename}
location={state.location}
navigationType={state.historyAction}
navigator={navigator}
>
{state.initialized ? (
<DataRoutes routes={router.routes} state={state} />
) : (
fallbackElement
)}
</Router>
</ViewTransitionContext.Provider>
</FetchersContext.Provider>
</DataRouterStateContext.Provider>
</DataRouterContext.Provider>
{null}
Expand Down Expand Up @@ -1198,6 +1221,8 @@ enum DataRouterStateHook {
UseScrollRestoration = "useScrollRestoration",
}

// Internal hooks

function getDataRouterConsoleError(
hookName: DataRouterHook | DataRouterStateHook
) {
Expand All @@ -1216,6 +1241,49 @@ function useDataRouterState(hookName: DataRouterStateHook) {
return state;
}

function useFetcherDataLayer() {
let fetcherRefs = React.useRef<Map<string, number>>(new Map());
let fetcherData = React.useRef<Map<string, any>>(new Map());
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think we're OK using a ref here but I need to bang on some edge cases to be sure - all tests and such pass as-is which is reassuring. The idea is that anytime a fetcher.data needs to update - a fetcher will have changed state which will be what triggers the re-render, and we don't need useState version of fetcherData.


let registerFetcher = React.useCallback(
(key: string) => {
let count = fetcherRefs.current.get(key);
if (count == null) {
fetcherRefs.current.set(key, 1);
} else {
fetcherRefs.current.set(key, count + 1);
}
},
[fetcherRefs]
);

let unregisterFetcher = React.useCallback(
(key: string) => {
let count = fetcherRefs.current.get(key);
if (count == null || count <= 1) {
fetcherRefs.current.delete(key);
fetcherData.current.delete(key);
} else {
fetcherRefs.current.set(key, count - 1);
}
},
[fetcherData, fetcherRefs]
);

let fetcherContext = React.useMemo<FetchersContextObject>(
() => ({
fetcherData: fetcherData.current,
register: registerFetcher,
unregister: unregisterFetcher,
}),
[fetcherData, registerFetcher, unregisterFetcher]
);

return { fetcherContext, fetcherData };
}

// External hooks

/**
* Handles the click behavior for router `<Link>` components. This is useful if
* you need to create custom `<Link>` components with the same click behavior we
Expand Down Expand Up @@ -1499,20 +1567,41 @@ export function useFetcher<TData = any>({
key,
}: { key?: string } = {}): FetcherWithComponents<TData> {
let { router } = useDataRouterContext(DataRouterHook.UseFetcher);
let fetchersContext = React.useContext(FetchersContext);
let route = React.useContext(RouteContext);
invariant(route, `useFetcher must be used inside a RouteContext`);

let routeId = route.matches[route.matches.length - 1]?.route.id;

invariant(
fetchersContext,
`useFetcher must be used inside a FetchersContext`
);
invariant(route, `useFetcher must be used inside a RouteContext`);
invariant(
routeId != null,
`useFetcher can only be used on routes that contain a unique "id"`
);

// Fetcher key handling
let [fetcherKey, setFetcherKey] = React.useState<string>(key || "");
if (!fetcherKey) {
setFetcherKey(getUniqueFetcherId());
}

// Registration/cleanup
let { fetcherData, register, unregister } = fetchersContext;
React.useEffect(() => {
register(fetcherKey);
return () => {
unregister(fetcherKey);
if (!router) {
console.warn(`No router available to clean up from useFetcher()`);
return;
}
router.deleteFetcher(fetcherKey);
};
}, [router, fetcherKey, register, unregister]);

// Fetcher additions
let load = React.useCallback(
(href: string) => {
invariant(router, "No router available for fetcher.load()");
Expand All @@ -1521,8 +1610,6 @@ export function useFetcher<TData = any>({
},
[fetcherKey, routeId, router]
);

// Fetcher additions (submit)
let submitImpl = useSubmit();
let submit = React.useCallback<FetcherSubmitFunction>(
(target, opts) => {
Expand All @@ -1548,31 +1635,20 @@ export function useFetcher<TData = any>({
return FetcherForm;
}, [fetcherKey]);

// Exposed FetcherWithComponents
let fetcher = router.getFetcher<TData>(fetcherKey);

let data = fetcherData.get(fetcherKey);
let fetcherWithComponents = React.useMemo(
() => ({
Form: FetcherForm,
submit,
load,
...fetcher,
data,
}),
[fetcher, FetcherForm, submit, load]
[FetcherForm, submit, load, fetcher, data]
);

React.useEffect(() => {
// Is this busted when the React team gets real weird and calls effects
// twice on mount? We really just need to garbage collect here when this
// fetcher is no longer around.
return () => {
if (!router) {
console.warn(`No router available to clean up from useFetcher()`);
return;
}
router.deleteFetcher(fetcherKey);
};
}, [router, fetcherKey]);

return fetcherWithComponents;
}

Expand Down
31 changes: 20 additions & 11 deletions packages/react-router-dom/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
Router,
UNSAFE_DataRouterContext as DataRouterContext,
UNSAFE_DataRouterStateContext as DataRouterStateContext,
UNSAFE_FetchersContext as FetchersContext,
UNSAFE_ViewTransitionContext as ViewTransitionContext,
} from "react-router-dom";

Expand Down Expand Up @@ -132,17 +133,25 @@ export function StaticRouterProvider({
<>
<DataRouterContext.Provider value={dataRouterContext}>
<DataRouterStateContext.Provider value={state}>
<ViewTransitionContext.Provider value={{ isTransitioning: false }}>
<Router
basename={dataRouterContext.basename}
location={state.location}
navigationType={state.historyAction}
navigator={dataRouterContext.navigator}
static={dataRouterContext.static}
>
<DataRoutes routes={router.routes} state={state} />
</Router>
</ViewTransitionContext.Provider>
<FetchersContext.Provider
value={{
fetcherData: new Map<string, any>(),
register: () => {},
unregister: () => {},
}}
>
<ViewTransitionContext.Provider value={{ isTransitioning: false }}>
<Router
basename={dataRouterContext.basename}
location={state.location}
navigationType={state.historyAction}
navigator={dataRouterContext.navigator}
static={dataRouterContext.static}
>
<DataRoutes routes={router.routes} state={state} />
</Router>
</ViewTransitionContext.Provider>
</FetchersContext.Provider>
</DataRouterStateContext.Provider>
</DataRouterContext.Provider>
{hydrateScript ? (
Expand Down