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

(router): add url hash support #27105

Merged
merged 13 commits into from Mar 14, 2024
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 on websites 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 `#`. It can be accessed and modified using the same hooks and APIs from [search parameters](/router/reference/search-parameters/).

```tsx app/hash.tsx
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
```tsx app/hash.tsx
```tsx app/index.tsx

import { Text } from 'react-native';
import { router, useLocalSearchParams, Link } from 'expo-router';

export default function User() {
// Access the hash
const { '#': hash } = useLocalSearchParams<{ '#': 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>
</>
);
}
```
1 change: 1 addition & 0 deletions packages/expo-router/CHANGELOG.md
Expand Up @@ -7,6 +7,7 @@
### 🎉 New features

- Mark React client components with "use client" directives. ([#27300](https://github.com/expo/expo/pull/27300) by [@EvanBacon](https://github.com/EvanBacon))
- Add URL hash support ([#27105](https://github.com/expo/expo/pull/27105) by [@marklawlor](https://github.com/marklawlor))

### 🐛 Bug fixes

Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 17 additions & 2 deletions packages/expo-router/build/fork/getPathFromState.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/expo-router/build/fork/getPathFromState.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/expo-router/build/fork/getStateFromPath.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 9 additions & 7 deletions packages/expo-router/build/fork/getStateFromPath.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/expo-router/build/fork/getStateFromPath.js.map

Large diffs are not rendered by default.

136 changes: 136 additions & 0 deletions packages/expo-router/src/__tests__/hashs.test.ios.tsx
@@ -0,0 +1,136 @@
import React, { Text } from 'react-native';

import { router } from '../exports';
import { store } from '../global-state/router-store';
import { act, renderRouter, screen } 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#a'));
expect(screen.getByTestId('test')).toBeOnTheScreen();

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

expect(store.rootStateSnapshot()).toStrictEqual({
index: 4,
key: expect.any(String),
routeNames: ['index', 'test', '_sitemap', '+not-found'],
routes: [
{
key: expect.any(String),
name: 'index',
params: undefined,
path: '/',
},
{
key: expect.any(String),
name: 'test',
params: {
'#': 'a',
},
path: undefined,
},
{
key: expect.any(String),
name: 'test',
params: {
'#': 'b',
},
path: undefined,
},
{
key: expect.any(String),
name: 'test',
params: {
'#': 'b',
},
path: undefined,
},
{
key: expect.any(String),
name: 'test',
params: {
'#': 'c',
},
path: undefined,
},
],
stale: false,
type: 'stack',
});
});

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

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

// Add a hash
act(() => router.navigate('/test?a=1#hash1'));
expect(screen.getByTestId('test')).toBeOnTheScreen();
expect(screen).toHaveSegments(['test']);
expect(screen).toHavePathname('/test');
expect(screen).toHavePathnameWithParams('/test?a=1#hash1');
expect(screen).toHaveSearchParams({ a: '1', '#': 'hash1' });

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

act(() => router.navigate('/test?a=3'));
expect(screen).toHaveSegments(['test']);
expect(screen).toHavePathname('/test');
expect(screen).toHavePathnameWithParams('/test?a=3');
expect(screen).toHaveSearchParams({ a: '3' });
});

it('navigating to the same route with a hash will only rerender the screen', () => {
renderRouter({
index: () => <Text testID="index" />,
});

expect(store.rootStateSnapshot()).toStrictEqual({
routes: [
{
name: 'index',
path: '/',
},
],
stale: true,
});

act(() => router.navigate('/?#hash1'));

expect(store.rootStateSnapshot()).toStrictEqual({
index: 0,
key: expect.any(String),
routeNames: ['index', '_sitemap', '+not-found'],
routes: [
{
name: 'index',
key: expect.any(String),
path: '/',
params: {
'#': 'hash1',
},
},
],
stale: false,
type: 'stack',
});
});
@@ -0,0 +1,68 @@
import getPathFromState from '../getPathFromState';

describe('hash support', () => {
it('appends hash to the path', () => {
const state = {
index: 0,
key: 'key',
routes: [
{
name: 'index',
path: '/',
params: {
'#': 'hash1',
},
},
],
stale: true,
type: 'stack',
};

const config = {
screens: {
index: '',
_sitemap: '_sitemap',
},
};

expect(getPathFromState(state, config)).toBe('/#hash1');
});

it('works with nested state, existing router and path params', () => {
const state = {
index: 1,
key: 'key',
routeNames: ['index', '[test]', '_sitemap', '+not-found'],
routes: [
{
key: 'key',
name: 'index',
params: undefined,
path: '/',
},
{
key: 'key',
name: '[test]',
params: {
test: 'hello-world',
query: 'true',
'#': 'a',
},
path: undefined,
},
],
stale: false,
type: 'stack',
};

const config = {
screens: {
'[test]': ':test',
index: '',
_sitemap: '_sitemap',
},
};

expect(getPathFromState(state, config)).toBe('/hello-world?query=true#a');
});
});