refactor: trim widget reload plumbing
This commit is contained in:
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" />
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in new issue
Block a user