Skip to content

Instantly share code, notes, and snippets.

@7iomka
Created August 8, 2023 09:35
Show Gist options
  • Save 7iomka/66243d65bef2c96c8a6cdb09e1959d40 to your computer and use it in GitHub Desktop.
Save 7iomka/66243d65bef2c96c8a6cdb09e1959d40 to your computer and use it in GitHub Desktop.
_Next_Router_events
import type { ParsedUrlQuery } from 'querystring';
import { createEvent, attach, createStore, sample, restore } from 'effector';
import type { NextRouter } from 'next/router';
import equal from 'fast-deep-equal';
import { debug } from 'patronum/debug';
import { getUrlWithoutOriginFromUrlObject } from '@xxx/utils';
import { atom } from '@/shared/lib/factory';
import type { PageContext } from '@/shared/lib/nextjs-effector';
import type { NextHistoryState } from './navigation.types';
export const $$navigation = atom(() => {
const routerInitialized = createEvent<NextRouter>();
const routerUpdated = createEvent<NextRouter>();
const routerStateChanged = createEvent<PageContext>();
const queryParamsChanged = createEvent<ParsedUrlQuery>();
const urlChanged = createEvent<string>();
const beforePopstateChanged = createEvent<NextHistoryState>();
const $router = restore(routerInitialized, null);
sample({
clock: routerUpdated,
target: $router,
});
// Custom store for test cases, related to issues with loss of the scope
const $isRouterInitialized = createStore(false).on(routerInitialized, () => true);
/**
* Prepare query params
* NOTE: next.js `query` also contains `params` from dynamic routes,
* So we don't use it as a source of truth!
* Instead we will use `routerStateChanged`, which is triggered whenever
* the router changes but only when it `isReady`,
* it contains normalized router state
*/
const $query = restore(queryParamsChanged, {});
sample({
clock: routerStateChanged,
source: $query,
filter: (currentQuery, { query }) => {
const isDeepEq = equal(currentQuery, query);
return !isDeepEq;
},
fn: (_, { query }) => query,
target: queryParamsChanged,
});
/**
* Current page url
* NOTE: Don't use it serverside,because of an empty initial
* Updated only clientside
*/
const $url = restore(urlChanged, '');
// Set url on router initialize
sample({
clock: routerInitialized,
fn: ({ asPath }) => asPath,
target: urlChanged,
});
// Set url on router initialize / `asPath` updated
sample({
clock: routerStateChanged,
source: $url,
filter: (currentUrl, { asPath }) => Boolean(asPath) && currentUrl !== asPath,
fn: (_, { asPath }) => asPath!,
target: urlChanged,
});
// Use router.push with options
const pushFx = attach({
source: $router,
effect(
router,
{
url,
options = {},
}: { url: NextHistoryState['url']; options?: NextHistoryState['options'] },
) {
return router?.push(url, undefined, options);
},
});
// Update url on push
sample({
clock: pushFx.done,
fn: ({ params: { url } }) =>
typeof url === 'string' ? url : getUrlWithoutOriginFromUrlObject(url),
target: $url,
});
if (process.env.NEXT_PUBLIC_APP_STAGE !== 'production') {
debug({
$query,
routerInitialized,
routerUpdated,
routerStateChanged,
queryParamsChanged,
urlChanged,
beforePopstateChanged,
});
}
// Server only units. Updates from serverside events (with fork)
const $serverUrl = createStore('');
const $serverQueryParams = createStore<ParsedUrlQuery>({});
return {
routerInitialized,
routerUpdated,
routerStateChanged,
urlChanged,
queryParamsChanged,
beforePopstateChanged,
$router,
$isRouterInitialized,
$query,
$url,
pushFx,
$serverUrl,
$serverQueryParams,
};
});
import { useUnit } from 'effector-react';
import { useRouter } from 'next/router';
import { useRef } from 'react';
import equal from 'fast-deep-equal';
import { useIsomorphicLayoutEffect } from '@xxx/hooks';
import { $$navigation } from '@/entities/navigation';
import { ContextNormalizers } from '@/shared/lib/next';
import type { PageContext } from '@/shared/lib/nextjs-effector';
import type { AppPropsWithLayout } from '../app.types';
export const withEffectorRouterEvents = (App: any) => {
return function AppWithEffectorRouterEvents(props: AppPropsWithLayout) {
const router = useRouter();
const beforePopstateChanged = useUnit($$navigation.beforePopstateChanged);
const routerInitialized = useUnit($$navigation.routerInitialized);
const routerUpdated = useUnit($$navigation.routerUpdated);
const routerStateChanged = useUnit($$navigation.routerStateChanged);
const isInitRouterEventCalledRef = useRef(false);
const normalizedRouterStateRef = useRef<PageContext | null>(null);
useIsomorphicLayoutEffect(() => {
if (router.isReady && !isInitRouterEventCalledRef.current) {
routerInitialized(router);
isInitRouterEventCalledRef.current = true;
}
return () => {
isInitRouterEventCalledRef.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady]);
useIsomorphicLayoutEffect(() => {
if (router.isReady) {
// Update router instance
routerUpdated(router);
// Update normalized state from router only if it changed
const normalizedRouterState = ContextNormalizers.router(router);
if (!equal(normalizedRouterState, normalizedRouterStateRef.current)) {
routerStateChanged(ContextNormalizers.router(router));
}
normalizedRouterStateRef.current = normalizedRouterState;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router]);
// Handle beforePopState
// NOTE: currently next support only single callback, that can be overwriten on updates
// See: https://github.com/vercel/next.js/discussions/34835
useIsomorphicLayoutEffect(() => {
router.beforePopState((state) => {
beforePopstateChanged(state);
return true;
});
}, [router, beforePopstateChanged]);
return <App {...props} />;
};
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment