mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
perf(desktop): stop re-rendering the app shell on every keystroke; fix two latent cache races it unmasked (#1692)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay
|
||||
import {
|
||||
emptyChannelWindowStore,
|
||||
flattenChannelWindowEvents,
|
||||
mapChannelWindowEvents,
|
||||
mergeLiveChannelWindowEvent,
|
||||
mergeLiveThreadSummary,
|
||||
replaceNewestChannelWindow,
|
||||
@@ -769,23 +770,33 @@ export function useEditMessageMutation(channel: Channel | null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply-on-success cache update: reflect the edit's new content and
|
||||
// imeta tag set immediately, so the local cache matches what the
|
||||
// receiver overlay (formatTimelineMessages) will produce when the
|
||||
// edit event arrives back from the relay. (Not a true optimistic
|
||||
// update — runs in onSuccess, not onMutate. Worth bearing the cost
|
||||
// only because the edit event round-trip can lag perceptibly.)
|
||||
const applyEdit = (message: RelayEvent): RelayEvent => {
|
||||
if (message.id !== eventId) return message;
|
||||
const nextTags = mediaTags
|
||||
? applyEditTagOverlay(message.tags, mediaTags)
|
||||
: message.tags;
|
||||
return { ...message, content, tags: nextTags };
|
||||
};
|
||||
|
||||
// The WINDOW STORE is the source of truth: every live merge
|
||||
// re-flattens it over `channelMessagesKey`, so patching only the
|
||||
// flattened array gets reverted by the next live event (see
|
||||
// mapChannelWindowEvents). Update the store first, then keep the
|
||||
// flattened cache in step for immediate paint.
|
||||
queryClient.setQueryData<ChannelWindowStore>(
|
||||
channelWindowKey(channel.id),
|
||||
(current) =>
|
||||
current ? mapChannelWindowEvents(current, applyEdit) : current,
|
||||
);
|
||||
queryClient.setQueryData<RelayEvent[]>(
|
||||
channelMessagesKey(channel.id),
|
||||
(current = []) =>
|
||||
current.map((message) => {
|
||||
if (message.id !== eventId) return message;
|
||||
// Apply-on-success cache update: reflect the edit's new content
|
||||
// and imeta tag set immediately, so the local cache matches
|
||||
// what the receiver overlay (formatTimelineMessages) will
|
||||
// produce when the edit event arrives back from the relay.
|
||||
// (Not a true optimistic update — runs in onSuccess, not
|
||||
// onMutate. Worth bearing the cost only because the edit event
|
||||
// round-trip can lag perceptibly.)
|
||||
const nextTags = mediaTags
|
||||
? applyEditTagOverlay(message.tags, mediaTags)
|
||||
: message.tags;
|
||||
return { ...message, content, tags: nextTags };
|
||||
}),
|
||||
(current = []) => current.map(applyEdit),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
appendOlderChannelWindow,
|
||||
mapChannelWindowEvents,
|
||||
channelWindowThreadSummaries,
|
||||
emptyChannelWindowStore,
|
||||
flattenChannelWindowEvents,
|
||||
@@ -342,3 +343,61 @@ test("live summaries survive scrollback pages and still win for their root", ()
|
||||
store = appendOlderChannelWindow(store, tail);
|
||||
assert.equal(channelWindowThreadSummaries(store).get(older.id).replyCount, 5);
|
||||
});
|
||||
|
||||
test("mapChannelWindowEvents rewrites the event everywhere it lives", () => {
|
||||
const target = event("target", 100);
|
||||
const bystander = event("bystander", 90);
|
||||
const auxEvent = event("aux-target", 95, 40003);
|
||||
let store = replaceNewestChannelWindow(
|
||||
emptyChannelWindowStore(),
|
||||
page(null, [target, bystander], { aux: [auxEvent], hasMore: false }),
|
||||
);
|
||||
store = mergeLiveChannelWindowEvent(store, event("live-target", 110));
|
||||
|
||||
const mapped = mapChannelWindowEvents(store, (candidate) =>
|
||||
candidate.id === target.id
|
||||
? { ...candidate, content: "edited" }
|
||||
: candidate,
|
||||
);
|
||||
assert.notEqual(mapped, store);
|
||||
const flattened = flattenChannelWindowEvents(mapped);
|
||||
assert.equal(
|
||||
flattened.find((candidate) => candidate.id === target.id).content,
|
||||
"edited",
|
||||
);
|
||||
// Untouched events keep identity (no churn for memo consumers).
|
||||
assert.equal(
|
||||
flattened.find((candidate) => candidate.id === bystander.id),
|
||||
bystander,
|
||||
);
|
||||
});
|
||||
|
||||
test("mapChannelWindowEvents returns the same store when nothing matches", () => {
|
||||
const store = replaceNewestChannelWindow(
|
||||
emptyChannelWindowStore(),
|
||||
page(null, [event("a", 100)], { hasMore: false }),
|
||||
);
|
||||
assert.equal(
|
||||
mapChannelWindowEvents(store, (candidate) => candidate),
|
||||
store,
|
||||
);
|
||||
});
|
||||
|
||||
test("mapChannelWindowEvents rewrites live overlay events", () => {
|
||||
const liveEvent = event("live", 110);
|
||||
const store = mergeLiveChannelWindowEvent(
|
||||
emptyChannelWindowStore(),
|
||||
liveEvent,
|
||||
);
|
||||
const mapped = mapChannelWindowEvents(store, (candidate) =>
|
||||
candidate.id === liveEvent.id
|
||||
? { ...candidate, content: "edited-live" }
|
||||
: candidate,
|
||||
);
|
||||
assert.equal(
|
||||
flattenChannelWindowEvents(mapped).find(
|
||||
(candidate) => candidate.id === liveEvent.id,
|
||||
).content,
|
||||
"edited-live",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -215,6 +215,47 @@ export function mergeLiveChannelWindowEvent(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a per-event transform across every event the store holds (page rows,
|
||||
* page aux, live overlay, live aux), returning the same store reference when
|
||||
* nothing changed.
|
||||
*
|
||||
* Local writes MUST go through this rather than patching the flattened
|
||||
* `channelMessagesKey` array alone: the window store is the source of truth,
|
||||
* and every live merge re-flattens it into `channelMessagesKey`
|
||||
* (`flattenChannelWindowEvents`), so an update applied only to the flattened
|
||||
* array is silently reverted by the next live event. The message-edit
|
||||
* apply-on-success update hit exactly that: the edit rendered, then the next
|
||||
* live merge re-flattened the un-edited store over it. (Masked on busy
|
||||
* screens because unrelated re-renders kept re-deriving state; exposed once
|
||||
* per-keystroke app-shell renders were removed.)
|
||||
*/
|
||||
export function mapChannelWindowEvents(
|
||||
store: ChannelWindowStore,
|
||||
map: (event: RelayEvent) => RelayEvent,
|
||||
): ChannelWindowStore {
|
||||
let changed = false;
|
||||
const mapEvent = (event: RelayEvent) => {
|
||||
const next = map(event);
|
||||
if (next !== event) changed = true;
|
||||
return next;
|
||||
};
|
||||
const pages = store.pages.map((page) => {
|
||||
const rows = page.rows.map((row) => {
|
||||
const event = mapEvent(row.event);
|
||||
return event === row.event ? row : { ...row, event };
|
||||
});
|
||||
const aux = page.aux.map(mapEvent);
|
||||
return rows.every((row, index) => row === page.rows[index]) &&
|
||||
aux.every((event, index) => event === page.aux[index])
|
||||
? page
|
||||
: { ...page, rows, aux };
|
||||
});
|
||||
const liveOverlay = store.liveOverlay.map(mapEvent);
|
||||
const liveAux = store.liveAux.map(mapEvent);
|
||||
return changed ? { ...store, pages, liveOverlay, liveAux } : store;
|
||||
}
|
||||
|
||||
/** Raw events in the chronological order expected by the existing renderer. */
|
||||
export function flattenChannelWindowEvents(store: ChannelWindowStore) {
|
||||
const byId = new Map<string, RelayEvent>();
|
||||
|
||||
@@ -197,21 +197,42 @@ export function usePresenceSession(pubkey?: string) {
|
||||
React.useState<PresencePreference>(() =>
|
||||
readStoredPresencePreference(normalizedPubkey),
|
||||
);
|
||||
const [lastActivityAt, setLastActivityAt] = React.useState(() => Date.now());
|
||||
const [statusClock, setStatusClock] = React.useState(() => Date.now());
|
||||
const [isDocumentHidden, setIsDocumentHidden] = React.useState(() =>
|
||||
typeof document === "undefined" ? false : document.hidden,
|
||||
// Activity is tracked in a REF, and React state holds only the DERIVED
|
||||
// status. The previous shape stored raw `Date.now()` timestamps in state
|
||||
// and bumped them from a capture-phase `keydown`/`pointerdown` listener —
|
||||
// which re-rendered this hook's host (AppShell, the app root) on every
|
||||
// keystroke the user typed anywhere in the app. Presence only needs a
|
||||
// render when the automatic status actually flips (online <-> away), so
|
||||
// activity updates the ref and re-derives; setState fires on transitions
|
||||
// only (see typing-latency.perf.ts / keystroke input-to-paint).
|
||||
const lastActivityAtRef = React.useRef(Date.now());
|
||||
const [automaticStatus, setAutomaticStatus] = React.useState<PresenceStatus>(
|
||||
() =>
|
||||
resolveAutomaticPresenceStatus(
|
||||
typeof document === "undefined" ? false : document.hidden,
|
||||
lastActivityAtRef.current,
|
||||
Date.now(),
|
||||
),
|
||||
);
|
||||
const automaticStatusRef = React.useRef(automaticStatus);
|
||||
const skipNextSyncRef = React.useRef<PresenceStatus | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const now = Date.now();
|
||||
setPresencePreference(readStoredPresencePreference(normalizedPubkey));
|
||||
setLastActivityAt(now);
|
||||
setStatusClock(now);
|
||||
setIsDocumentHidden(
|
||||
const reevaluateAutomaticStatus = React.useEffectEvent(() => {
|
||||
const next = resolveAutomaticPresenceStatus(
|
||||
typeof document === "undefined" ? false : document.hidden,
|
||||
lastActivityAtRef.current,
|
||||
Date.now(),
|
||||
);
|
||||
if (next !== automaticStatusRef.current) {
|
||||
automaticStatusRef.current = next;
|
||||
setAutomaticStatus(next);
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
setPresencePreference(readStoredPresencePreference(normalizedPubkey));
|
||||
lastActivityAtRef.current = Date.now();
|
||||
reevaluateAutomaticStatus();
|
||||
}, [normalizedPubkey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -219,9 +240,8 @@ export function usePresenceSession(pubkey?: string) {
|
||||
}, [normalizedPubkey, presencePreference]);
|
||||
|
||||
const recordActivity = React.useEffectEvent(() => {
|
||||
const now = Date.now();
|
||||
setLastActivityAt(now);
|
||||
setStatusClock(now);
|
||||
lastActivityAtRef.current = Date.now();
|
||||
reevaluateAutomaticStatus();
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -238,17 +258,16 @@ export function usePresenceSession(pubkey?: string) {
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
setIsDocumentHidden(false);
|
||||
recordActivity();
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
const hidden = document.hidden;
|
||||
setIsDocumentHidden(hidden);
|
||||
|
||||
if (!hidden) {
|
||||
if (!document.hidden) {
|
||||
recordActivity();
|
||||
return;
|
||||
}
|
||||
// Going hidden can flip the automatic status to away.
|
||||
reevaluateAutomaticStatus();
|
||||
}
|
||||
|
||||
window.addEventListener("pointerdown", handleUserActivity, true);
|
||||
@@ -270,23 +289,13 @@ export function usePresenceSession(pubkey?: string) {
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setStatusClock(Date.now());
|
||||
reevaluateAutomaticStatus();
|
||||
}, PRESENCE_STATUS_TICK_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [normalizedPubkey]);
|
||||
|
||||
const automaticStatus = React.useMemo(
|
||||
() =>
|
||||
resolveAutomaticPresenceStatus(
|
||||
isDocumentHidden,
|
||||
lastActivityAt,
|
||||
statusClock,
|
||||
),
|
||||
[isDocumentHidden, lastActivityAt, statusClock],
|
||||
);
|
||||
const currentStatus =
|
||||
normalizedPubkey.length === 0
|
||||
? "offline"
|
||||
@@ -305,12 +314,8 @@ export function usePresenceSession(pubkey?: string) {
|
||||
status === "online" ? "auto" : status;
|
||||
|
||||
if (nextPreference === "auto") {
|
||||
const now = Date.now();
|
||||
setLastActivityAt(now);
|
||||
setStatusClock(now);
|
||||
setIsDocumentHidden(
|
||||
typeof document === "undefined" ? false : document.hidden,
|
||||
);
|
||||
lastActivityAtRef.current = Date.now();
|
||||
reevaluateAutomaticStatus();
|
||||
}
|
||||
|
||||
setPresencePreference(nextPreference);
|
||||
|
||||
@@ -490,7 +490,19 @@ export function useUpdateProfileMutation() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: UpdateProfileInput) => updateProfile(input),
|
||||
onSuccess: (profile: Profile) => {
|
||||
onMutate: async () => {
|
||||
// Discard any in-flight profile fetch: a background refetch started
|
||||
// before the update (e.g. by a route mount) can resolve AFTER
|
||||
// onSuccess writes the fresh profile below and clobber it with the
|
||||
// pre-update snapshot — the avatar/name then silently reverts until
|
||||
// some later refetch. (Masked historically by users-batch refetch
|
||||
// churn from per-keystroke app renders; exposed when those renders
|
||||
// were removed.)
|
||||
await queryClient.cancelQueries({ queryKey: profileQueryKey });
|
||||
},
|
||||
onSuccess: async (profile: Profile) => {
|
||||
// Cancel again: a refetch may have started while mutationFn awaited.
|
||||
await queryClient.cancelQueries({ queryKey: profileQueryKey });
|
||||
queryClient.setQueryData(profileQueryKey, profile);
|
||||
const relayUrl = activeWorkspace?.relayUrl ?? "";
|
||||
const pubkey = identityQuery.data?.pubkey ?? profile.pubkey;
|
||||
|
||||
Reference in New Issue
Block a user