Skip to content

Commit

Permalink
(router): add url hash support (#27105)
Browse files Browse the repository at this point in the history
# Why

Adds [URL
hash](https://developer.mozilla.org/en-US/docs/Web/API/URL/hash) support
to Expo Router. This enable 3rd party services that use the hash as part
of work flows (e.g authentication)

# How

The hash is converted into a search parameter called `#`. This was
chosen as its not a valid search parameter and the encoded version is
unlikely to be in use.

# Test Plan

<!--
Please describe how you tested this change and how a reviewer could
reproduce your test, especially if this PR does not include automated
tests! If possible, please also provide terminal output and/or
screenshots demonstrating your test/reproduction.
-->

# Checklist

<!--
Please check the appropriate items below if they apply to your diff.
This is required for changes to Expo modules.
-->

- [ ] Documentation is up to date to reflect these changes (eg:
https://docs.expo.dev and README.md).
- [ ] Conforms with the [Documentation Writing Style
Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)
- [ ] This diff will work correctly for `npx expo prebuild` & EAS Build
(eg: updated a module plugin).

---------

Co-authored-by: Expo Bot <34669131+expo-bot@users.noreply.github.com>
Co-authored-by: Aman Mittal <amandeepmittal@live.com>
  • Loading branch information
3 people committed Mar 14, 2024
1 parent 792df44 commit 277b72c
Show file tree
Hide file tree
Showing 14 changed files with 311 additions and 38 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 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
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
2 changes: 1 addition & 1 deletion packages/expo-router/build/fork/getPathFromState.d.ts.map

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.

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

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');
});
});

0 comments on commit 277b72c

Please sign in to comment.