diff --git a/demo/App.module.css b/demo/App.module.css
index 9a68a54..d5d8acd 100644
--- a/demo/App.module.css
+++ b/demo/App.module.css
@@ -85,6 +85,8 @@
left: 0;
width: 100%;
cursor: pointer;
+ color: inherit;
+ text-decoration: none;
}
.rowIndex {
diff --git a/demo/App.tsx b/demo/App.tsx
index 69db5fb..d31b0e6 100644
--- a/demo/App.tsx
+++ b/demo/App.tsx
@@ -147,19 +147,19 @@ export function App(): React.ReactNode {
}
return (
-
setHash(row.id === permalinkID ? '' : row.id)}
+ href={`#${row.id}`}
>
{row.title}
{dateFormatter.format(row[sortField])}
-
+
);
})}
diff --git a/demo/api/index.ts b/demo/api/index.ts
index eb504af..c88ddf6 100644
--- a/demo/api/index.ts
+++ b/demo/api/index.ts
@@ -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,
diff --git a/demo/seed.ts b/demo/seed.ts
index 254062c..f6a92b9 100644
--- a/demo/seed.ts
+++ b/demo/seed.ts
@@ -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
};
diff --git a/demo/use-hash.ts b/demo/use-hash.ts
index af81f72..275ef79 100644
--- a/demo/use-hash.ts
+++ b/demo/use-hash.ts
@@ -1,43 +1,42 @@
import {useSyncExternalStore} from 'react';
-interface NavigationHistoryEntry {
- url: string | null;
-}
-
-interface NavigationNavigateOptions {
- state?: unknown;
- info?: unknown;
- history?: 'auto' | 'push' | 'replace';
-}
-
-interface NavigationResult {
- committed: Promise;
- finished: Promise;
-}
-
-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.
@@ -45,6 +44,6 @@ function setHash(newHash: string) {
* @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];
}
diff --git a/package.json b/package.json
index 016ec69..a5efca6 100644
--- a/package.json
+++ b/package.json
@@ -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",
@@ -60,5 +63,5 @@
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
- "packageManager": "pnpm@10.30.2"
+ "packageManager": "pnpm@10.33.0"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0bff2d8..8c7e59b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,6 +7,10 @@ settings:
importers:
.:
+ dependencies:
+ '@types/dom-navigation':
+ specifier: ^1.0.7
+ version: 1.0.7
devDependencies:
'@rocicorp/zero':
specifier: ^1.0.0
@@ -1414,6 +1418,9 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+ '@types/dom-navigation@1.0.7':
+ resolution: {integrity: sha512-Di4W+i2faYquHUnyWUg3bBQp5pTNvjDDA7mIYfD/1WlLgan6sKkeVjGbdL78K0CuNEk5Pfc/c0rfelwkz10mnQ==}
+
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -4216,6 +4223,8 @@ snapshots:
'@types/deep-eql@4.0.2': {}
+ '@types/dom-navigation@1.0.7': {}
+
'@types/estree@1.0.8': {}
'@types/memcached@2.2.10':
diff --git a/src/react/use-history-scroll-state.ts b/src/react/use-history-scroll-state.ts
index 2b651db..dd7e75c 100644
--- a/src/react/use-history-scroll-state.ts
+++ b/src/react/use-history-scroll-state.ts
@@ -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';
@@ -32,32 +33,26 @@ export function useHistoryScrollState(
key: string = DEFAULT_KEY,
): [
ScrollHistoryState | null,
- (state: ScrollHistoryState) => void,
+ (state: ScrollHistoryState | null) => void,
] {
- const state = useSyncExternalStore(
- subscribeToPopState,
- () => getSnapshot(key),
- () => null,
- );
+ const [state, setState] = useHistoryState();
+
+ const scrollState: ScrollHistoryState | null = useMemo(() => {
+ if (!state) return null;
+ return (state as Record)[
+ key
+ ] as ScrollHistoryState | null;
+ }, [state && JSON.stringify((state as Record)[key])]);
- const setState = useCallback(
- (newState: ScrollHistoryState) => {
- window.history.replaceState(
- {...window.history.state, [key]: newState},
- '',
- );
+ const setScrollState = useCallback(
+ (newState: ScrollHistoryState | null) => {
+ setState({
+ ...(state ?? {}),
+ [key]: newState,
+ });
},
- [key],
+ [state, key],
);
- return [state, setState];
-}
-
-function subscribeToPopState(onStoreChange: () => void) {
- window.addEventListener('popstate', onStoreChange);
- return () => window.removeEventListener('popstate', onStoreChange);
-}
-
-function getSnapshot(key: string) {
- return (window.history.state?.[key] as ScrollHistoryState) ?? null;
+ return [scrollState, setScrollState];
}
diff --git a/src/react/use-history-state.ts b/src/react/use-history-state.ts
new file mode 100644
index 0000000..4889646
--- /dev/null
+++ b/src/react/use-history-state.ts
@@ -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);
+}
diff --git a/src/react/use-zero-virtualizer.ts b/src/react/use-zero-virtualizer.ts
index e572674..788932d 100644
--- a/src/react/use-zero-virtualizer.ts
+++ b/src/react/use-zero-virtualizer.ts
@@ -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) => void)
+ | undefined;
};
const createPermalinkAnchor = (id: string) =>
@@ -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({
@@ -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({