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
2 changes: 2 additions & 0 deletions demo/App.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@
left: 0;
width: 100%;
cursor: pointer;
color: inherit;
text-decoration: none;
}

.rowIndex {
Expand Down
6 changes: 3 additions & 3 deletions demo/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,19 +147,19 @@ export function App(): React.ReactNode {
}

return (
<div
<a
key={virtualRow.key}
data-index={virtualRow.index}
className={styles.row}
style={{transform: `translateY(${virtualRow.start}px)`}}
aria-selected={row.id === permalinkID || undefined}
onClick={() => setHash(row.id === permalinkID ? '' : row.id)}
href={`#${row.id}`}
>
<span className={styles.rowLabel}>{row.title}</span>
<span className={styles.rowValue}>
{dateFormatter.format(row[sortField])}
</span>
</div>
</a>
);
})}
</div>
Expand Down
2 changes: 2 additions & 0 deletions demo/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const pool = new Pool({
const dbProvider = zeroNodePg(schema, pool);

app.post('/zero/query', async c => {
console.log('handling query', {url: c.req.url, method: c.req.method});

const result = await handleQueryRequest(
(name, args) => mustGetQuery(queries, name).fn({args, ctx: {}}),
schema,
Expand Down
6 changes: 4 additions & 2 deletions demo/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ const items = Array.from({length: ITEM_COUNT}, (_, i) => {
created + faker.number.int({min: 0, max: 7 * 24 * 60 * 60 * 1000});
return {
id: faker.string.nanoid(10),
title: faker.lorem.words({min: 2, max: 6}),
description: faker.lorem.sentences({min: 1, max: 4}),
title: faker.word.words({count: {min: 2, max: 6}}),
description: Array.from({length: faker.number.int({min: 1, max: 4})}, () =>
faker.hacker.phrase(),
).join(' '),
created,
modified: Math.min(modified, now - i), // ensure unique ordering
};
Expand Down
55 changes: 27 additions & 28 deletions demo/use-hash.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,49 @@
import {useSyncExternalStore} from 'react';

interface NavigationHistoryEntry {
url: string | null;
}

interface NavigationNavigateOptions {
state?: unknown;
info?: unknown;
history?: 'auto' | 'push' | 'replace';
}

interface NavigationResult {
committed: Promise<NavigationHistoryEntry>;
finished: Promise<NavigationHistoryEntry>;
}

interface Navigation extends EventTarget {
readonly currentEntry: NavigationHistoryEntry | null;
navigate(url: string, options?: NavigationNavigateOptions): NavigationResult;
}

declare const navigation: Navigation;
// Module-level cache updated immediately when a currententrychange event fires
// so that getHash() always returns the correct value when React reads it
// synchronously after the subscriber notifies it.
let currentHash = location.hash.slice(1);

function getHash(): string {
const url = navigation.currentEntry?.url;
return url ? new URL(url).hash.slice(1) : location.hash.slice(1);
return currentHash;
}

function subscribe(callback: () => void): () => void {
navigation.addEventListener('currententrychange', callback);
return () => {
navigation.removeEventListener('currententrychange', callback);
const onNavigate = (e: NavigateEvent) => {
if (e.canIntercept && navigation.currentEntry?.url) {
e.intercept();
const currentURL = new URL(navigation.currentEntry.url);
const destinationURL = new URL(e.destination.url);
if (currentURL.pathname === destinationURL.pathname) {
const newHash = destinationURL.hash.slice(1);
if (newHash !== currentHash) {
currentHash = newHash;
callback();
}
}
}
};

navigation.addEventListener('navigate', onNavigate);
return () => navigation.removeEventListener('navigate', onNavigate);
}

function setHash(newHash: string) {
function setHash(newHash: string): void {
navigation.navigate(location.pathname + location.search + '#' + newHash);
}

function getServerSnapshot(): string {
return '';
}

/**
* Returns the current URL hash (without the leading `#`) and a setter function.
* Uses the Navigation API to reactively track hash changes.
*
* @returns `[hash, setHash]` – the current hash value and a function to update it.
*/
export function useHash(): [string, (hash: string) => void] {
const hash = useSyncExternalStore(subscribe, getHash);
const hash = useSyncExternalStore(subscribe, getHash, getServerSnapshot);
return [hash, setHash];
}
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
"test": "vitest run",
"prepack": "rm -rf dist && pnpm run build"
},
"dependencies": {
"@types/dom-navigation": "^1.0.7"
},
"devDependencies": {
"@rocicorp/zero": "^1.0.0",
"@tanstack/react-virtual": "^3.13.23",
Expand All @@ -60,5 +63,5 @@
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"packageManager": "pnpm@10.30.2"
"packageManager": "pnpm@10.33.0"
}
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 19 additions & 24 deletions src/react/use-history-scroll-state.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {useCallback, useSyncExternalStore} from 'react';
import {useCallback, useMemo} from 'react';
import {useHistoryState} from './use-history-state.ts';
import type {ScrollHistoryState} from './use-zero-virtualizer.ts';

const DEFAULT_KEY = 'scrollState';
Expand Down Expand Up @@ -32,32 +33,26 @@
key: string = DEFAULT_KEY,
): [
ScrollHistoryState<TStartRow> | null,
(state: ScrollHistoryState<TStartRow>) => void,
(state: ScrollHistoryState<TStartRow> | null) => void,
] {
const state = useSyncExternalStore(
subscribeToPopState,
() => getSnapshot<TStartRow>(key),
() => null,
);
const [state, setState] = useHistoryState();

const scrollState: ScrollHistoryState<TStartRow> | null = useMemo(() => {
if (!state) return null;
return (state as Record<string, unknown>)[
key
] as ScrollHistoryState<TStartRow> | null;
}, [state && JSON.stringify((state as Record<string, unknown>)[key])]);

const setState = useCallback(
(newState: ScrollHistoryState<TStartRow>) => {
window.history.replaceState(
{...window.history.state, [key]: newState},
'',
);
const setScrollState = useCallback(
(newState: ScrollHistoryState<TStartRow> | null) => {
setState({
...(state ?? {}),

Check warning on line 50 in src/react/use-history-scroll-state.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
[key]: newState,
});
},
[key],
[state, key],
);

return [state, setState];
}

function subscribeToPopState(onStoreChange: () => void) {
window.addEventListener('popstate', onStoreChange);
return () => window.removeEventListener('popstate', onStoreChange);
}

function getSnapshot<TStartRow>(key: string) {
return (window.history.state?.[key] as ScrollHistoryState<TStartRow>) ?? null;
return [scrollState, setScrollState];
}
48 changes: 48 additions & 0 deletions src/react/use-history-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {useSyncExternalStore} from 'react';

/**
* A React hook that provides access to the Navigation API's current entry state,
* synchronized with React's rendering cycle via `useSyncExternalStore`.
*
* Returns a tuple of the current history state and a setter function that calls
* `navigation.updateCurrentEntry` to update it.
*/
export function useHistoryState(): [
state: unknown,
setState: (state: unknown) => void,
] {
const state = useSyncExternalStore(
subscribeState,
getSnapshot,
getServerSnapshot,
);

return [state, updateCurrentEntryState];
}

let currentSnapshot: unknown = null;
let currentSnapshotString = 'null';

function getSnapshot(): unknown {
const newSnapshot = navigation.currentEntry?.getState();
const newSnapshotString = JSON.stringify(newSnapshot);
if (newSnapshotString !== currentSnapshotString) {
currentSnapshot = newSnapshot;
currentSnapshotString = newSnapshotString;
}
return currentSnapshot;
}

function getServerSnapshot() {
return null;
}

function updateCurrentEntryState(state: unknown) {
navigation.updateCurrentEntry({state});
}

function subscribeState(onStoreChange: () => void) {
navigation.addEventListener('currententrychange', onStoreChange);
return () =>
navigation.removeEventListener('currententrychange', onStoreChange);
}
64 changes: 49 additions & 15 deletions src/react/use-zero-virtualizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ export type UseZeroVirtualizerOptions<
* like URL query parameters.
*/
onSettled?: (() => void) | undefined;

// new temporary API

// Before we navigate away we will save the current scroll state
saveScrollState?:
| ((state: ScrollHistoryState<TStartRow>) => void)
| undefined;
};

const createPermalinkAnchor = (id: string) =>
Expand Down Expand Up @@ -519,9 +526,19 @@ export function useZeroVirtualizer<
// virtualizer - omitted to avoid infinite render loops from scroll events
]);

// Use layoutEffect to restore scroll position synchronously to avoid visual jumps
// Track the last applied scroll state so we can detect when it changes due
// to a real navigation (back/forward/push) as opposed to a re-render.
const appliedScrollStateRef = useRef(effectiveScrollState);

// Use layoutEffect to restore scroll position synchronously to avoid visual jumps.
// Triggers when listContextParams changes OR when effectiveScrollState
// changes (e.g. browser back/forward within the same list context).
useLayoutEffect(() => {
if (!isListContextCurrent) {
const scrollStateChanged =
effectiveScrollState !== appliedScrollStateRef.current;
appliedScrollStateRef.current = effectiveScrollState;

if (!isListContextCurrent || scrollStateChanged) {
if (effectiveScrollState) {
virtualizer.scrollToOffset(effectiveScrollState.scrollTop);
dispatch({
Expand All @@ -533,19 +550,36 @@ export function useZeroVirtualizer<
listContextParams,
});
} else if (permalinkID) {
virtualizer.scrollToOffset(
NUM_ROWS_FOR_LOADING_SKELETON *
// TODO: Support dynamic item sizes
estimateSize(0),
);
dispatch({
type: 'RESET_STATE',
estimatedTotal: NUM_ROWS_FOR_LOADING_SKELETON,
hasReachedStart: false,
hasReachedEnd: false,
anchor: createPermalinkAnchor(permalinkID),
listContextParams,
});
// Check if the permalink item is already in the current virtual items.
// If so, scroll directly to it instead of resetting to the loading skeleton.
const permalinkVirtualItem = getRowKey
? virtualizer.getVirtualItems().find(item => {
const row = rowAt(item.index);
return row !== undefined && getRowKey(row) === permalinkID;
})
: undefined;

if (permalinkVirtualItem) {
virtualizer.scrollToIndex(permalinkVirtualItem.index, {
align: 'auto',
});
} else {
// TODO(arv): Figure out if we should scroll to top or bottom.

virtualizer.scrollToOffset(
NUM_ROWS_FOR_LOADING_SKELETON *
// TODO: Support dynamic item sizes
estimateSize(0),
);
dispatch({
type: 'RESET_STATE',
estimatedTotal: NUM_ROWS_FOR_LOADING_SKELETON,
hasReachedStart: false,
hasReachedEnd: false,
anchor: createPermalinkAnchor(permalinkID),
listContextParams,
});
}
} else {
virtualizer.scrollToOffset(0);
dispatch({
Expand Down
Loading