Skip to content

Commit bd88006

Browse files
bloveclaude
andcommitted
fix(website): keep the heading fragment on a hard-loaded docs deep link
Hard-loading `/docs/<library>/<section>/<slug>#heading` landed at the top of the article instead of the heading. Not a dev-server artifact: measured against a production build, the heading sat ~9760px below the reading pane. The article is server-rendered straight into the document, so the browser performs its native scroll to the fragment on the page scroller. Hydration then mounts `WebsiteWorkspaceSurface`, `html:has([data-website-workspace-host])` starts matching `overflow: hidden`, the page scroller ceases to exist, and the real scroller — `.docs-workspace-article`, mounted with the shell — starts at zero. No script resets anything; CSS simply discards the scroll, and nothing puts the reader back. Shared links, search results and the heading-granular deep links the docs search emits are all exactly this shape, and the failure is silent: the page renders perfectly, just in the wrong place. Re-apply the fragment once, on the mount that takes the scrolling over. The resulting position is identical to clicking the same rail link, including the shell's pre-existing 45px section offset. Non-workspace docs pages were never affected — they keep the document scroller and `html`'s `scroll-padding-top`. The one report against `/docs/choosing-an-adapter` was a cold `next dev` compile dropping the pending fragment scroll, which a production build does not do; the new spec covers that page anyway so the difference stays recorded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 0af6967 commit bd88006

2 files changed

Lines changed: 231 additions & 0 deletions

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
3+
/**
4+
* Hard-loading a docs URL that carries a heading fragment.
5+
*
6+
* This is the shape every shared link, every search-engine result, and every
7+
* hit from the docs search index takes: the browser is handed
8+
* `/docs/…#some-heading` cold, with no in-page click to fall back on. The
9+
* failure mode is silent — the page renders perfectly, just at the top — so
10+
* nothing but a positional assertion catches it.
11+
*
12+
* Two page kinds scroll through different machinery and both are guarded here:
13+
*
14+
* - A non-workspace docs page (`/docs/choosing-an-adapter`) scrolls the
15+
* document. `html`'s `scroll-padding-top` (global.css) clears the fixed
16+
* nav, so the heading lands just below it.
17+
* - A workspace docs page (`/docs/[library]/[section]/[slug]`) does not.
18+
* `html:has([data-website-workspace-host])` is `overflow: hidden`
19+
* (styles/docs.css) and the only scroller is `.docs-workspace-article`
20+
* inside the shell — which the shell mounts during hydration, throwing
21+
* away whatever fragment scroll the browser had already performed on the
22+
* document. `WebsiteWorkspaceSurface` re-applies it; without that the
23+
* heading is stranded thousands of pixels below the reading pane.
24+
*
25+
* Note for anyone debugging a red run by hand: on a *cold* `next dev` server
26+
* the first request for a route compiles it, `load` fires before the document
27+
* is laid out, and Chrome drops the pending fragment scroll — so the very
28+
* first hard load of a docs URL can look broken in dev even when the product
29+
* is fine. It does not happen against a production build. Each test below
30+
* visits its route once before the hard load, which pays that compile.
31+
*/
32+
33+
interface DeepLinkTarget {
34+
readonly id: string;
35+
/** Distance from the top of the scroller's content, at scrollTop 0. */
36+
readonly offset: number;
37+
}
38+
39+
/**
40+
* The deepest heading in the article's own rail that can actually come to rest
41+
* at the top of its scroller.
42+
*
43+
* Deliberately read off the page rather than hardcoded: a heading id baked
44+
* into this file goes stale the first time someone retitles a section, and the
45+
* test then passes vacuously against a fragment that matches nothing. The
46+
* reachability filter matters just as much — the last heading on a page is
47+
* usually inside the final screenful, where scrolling to it hits the end of
48+
* the scroll range and the assertion would fail on a page that works.
49+
*/
50+
async function findDeepLinkTarget(
51+
page: Page,
52+
route: string,
53+
scrollRootSelector: string | null
54+
): Promise<DeepLinkTarget> {
55+
await page.goto(route);
56+
if (scrollRootSelector) {
57+
// Before the shell hydrates, `.docs-workspace-article` is server-rendered
58+
// straight into the page and is not yet a scroller — measuring it then
59+
// yields a scroll range that has nothing to do with the one under test.
60+
await expect(page.locator('[data-workspace-shell]')).toHaveAttribute(
61+
'data-hydrated',
62+
'true'
63+
);
64+
}
65+
await expect(page.locator('.docs-toc-link').first()).toBeVisible();
66+
67+
const target = await page.evaluate((selector) => {
68+
const root = selector
69+
? document.querySelector<HTMLElement>(selector)
70+
: document.documentElement;
71+
if (!root) return null;
72+
const rootTop = selector ? root.getBoundingClientRect().top : 0;
73+
const rootScroll = selector ? root.scrollTop : window.scrollY;
74+
const viewport = selector ? root.clientHeight : window.innerHeight;
75+
const maxScroll = root.scrollHeight - viewport;
76+
77+
let deepest: { id: string; offset: number } | null = null;
78+
for (const link of document.querySelectorAll('.docs-toc-link')) {
79+
const href = link.getAttribute('href');
80+
if (!href?.startsWith('#')) continue;
81+
const heading = document.getElementById(href.slice(1));
82+
if (!heading) continue;
83+
const offset = Math.round(
84+
heading.getBoundingClientRect().top - rootTop + rootScroll
85+
);
86+
// Below the fold, so "it scrolled" is not vacuously true, and inside the
87+
// scroll range, so the scroller can put it at its top edge.
88+
if (offset > viewport && offset <= maxScroll) {
89+
deepest = { id: href.slice(1), offset };
90+
}
91+
}
92+
return deepest;
93+
}, scrollRootSelector);
94+
95+
expect(target, `no reachable off-screen rail heading on ${route}`).not.toBe(
96+
null
97+
);
98+
return target as DeepLinkTarget;
99+
}
100+
101+
/**
102+
* A real document load, not a same-document hash change.
103+
*
104+
* `page.goto()` to a URL that differs from the current one only by its
105+
* fragment scrolls in place and never reloads — which is the case that already
106+
* works, and would have made both tests here pass against the broken build.
107+
*/
108+
async function hardLoad(page: Page, url: string): Promise<void> {
109+
await page.goto('about:blank');
110+
await page.goto(url);
111+
}
112+
113+
test.describe('docs deep links', () => {
114+
test('a hard load with a heading fragment reaches the heading (non-workspace page)', async ({
115+
page,
116+
}) => {
117+
const route = '/docs/choosing-an-adapter';
118+
await page.setViewportSize({ width: 1280, height: 900 });
119+
const target = await findDeepLinkTarget(page, route, null);
120+
121+
await hardLoad(page, `${route}#${target.id}`);
122+
123+
// The heading's distance below the bottom edge of the fixed nav.
124+
// `scroll-padding-top: calc(var(--nav-h) + 16px)` (global.css) puts it at
125+
// that 16px gutter; the band absorbs sub-pixel nav geometry without
126+
// admitting a page that simply never scrolled.
127+
const gapBelowNav = () =>
128+
page.evaluate((id) => {
129+
const heading = document.getElementById(id);
130+
const nav = document.querySelector('[data-site-navigation]');
131+
if (!heading || !nav) return null;
132+
return Math.round(
133+
heading.getBoundingClientRect().top -
134+
nav.getBoundingClientRect().bottom
135+
);
136+
}, target.id);
137+
138+
// `scroll-behavior: smooth` animates the jump, so poll rather than sample.
139+
// The window is generous because a loaded `next dev` server can be slow to
140+
// stream the document; it only ever buys time for the *correct* resting
141+
// place, so a page that lands somewhere else still fails.
142+
await expect
143+
.poll(gapBelowNav, {
144+
message: 'heading never came to rest below the fixed nav',
145+
timeout: 15_000,
146+
})
147+
.toBeLessThanOrEqual(32);
148+
149+
// ...and it stopped below the nav rather than behind it.
150+
expect(await gapBelowNav()).toBeGreaterThanOrEqual(0);
151+
});
152+
153+
test('a hard load with a heading fragment reaches the heading (workspace page)', async ({
154+
page,
155+
}) => {
156+
const route = '/docs/langgraph/guides/streaming';
157+
await page.setViewportSize({ width: 1280, height: 900 });
158+
const target = await findDeepLinkTarget(
159+
page,
160+
route,
161+
'.docs-workspace-article'
162+
);
163+
164+
await hardLoad(page, `${route}#${target.id}`);
165+
await expect(page.locator('[data-workspace-shell]')).toHaveAttribute(
166+
'data-hydrated',
167+
'true'
168+
);
169+
170+
// The reading pane is the scroller here, so the heading comes to rest at
171+
// its top edge — exactly where clicking the same rail link puts it.
172+
await expect
173+
.poll(
174+
() =>
175+
page.evaluate((id) => {
176+
const heading = document.getElementById(id);
177+
const article = document.querySelector('.docs-workspace-article');
178+
if (!heading || !article) return null;
179+
// Absolute distance: overshooting the heading (landing it above
180+
// the pane, off screen) is as broken as never reaching it.
181+
return Math.abs(
182+
Math.round(
183+
heading.getBoundingClientRect().top -
184+
article.getBoundingClientRect().top
185+
)
186+
);
187+
}, target.id),
188+
{
189+
message: 'heading never came to rest at the top of the reading pane',
190+
timeout: 15_000,
191+
}
192+
)
193+
.toBeLessThanOrEqual(8);
194+
195+
// ...and the pane genuinely scrolled to get there.
196+
const articleScrollTop = await page
197+
.locator('.docs-workspace-article')
198+
.evaluate((element) => element.scrollTop);
199+
expect(articleScrollTop).toBeGreaterThan(0);
200+
});
201+
});

apps/website/src/components/workspace/WebsiteWorkspace.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,36 @@ function WebsiteWorkspaceSurface({
294294
[handleMobileModalPresenceChange]
295295
);
296296

297+
/*
298+
* Re-apply the URL fragment once the shell owns the scrolling.
299+
*
300+
* A hard load of `/docs/…#heading` — every shared link, search result and
301+
* docs-search deep link takes that shape — starts with the article rendered
302+
* straight into the document, so the browser performs its native scroll to
303+
* the fragment against the page scroller. Mounting this surface then makes
304+
* `html:has([data-website-workspace-host])` match, which is
305+
* `overflow: hidden` (styles/docs.css); the page scroller disappears, that
306+
* scroll is discarded, and the real scroller — `.docs-workspace-article`,
307+
* mounted with it — starts at zero. Nothing puts the reader back, so the
308+
* heading they followed is left off screen with no error anywhere.
309+
*
310+
* This runs once, on the mount that takes the scrolling over. Later
311+
* fragment navigation is same-document and the browser handles it inside
312+
* the pane on its own. Guarded by e2e/docs-deep-link.spec.ts.
313+
*/
314+
useEffect(() => {
315+
const fragment = window.location.hash.slice(1);
316+
if (!fragment) return;
317+
let id: string;
318+
try {
319+
id = decodeURIComponent(fragment);
320+
} catch {
321+
// A malformed escape is not an element id either way.
322+
return;
323+
}
324+
document.getElementById(id)?.scrollIntoView({ block: 'start' });
325+
}, []);
326+
297327
return (
298328
<ThemeProvider theme="light">
299329
<div className="website-workspace-host" data-website-workspace-host="">

0 commit comments

Comments
 (0)