Skip to content
Merged
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
10 changes: 10 additions & 0 deletions src/core/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ export type RowsSnapshot<TRow> = {
atEnd: boolean;
firstRowIndex: number;
permalinkNotFound: boolean;
/**
* The resolved permalink target row (the single-row lookup result), when the
* current anchor is a permalink and the row has loaded; `undefined`
* otherwise. Its `getRowKey` locates the DOM row to scroll to — the
* permalink id need not equal the row key.
*/
permalinkRow: TRow | undefined;
};

/** The raw results of the (up to) three staged queries. */
Expand Down Expand Up @@ -211,6 +218,7 @@ export function assembleRows<TRow, TStartRow>(
? anchorIndex
: anchorIndex - rowsBeforeSize,
permalinkNotFound,
permalinkRow: singleRow,
};
}

Expand All @@ -226,6 +234,7 @@ export function assembleRows<TRow, TStartRow>(
atEnd: mainComplete && !hasMoreRows,
firstRowIndex: anchorIndex,
permalinkNotFound,
permalinkRow: undefined,
};
}

Expand All @@ -241,5 +250,6 @@ export function assembleRows<TRow, TStartRow>(
atEnd: false,
firstRowIndex: anchorIndex - paginatedRowsLength,
permalinkNotFound,
permalinkRow: undefined,
};
}
47 changes: 47 additions & 0 deletions src/core/virtualizer.dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,3 +506,50 @@ describe('scroll-state restore when the container mounts lazily', () => {
}
});
});

describe('permalink scroll', () => {
test('scrolls the permalink row into view when its row key differs from the permalink id', () => {
// Deep-link by a human-friendly id (`r120`) while keying rows by something
// else (`key-r120`) — the common short-id-URL / uuid-key split. The scroll
// must still land, located via the resolved row's key, not the raw id.
const h = harness({
rowCount: 300,
options: {
permalinkID: 'r120',
getRowKey: row => `key-${row.id}`,
},
});
h.settle();

// The target row is scrolled into view near the top. Without the fix
// `findRow` looks up `r120` (the permalink id) against DOM rows keyed
// `key-r120`, never matches, and the row stays far below the viewport.
const top = h.rowTop('key-r120');
expect(top).toBeGreaterThanOrEqual(0);
expect(top).toBeLessThan(40); // within ~a row of the top
});
});

describe('persist timing', () => {
test('persists immediately on scrollend, before the debounce, so a fast navigation keeps the position', () => {
const persisted: Array<ScrollHistoryState<TestRow>> = [];
const h = harness({
rowCount: 100,
options: {
onScrollStateChange: s =>
persisted.push(s as ScrollHistoryState<TestRow>),
},
});
h.settle();
persisted.length = 0;

// Scroll, then the browser signals scrolling ended — without any debounce
// time elapsing. The position must already be persisted, so navigating
// away right now (e.g. clicking a row) doesn't lose it.
h.userScroll(300);
h.scroller.dispatchEvent(new Event('scrollend'));

expect(persisted.length).toBeGreaterThan(0);
expect(persisted.at(-1)!.scrollTop).toBe(300);
});
});
1 change: 1 addition & 0 deletions src/core/virtualizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function makeRows(
atEnd: false,
firstRowIndex: 0,
permalinkNotFound: false,
permalinkRow: undefined,
...overrides,
};
}
Expand Down
74 changes: 51 additions & 23 deletions src/core/virtualizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ const EMPTY_ROWS: RowsSnapshot<unknown> = {
atEnd: false,
firstRowIndex: 0,
permalinkNotFound: false,
permalinkRow: undefined,
};

/**
Expand Down Expand Up @@ -942,10 +943,18 @@ export class ZeroVirtualizer<TListContextParams, TRow, TStartRow> {
};

#onScrollEnd = (): void => {
if (!this.#manual()) return;
if (!this.#fingerDown) {
if (this.#manual()) {
// A gesture still under an active finger hasn't really ended.
if (this.#fingerDown) return;
this.#withNotify(() => this.#endScrolling());
}
// Flush the position the moment scrolling ends, cancelling the pending
// debounce. The debounce coalesces mid-scroll writes (and keeps us under
// history-API rate limits), but on its own it loses the position when the
// user navigates within the debounce window right after stopping — e.g.
// scrolling a list then clicking a row. `scrollend` is that "stopped"
// signal, so persist synchronously here.
this.#persistNow();
};

#onTouchStart = (): void => {
Expand Down Expand Up @@ -1196,7 +1205,14 @@ export class ZeroVirtualizer<TListContextParams, TRow, TStartRow> {
}
const el = this.#el;
if (!el) return;
const target = findRow(el, pending);
// The DOM row is keyed by `getRowKey`, which need not equal `permalinkID`:
// apps routinely deep-link by a human-friendly id (a short id / slug) while
// keying rows by something else (a uuid). Locate the row by the resolved
// target row's key, falling back to the permalink id itself until it loads.
const targetRow = this.#rows.permalinkRow;
const targetKey =
targetRow !== undefined ? this.#options.getRowKey(targetRow) : pending;
const target = findRow(el, targetKey);
if (!target) {
// Not rendered yet — keep the request open and retry once it loads,
// unless the row genuinely doesn't exist.
Expand Down Expand Up @@ -1374,30 +1390,42 @@ export class ZeroVirtualizer<TListContextParams, TRow, TStartRow> {
return;
}
this.#lastPersistKey = key;
// Capture at schedule time (matches the old effect's closure semantics).
const anchor = this.#paging.queryAnchor.anchor;
const estimatedTotal = this.#effectiveEstimatedTotal();
const {hasReachedStart, hasReachedEnd} = this.#paging;
const {listContextParams} = this.#options;
clearTimeout(this.#persistTimer);
this.#persistTimer = setTimeout(() => {
const el = this.#el;
// The element can detach during the debounce (unmount). Skip rather than
// persist a spurious scrollTop: 0 over the saved position.
if (!el) return;
onScrollStateChange({
anchor,
// The logical committed offset: if a gesture is mid-flight with an
// owed jump held in the wrapper margin, fold it in so restore lands
// right.
scrollTop: this.#scrollOffset(el) + this.#anchorState.pendingJump,
estimatedTotal,
hasReachedStart,
hasReachedEnd,
listContextParams,
});
this.#persistTimer = undefined;
this.#writeScrollState();
}, PERSIST_DEBOUNCE_MS);
}

// Persist the current scroll state immediately, cancelling any pending
// debounced persist. Called when scrolling ends (`scrollend`): a navigation
// in the debounce window right after the user stops scrolling must not lose
// the position.
#persistNow(): void {
clearTimeout(this.#persistTimer);
this.#persistTimer = undefined;
this.#lastPersistKey = this.#persistKey();
this.#writeScrollState();
}
Comment on lines +1404 to +1409

// The single persist write. Reads the live position at call time; skips when
// detached (the element can go away between schedule and fire) — a write
// then would clobber the saved position with a spurious scrollTop: 0.
#writeScrollState(): void {
const {onScrollStateChange, listContextParams} = this.#options;
const el = this.#el;
if (!el || !this.#isListContextCurrent() || !onScrollStateChange) return;
onScrollStateChange({
anchor: this.#paging.queryAnchor.anchor,
// The logical committed offset: if a gesture is mid-flight with an owed
// jump held in the wrapper margin, fold it in so restore lands right.
scrollTop: this.#scrollOffset(el) + this.#anchorState.pendingJump,
estimatedTotal: this.#effectiveEstimatedTotal(),
hasReachedStart: this.#paging.hasReachedStart,
hasReachedEnd: this.#paging.hasReachedEnd,
listContextParams,
});
}
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/react/use-zero-virtualizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function makeUseRowsResult(
atEnd: false,
firstRowIndex: 0,
permalinkNotFound: false,
permalinkRow: undefined,
...overrides,
};
}
Expand Down
1 change: 1 addition & 0 deletions src/solid/create-zero-virtualizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function makeRows(
atEnd: false,
firstRowIndex: 0,
permalinkNotFound: false,
permalinkRow: undefined,
...overrides,
};
}
Expand Down
Loading