From 0073dfe044fb01b9afc9351bee31741f91eac347 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 22 Aug 2026 18:36:40 +0200 Subject: [PATCH] refactor: trim widget reload plumbing --- .../query-persistence.spec.ts | 26 +------------------ .../_client-providers/query-persistence.ts | 19 +------------- .../app/[locale]/_client-providers/trpc.tsx | 3 --- .../[locale]/boards/(content)/_creator.tsx | 5 ---- .../components/board/items/item-content.tsx | 6 ----- e2e/lazy-widgets.spec.ts | 2 -- .../src/common/widget-time-context.tsx | 7 ----- 7 files changed, 2 insertions(+), 66 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts index 954797a22..7b57a1c68 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts @@ -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"; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts index f899a881d..e5145ff5c 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts @@ -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(); 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 => 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(); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 52bc9ac29..dc5242de4 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -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); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index ce91b82a2..991b4ce2e 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -96,11 +96,6 @@ export const createBoardContentPage = >( return ( - {/* - 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. - */} 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 = ({ /> - {/* - 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 && ( diff --git a/e2e/lazy-widgets.spec.ts b/e2e/lazy-widgets.spec.ts index 511dcb966..9bdaac3a9 100644 --- a/e2e/lazy-widgets.spec.ts +++ b/e2e/lazy-widgets.spec.ts @@ -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())) { diff --git a/packages/widgets/src/common/widget-time-context.tsx b/packages/widgets/src/common/widget-time-context.tsx index 347975369..021663784 100644 --- a/packages/widgets/src/common/widget-time-context.tsx +++ b/packages/widgets/src/common/widget-time-context.tsx @@ -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 = () => {