Skip to content

Commit

Permalink
Basic useRouter implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
amannn committed Sep 6, 2024
1 parent 9775157 commit 45bc9fc
Show file tree
Hide file tree
Showing 2 changed files with 199 additions and 20 deletions.
156 changes: 143 additions & 13 deletions packages/next-intl/src/navigation/react-client/createNavigation.test.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import {fireEvent, render, screen} from '@testing-library/react';
import {useParams, usePathname as useNextPathname} from 'next/navigation';
import {
useParams,
usePathname as useNextPathname,
useRouter as useNextRouter
} from 'next/navigation';
import React from 'react';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {NextIntlClientProvider} from '../../react-client';
import {Pathnames} from '../../routing';
import createNavigation from './createNavigation';

vi.mock('next/navigation', async () => {
const actual = await vi.importActual('next/navigation');
return {
...actual,
useParams: vi.fn(() => ({locale: 'en'})),
usePathname: vi.fn(() => '/')
};
});
vi.mock('next/navigation');

function mockCurrentLocale(locale: string) {
vi.mocked(useParams<{locale: string}>).mockImplementation(() => ({
Expand All @@ -27,7 +24,17 @@ function mockCurrentPathname(string: string) {

beforeEach(() => {
mockCurrentLocale('en');
mockCurrentLocale('/en');
mockCurrentPathname('/en');

const router = {
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn()
};
vi.mocked(useNextRouter).mockImplementation(() => router);
});

const locales = ['en', 'de', 'ja'] as const;
Expand Down Expand Up @@ -62,8 +69,19 @@ function getRenderPathname<Return extends string>(usePathname: () => Return) {
};
}

function getInvokeRouter<Router>(useRouter: () => Router) {
return function invokeRouter(cb: (router: Router) => void) {
function Component() {
const router = useRouter();
cb(router);
return null;
}
render(<Component />);
};
}

describe("localePrefix: 'always'", () => {
const {Link, usePathname} = createNavigation({
const {Link, usePathname, useRouter} = createNavigation({
locales,
defaultLocale,
localePrefix: 'always'
Expand Down Expand Up @@ -112,6 +130,48 @@ describe("localePrefix: 'always'", () => {
});
});

describe('useRouter', () => {
const invokeRouter = getInvokeRouter(useRouter);

it('leaves unrelated router functionality in place', () => {
(['back', 'forward', 'refresh'] as const).forEach((method) => {
invokeRouter((router) => router[method]());
expect(useNextRouter()[method]).toHaveBeenCalled();
});
});

describe.each(['push', 'replace'] as const)('`%s`', (method) => {
it('prefixes with the default locale', () => {
invokeRouter((router) => router[method]('/about'));
expect(useNextRouter()[method]).toHaveBeenCalledWith('/en/about');
});

it('prefixes with a secondary locale', () => {
invokeRouter((router) => router[method]('/about', {locale: 'de'}));
expect(useNextRouter()[method]).toHaveBeenCalledWith('/de/about');
});

it('passes through unknown options to the Next.js router', () => {
invokeRouter((router) => router[method]('/about', {scroll: true}));
expect(useNextRouter()[method]).toHaveBeenCalledWith('/en/about', {
scroll: true
});
});
});

describe('prefetch', () => {
it('prefixes with the default locale', () => {
invokeRouter((router) => router.prefetch('/about'));
expect(useNextRouter().prefetch).toHaveBeenCalledWith('/en/about');
});

it('prefixes with a secondary locale', () => {
invokeRouter((router) => router.prefetch('/about', {locale: 'de'}));
expect(useNextRouter().prefetch).toHaveBeenCalledWith('/de/about');
});
});
});

describe('usePathname', () => {
it('returns the correct pathname for the default locale', () => {
mockCurrentLocale('en');
Expand Down Expand Up @@ -175,7 +235,7 @@ describe("localePrefix: 'always', custom `prefixes`", () => {
});

describe("localePrefix: 'as-needed'", () => {
const {usePathname} = createNavigation({
const {usePathname, useRouter} = createNavigation({
locales,
defaultLocale,
localePrefix: 'as-needed'
Expand All @@ -188,6 +248,41 @@ describe("localePrefix: 'as-needed'", () => {
render(<Component />);
}

describe('useRouter', () => {
const invokeRouter = getInvokeRouter(useRouter);

it('leaves unrelated router functionality in place', () => {
(['back', 'forward', 'refresh'] as const).forEach((method) => {
invokeRouter((router) => router[method]());
expect(useNextRouter()[method]).toHaveBeenCalled();
});
});

describe.each(['push', 'replace'] as const)('`%s`', (method) => {
it('does not prefix the default locale', () => {
invokeRouter((router) => router[method]('/about'));
expect(useNextRouter()[method]).toHaveBeenCalledWith('/about');
});

it('prefixes a secondary locale', () => {
invokeRouter((router) => router[method]('/about', {locale: 'de'}));
expect(useNextRouter()[method]).toHaveBeenCalledWith('/de/about');
});
});

describe('prefetch', () => {
it('prefixes with the default locale', () => {
invokeRouter((router) => router.prefetch('/about'));
expect(useNextRouter().prefetch).toHaveBeenCalledWith('/about');
});

it('prefixes with a secondary locale', () => {
invokeRouter((router) => router.prefetch('/about', {locale: 'de'}));
expect(useNextRouter().prefetch).toHaveBeenCalledWith('/de/about');
});
});
});

describe('usePathname', () => {
it('returns the correct pathname for the default locale', () => {
mockCurrentLocale('en');
Expand All @@ -208,7 +303,7 @@ describe("localePrefix: 'as-needed'", () => {
});

describe("localePrefix: 'never'", () => {
const {Link, usePathname} = createNavigation({
const {Link, usePathname, useRouter} = createNavigation({
locales,
defaultLocale,
localePrefix: 'never'
Expand Down Expand Up @@ -246,6 +341,41 @@ describe("localePrefix: 'never'", () => {
});
});

describe('useRouter', () => {
const invokeRouter = getInvokeRouter(useRouter);

it('leaves unrelated router functionality in place', () => {
(['back', 'forward', 'refresh'] as const).forEach((method) => {
invokeRouter((router) => router[method]());
expect(useNextRouter()[method]).toHaveBeenCalled();
});
});

describe.each(['push', 'replace'] as const)('`%s`', (method) => {
it('does not prefix the default locale', () => {
invokeRouter((router) => router[method]('/about'));
expect(useNextRouter()[method]).toHaveBeenCalledWith('/about');
});

it('does not prefix a secondary locale', () => {
invokeRouter((router) => router[method]('/about', {locale: 'de'}));
expect(useNextRouter()[method]).toHaveBeenCalledWith('/about');
});
});

describe('prefetch', () => {
it('does not prefix the default locale', () => {
invokeRouter((router) => router.prefetch('/about'));
expect(useNextRouter().prefetch).toHaveBeenCalledWith('/about');
});

it('does not prefix a secondary locale', () => {
invokeRouter((router) => router.prefetch('/about', {locale: 'de'}));
expect(useNextRouter().prefetch).toHaveBeenCalledWith('/about');
});
});
});

describe('usePathname', () => {
it('returns the correct pathname for the default locale', () => {
mockCurrentLocale('en');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {useRouter as useNextRouter} from 'next/navigation';
import React, {ComponentProps, forwardRef, ReactElement, useMemo} from 'react';
import useLocale from '../../react-client/useLocale';
import {
Expand All @@ -19,16 +20,18 @@ export default function createNavigation<
) {
type Locale = AppLocales extends never ? string : AppLocales[number];

function getLocale() {
function useTypedLocale() {
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/rules-of-hooks -- Reading from context here is fine, since this must always be called during render (redirect, useRouter)
return useLocale() as Locale;
}

const {
Link: BaseLink,
config,
...fns
} = createSharedNavigationFns(getLocale, routing);
getPathname,
...redirects
} = createSharedNavigationFns(useTypedLocale, routing);

/**
* Returns the pathname without a potential locale prefix.
Expand All @@ -39,7 +42,7 @@ export default function createNavigation<
? string
: keyof AppPathnames {
const pathname = useBasePathname(config.localePrefix);
const locale = getLocale();
const locale = useTypedLocale();

// @ts-expect-error -- Mirror the behavior from Next.js, where `null` is returned when `usePathname` is used outside of Next, but the types indicate that a string is always returned.
return useMemo(
Expand Down Expand Up @@ -67,8 +70,54 @@ export default function createNavigation<
) => ReactElement;
(LinkWithRef as any).displayName = 'Link';

// TODO
function useRouter() {}
function useRouter() {
const router = useNextRouter();
const curLocale = useTypedLocale();

return {...fns, Link: LinkWithRef, usePathname, useRouter};
return useMemo(() => {
function createHandler<
Options,
Fn extends (href: string, options?: Options) => void
>(fn: Fn) {
return function handler(
href: string,
options?: Partial<Options> & {locale?: Locale}
): void {
const {locale: nextLocale, ...rest} = options || {};

const pathname = getPathname({
// @ts-expect-error -- This is fine
href,
locale: nextLocale || curLocale
});

const args: [href: string, options?: Options] = [pathname];
if (Object.keys(rest).length > 0) {
// @ts-expect-error -- This is fine
args.push(rest);
}

return fn(...args);
};
}

return {
...router,
push: createHandler<
Parameters<typeof router.push>[1],
typeof router.push
>(router.push),
replace: createHandler<
Parameters<typeof router.replace>[1],
typeof router.replace
>(router.replace),
prefetch: createHandler<
Parameters<typeof router.prefetch>[1],
typeof router.prefetch
>(router.prefetch)
};
}, [curLocale, router]);
}

return {...redirects, Link: LinkWithRef, usePathname, useRouter, getPathname};
}

0 comments on commit 45bc9fc

Please sign in to comment.