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({