Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
48 changes: 46 additions & 2 deletions examples/marko/chat-pretext/src/routes/+page.marko
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ static function makeMessage(index: number): Message {

static const HISTORY_LIMIT = -90
static const AT_END_PX = 80
static const PIN_RETRY_MAX_FRAMES = 60
static const PIN_SETTLED_FRAME_COUNT = 2

// ---- Height model: mirrors the CSS below exactly. Every constant corresponds to a
// declared style; if you change one, change the other. A NAMED font (Arial) with
Expand Down Expand Up @@ -139,8 +141,12 @@ static function messageHeight(message: Message, viewportWidth: number) {
// above the viewport, and if core's offset read is stale at that moment its
// at-end compensation is skipped — so the view strands ~12 rows short with no
// scroll event to recover it. The reactive <script> below re-issues the jump
// on every virtualizer update until the end holds, then disarms.
// on virtualizer updates and bounded retry frames until the end holds, then
// disarms.
pinPending: false,
pinRetryFrame: null as number | null,
pinRetryAttempts: 0,
pinSettledFrames: 0,
streamAbort: null as AbortController | null,
width: DEFAULT_VIEWPORT_WIDTH,
})/>
Expand Down Expand Up @@ -192,16 +198,48 @@ static function messageHeight(message: Message, viewportWidth: number) {
if (statusEl) {
atEnd = statusEl.scrollHeight - statusEl.scrollTop - statusEl.clientHeight <= AT_END_PX
}
const requestPinRetry = () => {
if (state.pinRetryFrame !== null) return
state.pinRetryFrame = requestAnimationFrame(() => {
state.pinRetryFrame = null
if (!state.pinPending) return
v.scrollToEnd()
const el = scrollEl()
const reachedEnd =
el !== null && el.scrollHeight - el.scrollTop - el.clientHeight <= AT_END_PX
atEnd = reachedEnd
if (reachedEnd && !loadingHistory) {
state.pinSettledFrames += 1
} else {
state.pinSettledFrames = 0
}
if (state.pinSettledFrames >= PIN_SETTLED_FRAME_COUNT) {
state.pinPending = false
state.pinRetryAttempts = 0
state.pinSettledFrames = 0
return
}
state.pinRetryAttempts += 1
if (state.pinRetryAttempts >= PIN_RETRY_MAX_FRAMES) {
state.pinPending = false
state.pinRetryAttempts = 0
state.pinSettledFrames = 0
return
}
requestPinRetry()
})
}
// Converge an in-flight "Latest": re-issue the jump while content growth keeps
// moving the end away (see pinPending above). Disarms once the end holds AND no
// history load is in flight — a prepend armed before the click can land after
// the end was first reached, and disarming early would leave that late growth
// with no corrective jump.
if (state.pinPending) {
if (atEnd && !loadingHistory) {
state.pinPending = false
requestPinRetry()
} else if (!atEnd) {
v.scrollToEnd()
requestPinRetry()
}
}
}/>
Expand Down Expand Up @@ -261,6 +299,8 @@ static function messageHeight(message: Message, viewportWidth: number) {
}>Stream reply</button>
<button data-testid="latest" onClick() {
state.pinPending = true
state.pinRetryAttempts = 0
state.pinSettledFrames = 0
v.scrollToEnd()
}>Latest</button>
</div>
Expand Down Expand Up @@ -330,6 +370,10 @@ static function messageHeight(message: Message, viewportWidth: number) {
lifecycleState.cleanup = () => {
cancelled = true
observer.disconnect()
if (state.pinRetryFrame !== null) {
cancelAnimationFrame(state.pinRetryFrame)
state.pinRetryFrame = null
}
}
}
onDestroy() {
Expand Down
51 changes: 33 additions & 18 deletions packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import type { Page } from '@playwright/test'
// v.resizeItem with calculated heights that match the rendered DOM.

const AT_END_PX = 80
const AUTO_HISTORY_ARM_DELAY_MS = 400
const PREPEND_RACE_DELAY_MS = 30
const LATEST_PREPEND_RACE_ATTEMPTS = 4
const DISPLAY_ATTEMPT_OFFSET = 1

const consoleErrors: string[] = []

Expand Down Expand Up @@ -43,6 +47,10 @@ async function distanceFromEnd(page: Page): Promise<number> {
.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight)
}

async function scrollHeight(page: Page): Promise<number> {
return page.locator('.messages').evaluate((el) => el.scrollHeight)
}

async function waitForPin(page: Page) {
await expect
.poll(() => distanceFromEnd(page), { timeout: 5000 })
Expand Down Expand Up @@ -193,25 +201,32 @@ test('scrolling near the top auto-loads older history', async ({ page }) => {
.toBeGreaterThan(heightBefore)
})

// FIXME(#1267): deterministic on CI. The click lands before the 180ms auto history
// load fires, the prepend then resolves after the jump, and the adapter's anchor
// write is clamped against the not-yet-grown sizer — the view strands one prepend
// (~870px) above the bottom. Re-enable once #1267 is fixed.
test.fixme('Latest returns to the bottom and status flips back to At latest', async ({
page,
}) => {
await page.goto('/chat-pretext')
await waitForPin(page)
await page.locator('.messages').evaluate((el) => {
el.scrollTop = 0
Array.from({ length: LATEST_PREPEND_RACE_ATTEMPTS }, (_, index) => {
const attempt = index + DISPLAY_ATTEMPT_OFFSET

test(`Latest returns to the bottom after an in-flight prepend (${attempt})`, async ({
page,
}) => {
await page.goto('/chat-pretext')
await waitForPin(page)
await page.waitForTimeout(AUTO_HISTORY_ARM_DELAY_MS)
await page.locator('.messages').evaluate((el) => {
el.scrollTop = 0
})
await expect(page.locator('[data-testid="status"]')).toHaveText(
'Loading history',
)
const heightBeforeClick = await scrollHeight(page)
await page.waitForTimeout(PREPEND_RACE_DELAY_MS)
await page.locator('[data-testid="latest"]').click()

await expect
.poll(() => scrollHeight(page), { timeout: 3000 })
.toBeGreaterThan(heightBeforeClick)
await waitForPin(page)
await expect(page.locator('[data-testid="status"]')).toHaveText('At latest')
await expect(page.locator('[data-key^="message-4"]').last()).toBeVisible()
})
await expect(page.locator('[data-testid="status"]')).toHaveText(
/Reading history|Loading history/,
)
await page.locator('[data-testid="latest"]').click()
await waitForPin(page)
await expect(page.locator('[data-testid="status"]')).toHaveText('At latest')
await expect(page.locator('[data-key^="message-4"]').last()).toBeVisible()
})

test('a reply streamed from the server grows progressively and stays pinned', async ({
Expand Down
Loading