Skip to content

Commit a9b5521

Browse files
authored
Fix anchor URL resolution (#4249)
1 parent 91fadb0 commit a9b5521

4 files changed

Lines changed: 41 additions & 2 deletions

File tree

packages/gitbook/src/components/hooks/useHash.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ ch
6868
}
6969
const url = new URL(
7070
href,
71-
typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
71+
typeof window !== 'undefined' ? window.location.href : 'http://localhost'
7272
);
7373
setHash(url.hash.slice(1));
7474

packages/gitbook/src/components/primitives/Link.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import NextLink, { type LinkProps as NextLinkProps } from 'next/link';
44
import React from 'react';
55

66
import { tcls } from '@/lib/tailwind';
7+
import { checkIsAnchor, resolveAnchorURL } from '@/lib/urls';
78
import { type TrackEventInput, useTrackEvent } from '../Insights';
89
import { NavigationStatusContext } from '../hooks';
910
import { isExternalLink, toNonEmbedLink } from '../utils/link';
@@ -88,12 +89,20 @@ export function Link(props: LinkProps) {
8889
const trackEvent = useTrackEvent();
8990
const forwardedClassNames = useClassnames(classNames || []);
9091
const isExternal = isExternalServer(href);
92+
const isAnchor = checkIsAnchor(href);
9193
const { target, rel } = getTargetProps(props, { externalTarget, isExternal });
9294

9395
const onClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
9496
// Only trigger navigation context for internal links in the same window without modifier keys (i.e. open in new tab).
9597
if (!isExternal && target !== '_blank' && !event.ctrlKey && !event.metaKey) {
96-
onNavigationClick(href);
98+
if (isAnchor) {
99+
event.preventDefault();
100+
const resolvedHref = resolveAnchorURL(href, window.location);
101+
window.history.pushState(null, '', resolvedHref);
102+
onNavigationClick(resolvedHref);
103+
} else {
104+
onNavigationClick(href);
105+
}
97106
}
98107

99108
const isExternalOnClient = isExternalClient(href);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, it } from 'bun:test';
2+
3+
import { resolveAnchorURL } from './urls';
4+
5+
describe('resolveAnchorURL', () => {
6+
it('replaces the current location hash with the new anchor', () => {
7+
expect(
8+
resolveAnchorURL('#new-anchor', {
9+
href: 'https://gitbook.com/docs/creating-content/blocks/heading#anchor-links',
10+
})
11+
).toBe('/docs/creating-content/blocks/heading#new-anchor');
12+
});
13+
14+
it('preserves URL path and search params when replacing the hash', () => {
15+
expect(
16+
resolveAnchorURL('#new-anchor', {
17+
href: 'https://gitbook.com/docs/creating-content/blocks/heading?fallback=true&query=%74est#anchor-links',
18+
})
19+
).toBe('/docs/creating-content/blocks/heading?fallback=true&query=%74est#new-anchor');
20+
});
21+
});

packages/gitbook/src/lib/urls.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,12 @@ export function checkIsExternalURL(input: string): boolean {
2222
export function checkIsAnchor(input: string): boolean {
2323
return input.startsWith('#');
2424
}
25+
26+
/**
27+
* Resolve a hash-only anchor against a location while replacing any existing hash.
28+
*/
29+
export function resolveAnchorURL(anchor: string, location: Pick<Location, 'href'>): string {
30+
const url = new URL(location.href);
31+
url.hash = anchor;
32+
return `${url.pathname}${url.search}${url.hash}`;
33+
}

0 commit comments

Comments
 (0)