Skip to content

Commit 96fca5e

Browse files
arunodarauchg
authored andcommitted
Add better hash URL support. (#1250)
* Add better hash URL support. 1. Add scrolling to given id related to hash 2. Hash changes won't trigger getInitialProps * Add some comments. * Fix tests. * Add some test cases.
1 parent 25414ac commit 96fca5e

File tree

7 files changed

+162
-53
lines changed

7 files changed

+162
-53
lines changed

client/index.js

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import HeadManager from './head-manager'
55
import { createRouter } from '../lib/router'
66
import App from '../lib/app'
77
import evalScript from '../lib/eval-script'
8-
import { loadGetInitialProps } from '../lib/utils'
8+
import { loadGetInitialProps, getURL } from '../lib/utils'
99

1010
const {
1111
__NEXT_DATA__: {
@@ -15,14 +15,15 @@ const {
1515
err,
1616
pathname,
1717
query
18-
}
18+
},
19+
location
1920
} = window
2021

2122
const Component = evalScript(component).default
2223
const ErrorComponent = evalScript(errorComponent).default
2324
let lastAppProps
2425

25-
export const router = createRouter(pathname, query, {
26+
export const router = createRouter(pathname, query, getURL(), {
2627
Component,
2728
ErrorComponent,
2829
err
@@ -34,11 +35,12 @@ const container = document.getElementById('__next')
3435
export default (onError) => {
3536
const emitter = new EventEmitter()
3637

37-
router.subscribe(({ Component, props, err }) => {
38-
render({ Component, props, err, emitter }, onError)
38+
router.subscribe(({ Component, props, hash, err }) => {
39+
render({ Component, props, err, hash, emitter }, onError)
3940
})
4041

41-
render({ Component, props, err, emitter }, onError)
42+
const hash = location.hash.substring(1)
43+
render({ Component, props, hash, err, emitter }, onError)
4244

4345
return emitter
4446
}
@@ -57,7 +59,7 @@ async function renderErrorComponent (err) {
5759
await doRender({ Component: ErrorComponent, props, err })
5860
}
5961

60-
async function doRender ({ Component, props, err, emitter }) {
62+
async function doRender ({ Component, props, hash, err, emitter }) {
6163
if (!props && Component &&
6264
Component !== ErrorComponent &&
6365
lastAppProps.Component === ErrorComponent) {
@@ -73,7 +75,7 @@ async function doRender ({ Component, props, err, emitter }) {
7375
Component = Component || lastAppProps.Component
7476
props = props || lastAppProps.props
7577

76-
const appProps = { Component, props, err, router, headManager }
78+
const appProps = { Component, props, hash, err, router, headManager }
7779
// lastAppProps has to be set before ReactDom.render to account for ReactDom throwing an error.
7880
lastAppProps = appProps
7981

lib/app.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ export default class App extends Component {
1717
}
1818

1919
render () {
20-
const { Component, props, err, router } = this.props
21-
const containerProps = { Component, props, router }
20+
const { Component, props, hash, err, router } = this.props
21+
const containerProps = { Component, props, hash, router }
2222

2323
return <div>
2424
<Container {...containerProps} />
@@ -29,6 +29,24 @@ export default class App extends Component {
2929
}
3030

3131
class Container extends Component {
32+
componentDidMount () {
33+
this.scrollToHash()
34+
}
35+
36+
componentDidUpdate () {
37+
this.scrollToHash()
38+
}
39+
40+
scrollToHash () {
41+
const { hash } = this.props
42+
const el = document.getElementById(hash)
43+
if (el) {
44+
// If we call scrollIntoView() in here without a setTimeout
45+
// it won't scroll properly.
46+
setTimeout(() => el.scrollIntoView(), 0)
47+
}
48+
}
49+
3250
shouldComponentUpdate (nextProps) {
3351
// need this check not to rerender component which has already thrown an error
3452
return !shallowEquals(this.props, nextProps)

lib/router/router.js

Lines changed: 36 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { EventEmitter } from 'events'
33
import evalScript from '../eval-script'
44
import shallowEquals from '../shallow-equals'
55
import PQueue from '../p-queue'
6-
import { loadGetInitialProps, getLocationOrigin } from '../utils'
6+
import { loadGetInitialProps, getURL } from '../utils'
77
import { _notifyBuildIdMismatch } from './'
88
import fetch from 'unfetch'
99

@@ -26,7 +26,7 @@ if (typeof window !== 'undefined' && typeof navigator.serviceWorker !== 'undefin
2626
}
2727

2828
export default class Router extends EventEmitter {
29-
constructor (pathname, query, { Component, ErrorComponent, err } = {}) {
29+
constructor (pathname, query, as, { Component, ErrorComponent, err } = {}) {
3030
super()
3131
// represents the current component key
3232
this.route = toRoute(pathname)
@@ -41,6 +41,7 @@ export default class Router extends EventEmitter {
4141
this.ErrorComponent = ErrorComponent
4242
this.pathname = pathname
4343
this.query = query
44+
this.as = as
4445
this.subscriptions = new Set()
4546
this.componentLoadCancel = null
4647
this.onPopState = this.onPopState.bind(this)
@@ -66,42 +67,12 @@ export default class Router extends EventEmitter {
6667
// Actually, for (1) we don't need to nothing. But it's hard to detect that event.
6768
// So, doing the following for (1) does no harm.
6869
const { pathname, query } = this
69-
this.replace(format({ pathname, query }), getURL())
70+
this.changeState('replaceState', format({ pathname, query }), getURL())
7071
return
7172
}
7273

7374
const { url, as } = e.state
74-
const { pathname, query } = parse(url, true)
75-
76-
this.abortComponentLoad(as)
77-
78-
if (!this.urlIsNew(pathname, query)) {
79-
this.emit('routeChangeStart', as)
80-
this.emit('routeChangeComplete', as)
81-
return
82-
}
83-
84-
const route = toRoute(pathname)
85-
86-
this.emit('routeChangeStart', as)
87-
const {
88-
data,
89-
props,
90-
error
91-
} = await this.getRouteInfo(route, pathname, query, as)
92-
93-
if (error && error.cancelled) {
94-
return
95-
}
96-
97-
this.route = route
98-
this.set(pathname, query, { ...data, props })
99-
100-
if (error) {
101-
this.emit('routeChangeError', error, as)
102-
} else {
103-
this.emit('routeChangeComplete', as)
104-
}
75+
this.replace(url, as)
10576
}
10677

10778
update (route, Component) {
@@ -160,6 +131,13 @@ export default class Router extends EventEmitter {
160131
this.abortComponentLoad(as)
161132
const { pathname, query } = parse(url, true)
162133

134+
// If the url change is only related to a hash change
135+
// We should not proceed. We should only replace the state.
136+
if (this.onlyAHashChange(as)) {
137+
this.changeState('replaceState', url, as)
138+
return
139+
}
140+
163141
// If asked to change the current URL we should reload the current page
164142
// (not location.reload() but reload getInitalProps and other Next.js stuffs)
165143
// We also need to set the method = replaceState always
@@ -180,9 +158,10 @@ export default class Router extends EventEmitter {
180158
}
181159

182160
this.changeState(method, url, as)
161+
const hash = window.location.hash.substring(1)
183162

184163
this.route = route
185-
this.set(pathname, query, { ...data, props })
164+
this.set(pathname, query, as, { ...data, props, hash })
186165

187166
if (error) {
188167
this.emit('routeChangeError', error, as)
@@ -228,12 +207,33 @@ export default class Router extends EventEmitter {
228207
return routeInfo
229208
}
230209

231-
set (pathname, query, data) {
210+
set (pathname, query, as, data) {
232211
this.pathname = pathname
233212
this.query = query
213+
this.as = as
234214
this.notify(data)
235215
}
236216

217+
onlyAHashChange (as) {
218+
if (!this.as) return false
219+
const [ oldUrlNoHash ] = this.as.split('#')
220+
const [ newUrlNoHash, newHash ] = as.split('#')
221+
222+
// If the urls are change, there's more than a hash change
223+
if (oldUrlNoHash !== newUrlNoHash) {
224+
return false
225+
}
226+
227+
// If there's no hash in the new url, we can't consider it as a hash change
228+
if (!newHash) {
229+
return false
230+
}
231+
232+
// Now there's a hash in the new URL.
233+
// We don't need to worry about the old hash.
234+
return true
235+
}
236+
237237
urlIsNew (pathname, query) {
238238
return this.pathname !== pathname || !shallowEquals(query, this.query)
239239
}
@@ -346,12 +346,6 @@ export default class Router extends EventEmitter {
346346
}
347347
}
348348

349-
function getURL () {
350-
const { href } = window.location
351-
const origin = getLocationOrigin()
352-
return href.substring(origin.length)
353-
}
354-
355349
function toRoute (path) {
356350
return path.replace(/\/$/, '') || '/'
357351
}

lib/utils.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,9 @@ export function getLocationOrigin () {
5858
const { protocol, hostname, port } = window.location
5959
return `${protocol}//${hostname}${port ? ':' + port : ''}`
6060
}
61+
62+
export function getURL () {
63+
const { href } = window.location
64+
const origin = getLocationOrigin()
65+
return href.substring(origin.length)
66+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import React, { Component } from 'react'
2+
import Link from 'next/link'
3+
4+
let count = 0
5+
6+
export default class SelfReload extends Component {
7+
static getInitialProps ({ res }) {
8+
if (res) return { count: 0 }
9+
count += 1
10+
11+
return { count }
12+
}
13+
14+
render () {
15+
return (
16+
<div id='hash-changes-page'>
17+
<Link href='#via-link'>
18+
<a id='via-link'>Via Link</a>
19+
</Link>
20+
<a href='#via-a' id='via-a'>Via A</a>
21+
<Link href='/nav/hash-changes'>
22+
<a id='page-url'>Page URL</a>
23+
</Link>
24+
<p>COUNT: {this.props.count}</p>
25+
</div>
26+
)
27+
}
28+
}

test/integration/basic/test/client-navigation.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,5 +119,65 @@ export default (context, render) => {
119119
await browser.close()
120120
})
121121
})
122+
123+
describe('with hash changes', () => {
124+
describe('when hash change via Link', () => {
125+
it('should not run getInitialProps', async () => {
126+
const browser = await webdriver(context.appPort, '/nav/hash-changes')
127+
128+
const counter = await browser
129+
.elementByCss('#via-link').click()
130+
.elementByCss('p').text()
131+
132+
expect(counter).toBe('COUNT: 0')
133+
134+
await browser.close()
135+
})
136+
})
137+
138+
describe('when hash change via A tag', () => {
139+
it('should not run getInitialProps', async () => {
140+
const browser = await webdriver(context.appPort, '/nav/hash-changes')
141+
142+
const counter = await browser
143+
.elementByCss('#via-a').click()
144+
.elementByCss('p').text()
145+
146+
expect(counter).toBe('COUNT: 0')
147+
148+
await browser.close()
149+
})
150+
})
151+
152+
describe('when hash get removed', () => {
153+
it('should not run getInitialProps', async () => {
154+
const browser = await webdriver(context.appPort, '/nav/hash-changes')
155+
156+
const counter = await browser
157+
.elementByCss('#via-a').click()
158+
.elementByCss('#page-url').click()
159+
.elementByCss('p').text()
160+
161+
expect(counter).toBe('COUNT: 1')
162+
163+
await browser.close()
164+
})
165+
})
166+
167+
describe('when hash changed to a different hash', () => {
168+
it('should not run getInitialProps', async () => {
169+
const browser = await webdriver(context.appPort, '/nav/hash-changes')
170+
171+
const counter = await browser
172+
.elementByCss('#via-a').click()
173+
.elementByCss('#via-link').click()
174+
.elementByCss('p').text()
175+
176+
expect(counter).toBe('COUNT: 0')
177+
178+
await browser.close()
179+
})
180+
})
181+
})
122182
})
123183
}

test/integration/basic/test/index.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ describe('Basic Features', () => {
4545
renderViaHTTP(context.appPort, '/nav'),
4646
renderViaHTTP(context.appPort, '/nav/about'),
4747
renderViaHTTP(context.appPort, '/nav/querystring'),
48-
renderViaHTTP(context.appPort, '/nav/self-reload')
48+
renderViaHTTP(context.appPort, '/nav/self-reload'),
49+
renderViaHTTP(context.appPort, '/nav/hash-changes')
4950
])
5051
})
5152
afterAll(() => stopApp(context.server))

0 commit comments

Comments
 (0)