Forked from den-churbanov/react-router-effector-bind.ts
Created
January 31, 2024 10:10
-
-
Save yarastqt/050a30ec07b4ea78deaedfcc09414aa6 to your computer and use it in GitHub Desktop.
Effector bindings for react-router-dom v.6.2.1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useContext } from 'react'; | |
import { createStore, createEffect, createEvent, sample, attach, combine, restore } from 'effector'; | |
import { spread, debug, not, and, empty, or } from 'patronum'; | |
import { createGate, useGate } from 'effector-react'; | |
import { | |
useLocation, | |
useNavigate, | |
matchPath, | |
generatePath, | |
RouteMatch, | |
matchRoutes, | |
UNSAFE_NavigationContext as NavigationContext, | |
} from 'react-router-dom'; | |
import type { History, Transition } from 'history'; | |
import type { Event, Store } from 'effector'; | |
import type { NavigateFunction, Location } from 'react-router-dom'; | |
import { atom, toStore } from '@/shared/effector'; | |
import { routesConfig } from '@/router'; | |
import { parseRouteParams } from './utils'; | |
import type { | |
AppGateProps, | |
EffectorRoute, | |
RoutesMatchingFunction, | |
NavigateEventPayload, | |
NavigateFxPayload, | |
NavigateParams, | |
RouteNavigatePayload, | |
RouteParams, | |
RouteQuery | |
} from './types'; | |
const defaultMatchingFunction: RoutesMatchingFunction = (next, current) => next.pathname !== current.pathname; | |
const router = atom(() => { | |
const AppGate = createGate<AppGateProps>(`AppGate`); | |
const $navigate = createStore<NavigateFunction>(null); | |
const $history = createStore<History>(null); | |
const $pathname = createStore(''); | |
const $hash = createStore(''); | |
const $search = createStore(''); | |
const $state = createStore<any>(null); | |
const $key = createStore(''); | |
spread({ | |
source: AppGate.state, | |
targets: { | |
navigate: $navigate, | |
history: $history, | |
location: spread({ | |
pathname: $pathname, | |
hash: $hash, | |
search: $search, | |
state: $state, | |
key: $key | |
}) | |
} | |
}); | |
const $matches = createStore<RouteMatch[]>([]); | |
const $location = combine<Location>({ | |
pathname: $pathname, | |
hash: $hash, | |
search: $search, | |
state: $state, | |
key: $key | |
}); | |
const navigate = createEvent<NavigateEventPayload>(); | |
const navigateFx = attach({ | |
source: $navigate, | |
mapParams: (params: NavigateParams, navigate) => ({ ...params, navigate }), | |
effect: createEffect(({ navigate, to, ...options }: NavigateFxPayload) => navigate(to, options)) | |
}); | |
sample({ | |
clock: navigate, | |
fn: (payload): NavigateParams => typeof payload === 'string' ? { to: payload } : payload, | |
target: navigateFx | |
}); | |
sample({ | |
clock: $location, | |
fn: location => matchRoutes(routesConfig, location), | |
target: $matches | |
}); | |
return { | |
$history, | |
$location, | |
$matches, | |
navigate, | |
navigateFx, | |
useSetup() { | |
const history = useContext(NavigationContext)?.navigator as History; | |
const navigate = useNavigate(); | |
const location = useLocation(); | |
useGate(AppGate, { navigate, location, history }); | |
} | |
} | |
}); | |
// Public API | |
export const useRouterSetup = router.useSetup; | |
// TODO add parent route option with params inheritance | |
export function createRoute< | |
Params extends RouteParams = RouteParams, | |
Query extends RouteQuery = RouteQuery | |
>( | |
path: string, | |
debugging?: boolean | |
) { | |
const $matches = router.$matches | |
const routerNavigate = router.navigate; | |
//#region stores | |
const $path = toStore(path); | |
const $params = createStore<Params>({} as Params); | |
const $query = createStore<Query>({} as Query); | |
const $match = sample({ | |
clock: $matches, | |
source: $path, | |
fn: (path, matches) => { | |
return matches.find(match => matchPath(path, match.pathname)) ?? null; | |
} | |
}); | |
const $opened = sample({ | |
clock: $match, | |
fn: match => match !== null | |
}); | |
//#endregion | |
//#region open/updated/closed events | |
const $updates = createStore(false); | |
sample({ | |
clock: $match, | |
filter: $opened, | |
fn: match => parseRouteParams(match.params), | |
target: $params | |
}); | |
const closed: Event<Params> = sample({ | |
clock: $opened, | |
source: $params, | |
filter: not($opened), | |
fn: params => params | |
}); | |
const opened: Event<Params> = sample({ | |
clock: $opened, | |
source: $params, | |
filter: $opened, | |
fn: params => params | |
}); | |
const updated: Event<Params> = sample({ | |
clock: $match, | |
source: $params, | |
filter: and($updates, not(empty($match))), | |
fn: params => params | |
}); | |
sample({ | |
clock: opened, | |
fn: () => true, | |
target: $updates | |
}); | |
//#endregion open/updated/closed events | |
//#region handling navigation | |
const open = createEvent<Params>(); | |
const replace = createEvent<Params>(); | |
const navigate = createEvent<RouteNavigatePayload<Params, Query>>(); | |
sample({ | |
clock: open, | |
source: $path, | |
fn: (path, params) => generatePath(path, params), | |
target: routerNavigate | |
}); | |
sample({ | |
clock: replace, | |
source: $path, | |
fn: (path, params) => ({ | |
to: generatePath(path, params), | |
replace: true | |
}), | |
target: routerNavigate | |
}); | |
// TODO учитывать текущий и следующий query, replace, state | |
sample({ | |
clock: navigate, | |
source: $path, | |
fn: (path, { params, query, replace, state }) => generatePath(path, params), | |
target: routerNavigate | |
}); | |
//#endregion handling navigation | |
// resets | |
$params.reset(closed); | |
$query.reset(closed); | |
$updates.reset(closed); | |
if (debugging) { | |
debug({ | |
closed, | |
opened, | |
updated | |
}) | |
} | |
const route: EffectorRoute<Params, Query> = { | |
$params, | |
$query, | |
$opened, | |
opened, | |
updated, | |
closed, | |
open, | |
replace, | |
navigate | |
} | |
Object.freeze(route); | |
return route; | |
} | |
export function createRouterBlocker( | |
$when: Store<boolean>, | |
// return true when need block navigation | |
shouldBlock: RoutesMatchingFunction = defaultMatchingFunction | |
) { | |
const navigateFx = router.navigateFx; | |
const $location = router.$location; | |
const $history = router.$history; | |
const blockNavigation = createEvent<Transition>(); // blocker | |
const confirmNavigation = createEvent(); | |
const cancelNavigation = createEvent(); | |
const $isBlocked = createStore(false); | |
const $lastTransition = createStore<Transition>(null); | |
const $confirmedNavigation = createStore(false); | |
$isBlocked.reset(cancelNavigation, confirmNavigation); | |
sample({ | |
clock: confirmNavigation, | |
fn: () => true, | |
target: $confirmedNavigation | |
}); | |
const localNavigateFx = attach({ effect: navigateFx }); | |
sample({ | |
clock: blockNavigation, | |
source: { location: $location, confirmed: $confirmedNavigation }, | |
fn: ({ location, confirmed }, tx) => { | |
const isBlocked = !confirmed && shouldBlock(tx.location, location); | |
return { | |
tx, | |
isBlocked, | |
confirmed: isBlocked ? confirmed : true | |
} | |
}, | |
target: spread({ | |
tx: $lastTransition, | |
isBlocked: $isBlocked, | |
confirmed: $confirmedNavigation | |
}) | |
}); | |
sample({ | |
clock: $confirmedNavigation, | |
source: $lastTransition, | |
filter: (tx, confirmed) => confirmed && tx !== null, | |
fn: ({ location }) => { | |
const state = typeof location === 'object' && 'state' in location ? location.state : undefined; | |
return { | |
to: location, | |
state | |
} | |
}, | |
target: localNavigateFx | |
}); | |
const blockFx = attach({ | |
source: $history, | |
effect(history) { | |
function blockCallback(tx: Transition) { | |
const autoUnblockingTx = { | |
...tx, | |
retry() { | |
unblock(); | |
tx.retry(); | |
}, | |
}; | |
blockNavigation(autoUnblockingTx); | |
} | |
const unblock = history.block(blockCallback); | |
return unblock; | |
} | |
}); | |
const $unblock = restore(blockFx, null); | |
const unblockFx = attach({ | |
source: $unblock, | |
effect(unblock) { | |
unblock?.() | |
} | |
}); | |
sample({ | |
clock: [$when, $location], | |
filter: $when, | |
target: blockFx | |
}); | |
sample({ | |
clock: [$when, $confirmedNavigation], | |
filter: or($confirmedNavigation, not($when)), | |
target: unblockFx | |
}); | |
$confirmedNavigation.reset(localNavigateFx.finally); | |
$unblock.reset(unblockFx.finally); | |
return Object.freeze({ | |
$isBlocked: $isBlocked as Store<boolean>, | |
confirmNavigation, | |
cancelNavigation | |
}); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { History } from 'history'; | |
import type { Location, NavigateFunction, NavigateOptions, To } from 'react-router-dom'; | |
import type { Event, EventCallable, Store } from 'effector'; | |
export type RouteParams = Record<string, any>; | |
export type RouteQuery = Record<string, any>; | |
export interface AppGateProps { | |
navigate: NavigateFunction, | |
location: Location, | |
history: History | |
} | |
export interface NavigateParams extends NavigateOptions { | |
to: To | |
} | |
export interface NavigateFxPayload extends NavigateParams { | |
navigate: NavigateFunction | |
} | |
export type NavigateEventPayload = string | NavigateParams; | |
export interface RouteNavigatePayload<Params extends RouteParams, Query extends RouteQuery> extends NavigateOptions { | |
params: Params, | |
query?: Query | |
} | |
export interface EffectorRoute<Params extends RouteParams = RouteParams, Query extends RouteQuery = RouteQuery> { | |
$params: Store<Params>, | |
$query: Store<Query>, | |
$opened: Store<boolean>, | |
opened: Event<Params>, | |
updated: Event<Params>, | |
closed: Event<Params>, | |
open: EventCallable<Params>, | |
replace: EventCallable<Params>, | |
navigate: EventCallable<RouteNavigatePayload<Params, Query>> | |
} | |
// block navigation | |
export type RoutesMatchingFunction = (next: Location, current: Location) => boolean; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { matchPath } from 'react-router-dom'; | |
import type { Location, Params as RouterParams } from 'react-router-dom'; | |
import { isBooleanString, isNumber, parseBooleanString } from '@/helpers'; | |
import { RouteParams } from './types'; | |
export const joinPaths = (...paths: string[]): string => paths.join('/').replace(/\/\/+/g, '/'); | |
function parseParamByType(value: string) { | |
if (isNumber(value)) return Number(value); | |
if (isBooleanString(value)) return parseBooleanString(value); | |
return value; | |
} | |
export function getRouteParams<Params extends RouteParams>(pattern: string, location: Location): Params { | |
const match = matchPath(pattern, location.pathname); | |
return parseRouteParams(match.params); | |
} | |
export function parseRouteParams<Params extends RouteParams>(params: RouterParams): Params { | |
const typedParams: Record<string, any> = {}; | |
for (const key in params) { | |
typedParams[key] = parseParamByType(params[key]); | |
} | |
return typedParams as Params; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment