refactor: trim widget reload plumbing

This commit is contained in:
Thomas Camlong committed 2026-08-23 09:00:33 +02:00
1 parent a22fb60647
commit 0073dfe044
7 files changed
+2 -66

No files matched your search

@@ -120,8 +120,6 @@ describe("widget query persistence", () => {
persister.flush();
persister.stop();
// A torn down provider clears its query client, which would otherwise be
// persisted as an empty cache on top of the snapshot we just saved.
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
@@ -135,41 +133,19 @@ describe("widget query persistence", () => {
test("removes the cache instead of storing an empty one", () => {
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const { queryClient } = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
const persister = createSessionQueryPersister(scope);
window.sessionStorage.setItem(key, "stale");
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient),
clientState: { mutations: [], queries: [] },
});
persister.flush();
expect(window.sessionStorage.getItem(key)).toBeNull();
});
test("persister itself rejects unrelated query payloads", async () => {
const scope = "user-a";
const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 });
const unrelated = createSuccessfulQuery([["user", "session"], { type: "query" }], { secret: true });
const persister = createSessionQueryPersister(scope);
persister.persistClient({
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: {
mutations: [],
queries: [...dehydrate(widget.queryClient).queries, ...dehydrate(unrelated.queryClient).queries],
},
});
persister.flush();
const storedValue = window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope));
expect(storedValue).not.toContain("secret");
expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1);
});
test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => {
vi.useFakeTimers();
const scope = "user-a";
@@ -25,11 +25,6 @@ interface PersistableQuery {
export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void };
const textEncoder = new TextEncoder();
// React Query re-dehydrates the whole cache on every cache event, so this
// predicate runs for every query several times per refresh cycle. Measuring a
// payload means serializing it, which dwarfs everything else in the pass, so
// remember the verdict per data reference instead of re-encoding every widget
// payload every time.
const budgetVerdictCache = new WeakMap<object, boolean>();
const measureFitsBudget = (data: unknown) => {
@@ -57,7 +52,6 @@ const holdsDashboardData = (query: PersistableQuery) =>
query.state.dataUpdatedAt > 0 &&
isWidgetDataQueryKey(query.queryKey);
/** Write path: dashboard data that is small enough to keep in session storage. */
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
holdsDashboardData(query) && fitsStorageBudget(query.state.data);
@@ -82,18 +76,13 @@ const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
...client,
clientState: {
mutations: [],
queries: client.clientState.queries
.filter((query) => shouldPersistDashboardQuery(query as PersistableQuery))
.map(sanitizePersistedQuery),
queries: client.clientState.queries.map(sanitizePersistedQuery),
},
});
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
// Read path: the payload was already budgeted when it was written, and this runs
// before any query is allowed to fetch, so it deliberately skips the expensive
// size measurement and only rejects anything that is not restorable widget data.
const isRestorableQuery = (value: unknown): value is DehydratedQuery => {
if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false;
if (!isRecord(value.state) || typeof value.state.status !== "string") return false;
@@ -133,9 +122,6 @@ const removeStorageItem = (storage: Storage | undefined, key: string) => {
}
};
// Signing out navigates away before the session scope guard can observe the
// change, so a previous account's cached dashboard data would otherwise linger
// in the tab. Only the active scope may keep a cache.
const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => {
if (!storage) return;
@@ -231,9 +217,6 @@ export const createSessionQueryPersister = (
removeStorageItem(storage, key);
},
flush,
// Called when the provider tears down, after the last flush: the teardown
// also clears the query cache, and that clear must not be able to overwrite
// the snapshot we just saved with an empty one.
stop() {
stopped = true;
clearPersistTimer();
@@ -152,9 +152,6 @@ const ScopedTRPCReactProvider = ({
return client;
});
// Declared before the cache teardown below so its cleanup runs first: the
// persister has to save the real cache and shut down before `clear()` empties
// it, otherwise the resulting "empty cache" event would be persisted instead.
useEffect(() => {
const flush = () => queryPersister.flush();
window.addEventListener("pagehide", flush);
@@ -96,11 +96,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
{/*
Rendered on the server, so this is the server's clock and time
zone. Widgets paint with it immediately and switch to the browser
values right after hydration.
*/}
<ClientBoard
initialTimestamp={Date.now()}
initialTimeZone={Intl.DateTimeFormat().resolvedOptions().timeZone}
@@ -161,7 +161,6 @@ const LoadedBoardItemContent = ({
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
const isWidgetRefreshing =
useIsFetching({
type: "active",
predicate: (query) =>
query.state.data !== undefined &&
matchesWidgetItemQuery(
@@ -343,11 +342,6 @@ const LoadedBoardItemContent = ({
/>
</ErrorBoundary>
</Box>
{/*
Purely decorative: widgets keep showing their previous data while they
refresh, and every widget refreshes on a timer, so announcing this would
make assistive technology repeat "loading" for the whole board forever.
*/}
{isWidgetRefreshing && (
<Box className={itemContentClasses.refreshIndicator} data-widget-refreshing aria-hidden>
<Loader size="xs" />
-2
View File
@@ -107,8 +107,6 @@ describe("lazy widget application graph", () => {
const pageErrors: Error[] = [];
const hydrationErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error));
// Production React reports hydration mismatches as minified error codes with
// a react.dev link, so matching the development wording alone never fires.
const hydrationErrorPattern = /hydrat|did not match|react\.dev\/errors\/4(18|2[1235])|react error #4(18|2[1235])/i;
page.on("console", (message) => {
if (message.type() === "error" && hydrationErrorPattern.test(message.text())) {
@@ -27,10 +27,6 @@ export const WidgetTimeProvider = ({
export const useWidgetInitialTimestamp = () => useContext(WidgetTimestampContext);
/**
* Resolves to the browser time zone, but renders (and hydrates) with the time
* zone the server used so widgets can paint a time before hydration finishes.
*/
export const useWidgetLocalTimeZone = () => {
const initialTimeZone = useContext(WidgetTimeZoneContext);
return useSyncExternalStore(subscribeToTimeZone, getResolvedLocalTimeZone, () => initialTimeZone);
@@ -38,9 +34,6 @@ export const useWidgetLocalTimeZone = () => {
const subscribeToTimeZone = () => () => undefined;
// React calls the snapshot getter on every render of every consumer, and
// resolving a time zone builds `Intl.DateTimeFormat` instances, so keep the
// answer around: it cannot change without a new document.
let resolvedLocalTimeZone: string | undefined;
const getResolvedLocalTimeZone = () => {