Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/contributing/architecture/request-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,13 @@ A thin top-of-viewport **navigation progress bar** listens for `navigationstart`
/ `navigationend` on `routerEvents` and appears only when a navigation is still
pending after a short delay.

The app shell also mounts **scroll restoration** for SPA navigations. The router
saves each history entry's window scroll position, restores it on back/forward,
scrolls to hash targets when present, and otherwise scrolls new navigations to
the top after the destination route commits. Preserve the current scroll for a
specific intercepted link or form with `data-prevent-scroll-reset`, or for
programmatic navigation with `navigate(to, { preventScrollReset: true })`.

Full page navigations occur for:

- Explicit browser reloads/new tab loads
Expand Down
5 changes: 5 additions & 0 deletions e2e/d1-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,14 @@ export async function seedCommunityListingInE2eDatabase(input: {
tags?: Array<string>
kodyId?: string
packageId?: string
readmeContent?: string | null
sourceId?: string
}) {
const ownerUserId = await createStableUserIdFromEmail(input.ownerEmail)
const tagsJson = JSON.stringify(input.tags ?? [])
const kodyId = input.kodyId ?? input.listingId
const packageId = input.packageId ?? `pkg-${input.listingId}`
const readmeContent = input.readmeContent ?? null
const sourceId = input.sourceId ?? `src-${input.listingId}`
const sql = `
INSERT INTO community_listings (
Expand All @@ -125,6 +127,7 @@ INSERT INTO community_listings (
name,
description,
tags_json,
readme_content,
license,
pinned_commit,
status
Expand All @@ -137,6 +140,7 @@ INSERT INTO community_listings (
${quoteSql(input.name)},
${quoteSql(input.description)},
${quoteSql(tagsJson)},
${readmeContent === null ? 'NULL' : quoteSql(readmeContent)},
'MIT',
'abc1234567890abcdef1234567890abcdef12345678',
'active'
Expand All @@ -146,6 +150,7 @@ ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
tags_json = excluded.tags_json,
readme_content = excluded.readme_content,
status = excluded.status,
updated_at = CURRENT_TIMESTAMP;`.trim()
executeE2eD1Command(sql)
Expand Down
61 changes: 61 additions & 0 deletions e2e/ssr-hydration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ const betaListing = {
tags: ['beta', 'ssr'],
}

const longReadme = `# Scroll restoration fixture

## Intent

${Array.from({ length: 24 }, (_, index) => {
return `This README paragraph ${index + 1} makes the detail route scrollable.`
}).join('\n\n')}`

test('SSR community HTML hydrates SPA navigation and client search', async ({
page,
request,
Expand All @@ -24,10 +32,12 @@ test('SSR community HTML hydrates SPA navigation and client search', async ({
await seedCommunityListingInE2eDatabase({
...alphaListing,
ownerEmail: primaryTestUser.email,
readmeContent: longReadme,
})
await seedCommunityListingInE2eDatabase({
...betaListing,
ownerEmail: primaryTestUser.email,
readmeContent: longReadme,
})

const htmlResponse = await request.get('/community')
Expand All @@ -48,24 +58,75 @@ test('SSR community HTML hydrates SPA navigation and client search', async ({
await page.goto('/community')
await expect(page.getByText(alphaListing.description)).toBeVisible()
await expect(page.getByText(betaListing.description)).toBeVisible()
await page.setViewportSize({ width: 900, height: 320 })

await page.evaluate(() => {
;(window as Window & { __e2eMarker?: boolean }).__e2eMarker = true
})

await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
const communityScrollY = await page.evaluate(() => window.scrollY)
expect(communityScrollY).toBeGreaterThan(0)

await page.getByRole('link', { name: alphaListing.name }).click()
await expect(page).toHaveURL(
new RegExp(`/community/${alphaListing.listingId}$`),
)
await expect(page.getByText(alphaListing.description)).toBeVisible()
await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0)
expect(
await page.evaluate(
() => (window as Window & { __e2eMarker?: boolean }).__e2eMarker,
),
).toBe(true)

await page.evaluate(() => window.scrollTo(0, 40))
const detailScrollY = await page.evaluate(() => window.scrollY)
expect(detailScrollY).toBeGreaterThan(0)
expect(detailScrollY).not.toBe(communityScrollY)
expect(communityScrollY).toBeGreaterThan(detailScrollY + 20)
await page.goBack()
await expect(page).toHaveURL(/\/community$/)
await expect
.poll(() => page.evaluate(() => window.scrollY))
.toBeGreaterThan(detailScrollY + 20)

await page
.getByRole('link', { name: betaListing.name })
.evaluate((link) => link.setAttribute('data-prevent-scroll-reset', ''))
await page.evaluate(() => window.scrollTo(0, 80))
const preservedScrollY = await page.evaluate(() => window.scrollY)
expect(preservedScrollY).toBeGreaterThan(0)
await page.getByRole('link', { name: betaListing.name }).click()
await expect(page).toHaveURL(
new RegExp(`/community/${betaListing.listingId}$`),
)
await expect(page.getByText(betaListing.description)).toBeVisible()
await expect
.poll(() => page.evaluate(() => window.scrollY))
.toBeGreaterThan(0)

await page
.getByRole('heading', { name: 'Report this listing' })
.evaluate((heading) => {
heading.id = 'report-heading'
})
await page.evaluate(() => {
window.scrollTo(0, 0)
const link = document.createElement('a')
link.href = `${window.location.pathname}#report-heading`
link.textContent = 'Jump to report'
document.body.prepend(link)
})
await page.getByRole('link', { name: 'Jump to report' }).click()
await expect(page).toHaveURL(/#report-heading$/)
await expect
.poll(() => page.evaluate(() => window.scrollY))
.toBeGreaterThan(0)

await page.getByRole('link', { name: 'Community', exact: true }).click()
await expect(page).toHaveURL(/\/community$/)
await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0)
expect(
await page.evaluate(
() => (window as Window & { __e2eMarker?: boolean }).__e2eMarker,
Expand Down
2 changes: 2 additions & 0 deletions packages/worker/client/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { AppLoaderDataProvider } from './loader-data-context.tsx'
import { NavigationProgress } from './navigation-progress.tsx'
import { readRouterPathname, readRouterSearch } from './router-location.tsx'
import { ScrollRestoration } from './scroll-restoration.tsx'
import {
fetchSessionInfo,
getSessionDisplayName,
Expand Down Expand Up @@ -152,6 +153,7 @@ export function App(handle: Handle<AppProps>) {
return (
<AppLoaderDataProvider loaderData={handle.props.loaderData}>
<NavigationProgress />
<ScrollRestoration />
<main
mix={css({
maxWidth: isWideLayout ? 'none' : '52rem',
Expand Down
Loading