feat(desktop): adding ui state into the history stack (#967)

Signed-off-by: tho <80c5f18be5aafa62cf6198c6335963ba3306b595288117c8ea2f805fc9bdc94a@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: tho <80c5f18be5aafa62cf6198c6335963ba3306b595288117c8ea2f805fc9bdc94a@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Taylor Ho
2026-06-16 23:37:09 -07:00
committed by GitHub
parent c22c54e7ae
commit 538f33341f
24 changed files with 1083 additions and 386 deletions

View File

@@ -0,0 +1,99 @@
import * as React from "react";
import { useNavigate, useSearch } from "@tanstack/react-router";
/**
* URL-search-param-backed UI state, so it lives in the history stack:
* back/forward restores the value a given entry carried, and reloads
* restore it from the URL.
*
* Patch calls made synchronously within one event handler are coalesced
* into a single navigation, so each user action produces exactly one
* history entry even when a handler updates several keys.
*/
export type HistorySearchSetterOptions = {
/** Rewrite the current entry instead of pushing a new one. */
replace?: boolean;
};
export function useHistorySearchState<K extends string>(keys: readonly K[]) {
const navigate = useNavigate();
const search = useSearch({ strict: false } as never) as Partial<
Record<K, string>
>;
const values = {} as Record<K, string | null>;
for (const key of keys) {
values[key] = search[key] ?? null;
}
const currentValuesRef = React.useRef(values);
currentValuesRef.current = values;
const keysRef = React.useRef(keys);
keysRef.current = keys;
const pendingRef = React.useRef<{
patch: Partial<Record<K, string | null>>;
replace: boolean;
} | null>(null);
const applyPatch = React.useCallback(
(
patch: Partial<Record<K, string | null>>,
options?: HistorySearchSetterOptions,
) => {
const pending = pendingRef.current;
if (pending) {
Object.assign(pending.patch, patch);
pending.replace = pending.replace || Boolean(options?.replace);
return;
}
pendingRef.current = {
patch: { ...patch },
replace: Boolean(options?.replace),
};
queueMicrotask(() => {
const flush = pendingRef.current;
pendingRef.current = null;
if (!flush) {
return;
}
const currentValues = currentValuesRef.current;
const isChanged = keysRef.current.some(
(key) =>
flush.patch[key] !== undefined &&
(flush.patch[key] ?? null) !== currentValues[key],
);
if (!isChanged) {
return;
}
void navigate({
to: ".",
search: (previousSearch: Record<string, unknown>) => {
const nextSearch = { ...previousSearch };
for (const key of keysRef.current) {
const value = flush.patch[key];
if (value === undefined) {
continue;
}
if (value === null) {
delete nextSearch[key];
} else {
nextSearch[key] = value;
}
}
return nextSearch;
},
replace: flush.replace,
resetScroll: false,
} as never);
});
},
[navigate],
);
return { applyPatch, values };
}