Skip to content

Commit

Permalink
(router): add url hash support
Browse files Browse the repository at this point in the history
  • Loading branch information
marklawlor committed Feb 15, 2024
1 parent 6ad4145 commit 976d8bf
Show file tree
Hide file tree
Showing 5 changed files with 164 additions and 33 deletions.
22 changes: 22 additions & 0 deletions docs/pages/router/reference/search-parameters.mdx
Expand Up @@ -251,3 +251,25 @@ export default function User() {
);
}
```

## Hash support

The URL (hash)[https://developer.mozilla.org/en-US/docs/Web/API/URL/hash] is a string that follows the `#` symbol in a URL. It is commonly used to link to a specific section of a page, but it can also be used to store data. Expo Router treats the hash as a special search parameter using the name `#`, and it can be accessed and modified using the same hooks and APIs are search parameters

```tsx app/hash.tsx
import { Text } from 'react-native';
import { router, useLocalSearchParams, Link } from 'expo-router';

export default function User() {
// Access the hash
const { '#': hash } = useSearchParams<{ '#': string }>();

return (
<>
<Text onPress={() => router.setParams({ '#': 'my-hash' })}>Set a new hash</Text>
<Text onPress={() => router.push('/#my-hash')}>Push with a new hash</Text>
<Link href="/#my-hash">Link with a hash</Link>
</>
);
}
```
2 changes: 1 addition & 1 deletion packages/expo-router/src/LocationProvider.tsx
Expand Up @@ -51,7 +51,7 @@ export function getNormalizedStatePath(
},
baseUrl?: string
): Pick<UrlObject, 'segments' | 'params'> {
const [pathname] = statePath.split('?');
const pathname = new URL(statePath, 'http://localhost').pathname;
return {
// Strip empty path at the start
segments: stripBaseUrl(pathname, baseUrl).split('/').filter(Boolean).map(decodeURIComponent),
Expand Down
93 changes: 93 additions & 0 deletions packages/expo-router/src/__tests__/hashs.test.ios.tsx
@@ -0,0 +1,93 @@
import React, { Text } from 'react-native';

import { router } from '../exports';
import { act, renderRouter, screen, testRouter } from '../testing-library';

it('can push a hash url', () => {
renderRouter({
index: () => <Text testID="index" />,
test: () => <Text testID="test" />,
});

expect(screen).toHavePathname('/');
expect(screen.getByTestId('index')).toBeOnTheScreen();

act(() => router.push('/test#my-hash'));
expect(screen.getByTestId('test')).toBeOnTheScreen();

expect(screen).toHaveSegments(['test']);
expect(screen).toHavePathname('/test');
expect(screen).toHavePathnameWithParams('/test#my-hash');
expect(screen).toHaveSearchParams({ '#': 'my-hash' });
});

it('route.push() with hash', () => {
renderRouter({
index: () => <Text testID="index" />,
test: () => <Text testID="test" />,
});

expect(screen).toHavePathname('/');
expect(screen.getByTestId('index')).toBeOnTheScreen();

act(() => router.push('/test#a'));
expect(screen.getByTestId('test')).toBeOnTheScreen();

expect(screen).toHaveSegments(['test']);
expect(screen).toHavePathname('/test');
expect(screen).toHavePathnameWithParams('/test#a');
expect(screen).toHaveSearchParams({ '#': 'a' });

act(() => router.push('/test#b'));
act(() => router.push('/test#b'));

expect(screen).toHavePathnameWithParams('/test#b');
expect(screen).toHaveSearchParams({ '#': 'b' });

act(() => router.push('/test#c'));
expect(screen).toHavePathnameWithParams('/test#c');
expect(screen).toHaveSearchParams({ '#': 'c' });

act(() => router.back());
expect(screen).toHavePathname('/test');
expect(screen).toHavePathnameWithParams('/test#b');
expect(screen).toHaveSearchParams({ '#': 'b' });

act(() => router.back());
expect(screen).toHavePathname('/test');
expect(screen).toHavePathnameWithParams('/test#a');
expect(screen).toHaveSearchParams({ '#': 'a' });

act(() => router.back());
expect(screen).toHavePathname('/');
});

it('works with search params', () => {
renderRouter({
index: () => <Text testID="index" />,
test: () => <Text testID="test" />,
});

expect(screen).toHavePathname('/');
expect(screen.getByTestId('index')).toBeOnTheScreen();

act(() => router.navigate('/test?a=1#my-hash'));

expect(screen.getByTestId('test')).toBeOnTheScreen();
expect(screen).toHaveSegments(['test']);
expect(screen).toHavePathname('/test');
expect(screen).toHavePathnameWithParams('/test?a=1#my-hash');
expect(screen).toHaveSearchParams({ a: '1', '#': 'my-hash' });

act(() => router.navigate('/test?a=2#my-hash'));
expect(screen).toHaveSegments(['test']);
expect(screen).toHavePathname('/test');
expect(screen).toHavePathnameWithParams('/test?a=2#my-hash');
expect(screen).toHaveSearchParams({ a: '2', '#': 'my-hash' });

act(() => router.navigate('/test?a=2#my-new-hash'));
expect(screen).toHaveSegments(['test']);
expect(screen).toHavePathname('/test');
expect(screen).toHavePathnameWithParams('/test?a=2#my-new-hash');
expect(screen).toHaveSearchParams({ a: '2', '#': 'my-new-hash' });
});
37 changes: 29 additions & 8 deletions packages/expo-router/src/fork/getPathFromState.ts
Expand Up @@ -115,7 +115,7 @@ export default function getPathFromState<ParamList extends object>(
preserveDynamicRoutes?: boolean;
}
): string {
return getPathDataFromState(state, _options).path;
return getPathDataFromState(state, _options).pathWithHash;
}

export function getPathDataFromState<ParamList extends object>(
Expand Down Expand Up @@ -222,6 +222,7 @@ function walkConfigItems(

let pattern: string | null = null;
let focusedParams: Record<string, any> | undefined;
let hash: string | undefined;

const collectedParams: Record<string, any> = {};

Expand All @@ -236,6 +237,11 @@ function walkConfigItems(
pattern = inputPattern;

if (route.params) {
if (route.params['#']) {
hash = route.params['#'];
delete route.params['#'];
}

const params = processParamsWithUserSettings(configItem, route.params);
if (pattern !== undefined && pattern !== null) {
Object.assign(collectedParams, params);
Expand Down Expand Up @@ -326,6 +332,7 @@ function walkConfigItems(
pattern,
nextRoute: route,
focusedParams,
hash,
params: collectedParams,
};
}
Expand All @@ -340,6 +347,7 @@ function getPathFromResolvedState(
) {
let path = '';
let current: State = state;
let hash: string | undefined;

const allParams: Record<string, any> = {};

Expand All @@ -355,12 +363,17 @@ function getPathFromResolvedState(
route.state = createFakeState(route.params);
}

const { pattern, params, nextRoute, focusedParams } = walkConfigItems(
route,
getActiveRoute(current),
{ ...configs },
{ preserveDynamicRoutes }
);
const {
pattern,
params,
nextRoute,
focusedParams,
hash: $hash,
} = walkConfigItems(route, getActiveRoute(current), { ...configs }, { preserveDynamicRoutes });

if ($hash) {
hash = $hash;
}

Object.assign(allParams, params);

Expand Down Expand Up @@ -405,7 +418,15 @@ function getPathFromResolvedState(
}
}

return { path: appendBaseUrl(basicSanitizePath(path)), params: decodeParams(allParams) };
const params = decodeParams(allParams);
let pathWithHash = path;

if (hash) {
pathWithHash += `#${hash}`;
params['#'] = hash;
}

return { path: appendBaseUrl(basicSanitizePath(path)), params, pathWithHash };
}

function decodeParams(params: Record<string, string>) {
Expand Down
43 changes: 19 additions & 24 deletions packages/expo-router/src/fork/getStateFromPath.ts
Expand Up @@ -53,20 +53,18 @@ export function getUrlWithReactNavigationConcessions(
// Do nothing with invalid URLs.
return {
nonstandardPathname: '',
inputPathnameWithoutHash: '',
url: null,
};
}

const pathname = parsed.pathname;
const pathname = stripBaseUrl(parsed.pathname, baseUrl);
parsed.pathname = pathname;

// Make sure there is a trailing slash
return {
// The slashes are at the end, not the beginning
nonstandardPathname:
stripBaseUrl(pathname, baseUrl).replace(/^\/+/g, '').replace(/\/+$/g, '') + '/',

// React Navigation doesn't support hashes, so here
inputPathnameWithoutHash: stripBaseUrl(path, baseUrl).replace(/#.*$/, ''),
nonstandardPathname: pathname.replace(/^\/+/g, '').replace(/\/+$/g, '') + '/',
url: parsed,
};
}

Expand Down Expand Up @@ -306,7 +304,7 @@ function sortConfigs(a: RouteConfig, b: RouteConfig): number {
}

function getStateFromEmptyPathWithConfigs(
path: string,
path: URL,
configs: RouteConfig[],
initialRoutes: InitialRouteConfig[]
): ResultState | undefined {
Expand Down Expand Up @@ -364,28 +362,20 @@ function getStateFromPathWithConfigs(
): ResultState | undefined {
const formattedPaths = getUrlWithReactNavigationConcessions(path);

if (formattedPaths.url === null) return;

if (formattedPaths.nonstandardPathname === '/') {
return getStateFromEmptyPathWithConfigs(
formattedPaths.inputPathnameWithoutHash,
configs,
initialRoutes
);
return getStateFromEmptyPathWithConfigs(formattedPaths.url, configs, initialRoutes);
}

// We match the whole path against the regex instead of segments
// This makes sure matches such as wildcard will catch any unmatched routes, even if nested
const routes = matchAgainstConfigs(formattedPaths.nonstandardPathname, configs);

if (routes == null) {
return undefined;
}
if (routes == null) return;

// This will always be empty if full path matched
return createNestedStateObject(
formattedPaths.inputPathnameWithoutHash,
routes,
configs,
initialRoutes
);
return createNestedStateObject(formattedPaths.url, routes, configs, initialRoutes);
}

const joinPaths = (...paths: string[]): string =>
Expand Down Expand Up @@ -703,7 +693,7 @@ const createStateObject = (
};

const createNestedStateObject = (
path: string,
url: URL,
routes: ParsedRoute[],
routeConfigs: RouteConfig[],
initialRoutes: InitialRouteConfig[]
Expand Down Expand Up @@ -742,7 +732,7 @@ const createNestedStateObject = (
route = findFocusedRoute(state) as ParsedRoute;

// Remove groups from the path while preserving a trailing slash.
route.path = stripGroupSegmentsFromPath(path);
route.path = stripGroupSegmentsFromPath(url.pathname + url.search);

const params = parseQueryParams(route.path, findParseConfigForRoute(route.name, routeConfigs));

Expand All @@ -768,6 +758,11 @@ const createNestedStateObject = (
}
}

if (url.hash) {
route.params ??= {};
route.params['#'] = url.hash.slice(1);
}

return state;
};

Expand Down

0 comments on commit 976d8bf

Please sign in to comment.