Skip to content

Commit d84308e

Browse files
committed
Improve the feed query logic further by introducing a useQuery hook
1 parent 5b449a4 commit d84308e

21 files changed

Lines changed: 624 additions & 734 deletions

File tree

apps/polycentric/src/common/components/layout/nav/NavItem.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Link, LinkProps, usePathname, useRouter } from 'expo-router';
1+
import { Link, LinkProps, router, usePathname, useRouter } from 'expo-router';
2+
import { emitFocusedRefresh } from '@/src/common/lib/navigation/useFocusedRefresh';
23
import {
34
cloneElement,
45
isValidElement,
@@ -78,6 +79,11 @@ export function NavItem({ href, icon, label, ...props }: NavItemProps) {
7879
onMouseEnter={() => setHovering(true)}
7980
onMouseLeave={() => setHovering(false)}
8081
href={href!}
82+
onPress={(e) => {
83+
e.preventDefault();
84+
if (isActive) emitFocusedRefresh();
85+
router.navigate(href!);
86+
}}
8187
style={[
8288
linkContainerStyle,
8389
Atoms.py_md,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useCallback, useRef } from 'react';
2+
import { useFocusEffect, useNavigation } from 'expo-router';
3+
import { isWeb } from '@/src/common/util/platform';
4+
5+
/**
6+
* The single registered refresh handler for the currently focused
7+
* screen. Only one screen is ever focused at a time, so there's
8+
* nothing to multiplex.
9+
*/
10+
let currentRefresh: (() => void) | null = null;
11+
12+
/**
13+
* Calls refresh to consumers of `useFocusedRefresh` hooks
14+
*/
15+
export function emitFocusedRefresh(): void {
16+
currentRefresh?.();
17+
}
18+
19+
/**
20+
* If the component is focused and a refresh is emitted, the hook is called
21+
*/
22+
export function useFocusedRefresh(refresh: () => void): void {
23+
const refreshRef = useRef(refresh);
24+
refreshRef.current = refresh;
25+
26+
// Claim the slot while focused; release it on blur. `useFocusEffect`
27+
// guarantees this aligns with the screen's actual focus state.
28+
useFocusEffect(
29+
useCallback(() => {
30+
const fn = () => refreshRef.current();
31+
currentRefresh = fn;
32+
return () => {
33+
if (currentRefresh === fn) currentRefresh = null;
34+
};
35+
}, []),
36+
);
37+
38+
const navigation = useNavigation();
39+
const navigationRef = useRef(navigation);
40+
navigationRef.current = navigation;
41+
42+
useFocusEffect(
43+
useCallback(() => {
44+
if (isWeb) return;
45+
return navigationRef.current.addListener('tabPress' as never, () => {
46+
if (navigationRef.current.isFocused()) emitFocusedRefresh();
47+
});
48+
}, []),
49+
);
50+
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { useEffect, useRef } from 'react';
2+
import {
3+
FetchMode,
4+
Query,
5+
QueryOpts,
6+
QueryStatus,
7+
type PolycentricClient,
8+
} from '@polycentric/react-native';
9+
import { create } from 'zustand';
10+
import { usePolycentric } from '../../lib/polycentric-hooks';
11+
12+
export type QueryRef = {
13+
data: ArrayBuffer | undefined;
14+
status: QueryStatus;
15+
error: string | null;
16+
};
17+
18+
type QueryArgs = {
19+
client: PolycentricClient;
20+
queryKey: string[];
21+
query: Query;
22+
opts: QueryOpts | undefined;
23+
};
24+
25+
type SubscriptionRef = {
26+
refCount: number;
27+
dispose: () => void;
28+
args: QueryArgs;
29+
};
30+
31+
type QueryStoreState = {
32+
queries: Map<string, QueryRef>;
33+
subscriptions: Map<string, SubscriptionRef>;
34+
subscribe: (key: string, args: QueryArgs) => void;
35+
unsubscribe: (key: string) => void;
36+
refresh: (key: string, args?: QueryArgs) => void;
37+
invalidate: (key: string, args?: QueryArgs) => void;
38+
};
39+
40+
const EMPTY_ENTRY: QueryRef = Object.freeze({
41+
data: undefined,
42+
status: QueryStatus.Loading,
43+
error: null,
44+
});
45+
46+
export const useQueryStore = create<QueryStoreState>((set, get) => {
47+
const updateQueryRef = (key: string, patch: Partial<QueryRef>) => {
48+
set((state) => {
49+
const prev = state.queries.get(key) ?? EMPTY_ENTRY;
50+
const merged = { ...prev, ...patch };
51+
if (
52+
merged.data === prev.data &&
53+
merged.status === prev.status &&
54+
merged.error === prev.error
55+
) {
56+
return {};
57+
}
58+
const next = new Map(state.queries);
59+
next.set(key, merged);
60+
return { queries: next };
61+
});
62+
};
63+
64+
const fetch = (key: string, args: QueryArgs): (() => void) => {
65+
// Set query to loading state
66+
updateQueryRef(key, {
67+
status: QueryStatus.Loading,
68+
error: null,
69+
});
70+
// Request from rs-core
71+
const observable = args.client.core.fetchQuery(
72+
args.queryKey,
73+
args.query,
74+
args.opts,
75+
);
76+
// Listen for outputs from relevant servers
77+
const sub = observable.subscribe({
78+
next(result) {
79+
updateQueryRef(key, { data: result.data, status: result.status });
80+
},
81+
error(message) {
82+
updateQueryRef(key, { error: message, status: QueryStatus.Error });
83+
},
84+
complete() {
85+
// Terminal status already arrived via the final `next`.
86+
},
87+
});
88+
// Dispose of the subscription if we cancel the fetch
89+
return () => sub.unsubscribe();
90+
};
91+
92+
return {
93+
queries: new Map(),
94+
subscriptions: new Map(),
95+
96+
subscribe(key, args) {
97+
const existing = get().subscriptions.get(key);
98+
if (existing) {
99+
existing.refCount += 1;
100+
existing.args = args;
101+
102+
if (args.opts?.fetchMode === FetchMode.Default) {
103+
console.log(`Trying to refetch ${key}`);
104+
existing.dispose();
105+
existing.dispose = fetch(key, args);
106+
}
107+
108+
return;
109+
}
110+
get().subscriptions.set(key, {
111+
refCount: 1,
112+
dispose: fetch(key, args),
113+
args,
114+
});
115+
},
116+
117+
unsubscribe(key) {
118+
const subscription = get().subscriptions.get(key);
119+
if (!subscription) return;
120+
subscription.refCount -= 1;
121+
if (subscription.refCount === 0) {
122+
subscription.dispose();
123+
get().subscriptions.delete(key);
124+
}
125+
},
126+
127+
refresh(key, args) {
128+
const sub = get().subscriptions.get(key);
129+
if (!sub) return;
130+
const next = args ?? sub.args;
131+
sub.dispose();
132+
sub.args = next;
133+
sub.dispose = fetch(key, next);
134+
},
135+
136+
invalidate(key, args) {
137+
const sub = get().subscriptions.get(key);
138+
const target = args ?? sub?.args;
139+
if (target) target.client.core.invalidateQuery(target.queryKey);
140+
if (sub && target) {
141+
sub.dispose();
142+
sub.args = target;
143+
// Keep stale `data` visible — `fetch` flips status to
144+
// Loading and the new fan-out's `next` emissions will
145+
// overwrite as fresh results arrive.
146+
sub.dispose = fetch(key, target);
147+
}
148+
},
149+
};
150+
});
151+
152+
export type UseQueryResult = QueryRef & {
153+
isLoading: boolean;
154+
/** Re-run the fan-out. Cached data stays visible until the new responses arrive. */
155+
refresh: () => void;
156+
/**
157+
* Drop the rust-side cache for this key, then re-run the fan-out.
158+
* Optional `opts` overrides the original `QueryOpts` for this run
159+
* (e.g. pass `{ fetchMode: FetchMode.Default }` to force a network
160+
* fetch when the original subscription used `OfflineOnly`).
161+
*/
162+
invalidate: (opts?: QueryOpts) => void;
163+
};
164+
165+
/**
166+
* Imperatively invalidate a query from outside a React component
167+
* (e.g. after a successful compose). Clears the rust-side cache and,
168+
* if a live subscription exists for this query, re-runs its fan-out
169+
* so the JS-side store gets fresh data.
170+
*/
171+
export function invalidateQuery(
172+
client: PolycentricClient,
173+
queryKey: string[],
174+
): void {
175+
client.core.invalidateQuery(queryKey);
176+
useQueryStore.getState().refresh(queryKey.join('\0'));
177+
}
178+
179+
/**
180+
* Subscribe to a rust-core query and share its state across every
181+
* consumer using the same `queryKey`. The first consumer kicks off
182+
* the rust-side fan-out; subsequent consumers refcount onto the same
183+
* subscription. `refresh` / `invalidate` re-run the shared fan-out
184+
* for every attached consumer. Set `enabled` to `false` to skip the
185+
* subscription entirely (the hook still returns cached state if any).
186+
*/
187+
export function useQuery(
188+
queryKey: string[],
189+
query: Query,
190+
opts: QueryOpts = { fetchMode: FetchMode.Default },
191+
enabled = true,
192+
): UseQueryResult {
193+
const client = usePolycentric();
194+
const cacheKey = queryKey.join('\0');
195+
196+
const entry = useQueryStore((s) => s.queries.get(cacheKey) ?? EMPTY_ENTRY);
197+
198+
// Keep `argsRef` pointing at the freshest call args so the
199+
// imperative handlers below always see them without needing the
200+
// effect to re-run.
201+
const argsRef = useRef<QueryArgs>({ client, queryKey, query, opts });
202+
argsRef.current = { client, queryKey, query, opts };
203+
204+
useEffect(() => {
205+
if (!enabled) return;
206+
const { subscribe: attach, unsubscribe: detach } = useQueryStore.getState();
207+
attach(cacheKey, argsRef.current);
208+
return () => detach(cacheKey);
209+
}, [cacheKey, enabled]);
210+
211+
return {
212+
...entry,
213+
isLoading: enabled && entry.status === QueryStatus.Loading,
214+
refresh: () => useQueryStore.getState().refresh(cacheKey, argsRef.current),
215+
invalidate: (overrideOpts?: QueryOpts) =>
216+
useQueryStore
217+
.getState()
218+
.invalidate(
219+
cacheKey,
220+
overrideOpts !== undefined
221+
? { ...argsRef.current, opts: overrideOpts }
222+
: argsRef.current,
223+
),
224+
};
225+
}

apps/polycentric/src/features/composer/ComposeSheetInner.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { ComposeSheetFooterBar } from './ComposeSheetFooterBar';
3030
import { useComposerStore } from './hooks/useComposerStore';
3131
import { Routes } from '@/src/common/constants';
3232
import { router } from 'expo-router';
33+
import { invalidateQuery } from '@/src/common/query/hooks/useQuery';
3334

3435
const MAX_ATTACHMENTS = 4;
3536
const THUMBNAIL_SIZE = 72;
@@ -190,11 +191,10 @@ export function ComposeSheetInner({
190191
.then(() => {
191192
// Force the feed observables to re-fetch from servers so the
192193
// newly-synced post appears alongside whatever else changed.
193-
// The local-post injection already showed it optimistically.
194194
const identity = currentIdentityKey ?? '';
195-
client.core.invalidateQuery(['following_feed', identity]);
196-
client.core.invalidateQuery(['identity_feed', identity]);
197-
client.core.invalidateQuery(['explore_feed', identity]);
195+
invalidateQuery(client, ['following_feed']);
196+
invalidateQuery(client, ['identity_feed', identity]);
197+
invalidateQuery(client, ['explore_feed', identity]);
198198
})
199199
.catch((err) => {
200200
console.warn('compose sync failed:', err);

apps/polycentric/src/features/feed/ExploreScreen.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import { openCompose } from '@/src/common/constants';
88
import { isWeb } from '@/src/common/util/platform';
99
import { Atoms, useTheme } from '@/src/common/theme';
1010
import { ActivityIndicator, RefreshControl, View } from 'react-native';
11-
import { useFocusEffect, useIsFocused } from 'expo-router';
11+
import { useFocusEffect } from 'expo-router';
1212
import { useState } from 'react';
13+
import { useFocusedRefresh } from '@/src/common/lib/navigation/useFocusedRefresh';
1314

1415
export default function ExploreScreen() {
1516
const { theme } = useTheme();
@@ -22,6 +23,8 @@ export default function ExploreScreen() {
2223
setEnabled(true);
2324
});
2425

26+
useFocusedRefresh(feed.refresh);
27+
2528
if (feed.error) {
2629
return (
2730
<Screen>

apps/polycentric/src/features/feed/FeedScreen.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import { openCompose } from '@/src/common/constants';
99
import { isWeb } from '@/src/common/util/platform';
1010
import { Atoms, useTheme } from '@/src/common/theme';
1111
import { ActivityIndicator, RefreshControl, View } from 'react-native';
12-
import { useFocusEffect, useIsFocused } from 'expo-router';
13-
import { useState } from 'react';
12+
import { useFocusEffect } from 'expo-router';
13+
import { useCallback, useState } from 'react';
14+
import { useFocusedRefresh } from '@/src/common/lib/navigation/useFocusedRefresh';
1415

1516
const ListHeader = () => (
1617
<>
@@ -26,9 +27,13 @@ export default function FeedScreen() {
2627
const [enabled, setEnabled] = useState<boolean>(false);
2728
const feed = useFollowingFeed({ enabled });
2829

29-
useFocusEffect(() => {
30-
setEnabled(true);
31-
});
30+
useFocusEffect(
31+
useCallback(() => {
32+
setEnabled(true);
33+
}, [setEnabled]),
34+
);
35+
36+
useFocusedRefresh(feed.refresh);
3237

3338
if (feed.error) {
3439
return (

0 commit comments

Comments
 (0)