refactor: simplify widget reload persistence
This commit is contained in:
8 files changed
+79
-241
No files matched your search
@@ -8,11 +8,9 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createSessionQueryPersister,
|
||||
getQueryPersistenceBuster,
|
||||
getQueryPersistenceStorageKey,
|
||||
queryPersistenceBuster,
|
||||
queryPersistenceMaxDataBytes,
|
||||
removeSessionQueryCache,
|
||||
scheduleRestoredDashboardQueryRefresh,
|
||||
shouldPersistDashboardQuery,
|
||||
} from "./query-persistence";
|
||||
|
||||
@@ -71,13 +69,12 @@ describe("widget query persistence", () => {
|
||||
expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a"));
|
||||
expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b"));
|
||||
expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a"));
|
||||
expect(getQueryPersistenceStorageKey("user-a")).not.toContain("user-a");
|
||||
|
||||
const userAKey = getQueryPersistenceStorageKey("user-a");
|
||||
const userBKey = getQueryPersistenceStorageKey("user-b");
|
||||
window.sessionStorage.setItem(userAKey, "a");
|
||||
window.sessionStorage.setItem(userBKey, "b");
|
||||
removeSessionQueryCache("user-a");
|
||||
createSessionQueryPersister("user-a").removeClient();
|
||||
expect(window.sessionStorage.getItem(userAKey)).toBeNull();
|
||||
expect(window.sessionStorage.getItem(userBKey)).toBe("b");
|
||||
});
|
||||
@@ -88,12 +85,12 @@ describe("widget query persistence", () => {
|
||||
const userAPersister = createSessionQueryPersister("user-a");
|
||||
userAPersister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: getQueryPersistenceBuster("user-a"),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(queryClient),
|
||||
});
|
||||
userAPersister.flush();
|
||||
|
||||
await expect(createSessionQueryPersister("user-b").restoreClient()).resolves.toBeUndefined();
|
||||
expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined();
|
||||
expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -105,7 +102,7 @@ describe("widget query persistence", () => {
|
||||
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: getQueryPersistenceBuster(scope),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: {
|
||||
mutations: [],
|
||||
queries: [...dehydrate(widget.queryClient).queries, ...dehydrate(unrelated.queryClient).queries],
|
||||
@@ -130,7 +127,7 @@ describe("widget query persistence", () => {
|
||||
expect(shouldPersistDashboardQuery(query)).toBe(true);
|
||||
const persistedClient: PersistedClient = {
|
||||
timestamp: Date.now(),
|
||||
buster: getQueryPersistenceBuster(scope),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
|
||||
};
|
||||
|
||||
@@ -141,13 +138,11 @@ describe("widget query persistence", () => {
|
||||
expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z"));
|
||||
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
|
||||
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
|
||||
expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true);
|
||||
expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error");
|
||||
const restoredPredicate = persister.takeRestoredDashboardQueryPredicate();
|
||||
expect(restoredPredicate?.(query)).toBe(true);
|
||||
expect(persister.takeRestoredDashboardQueryPredicate()).toBeUndefined();
|
||||
|
||||
window.sessionStorage.setItem(key, "not-valid-superjson");
|
||||
await expect(persister.restoreClient()).resolves.toBeUndefined();
|
||||
expect(persister.restoreClient()).toBeUndefined();
|
||||
expect(window.sessionStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -165,7 +160,7 @@ describe("widget query persistence", () => {
|
||||
key,
|
||||
stringify({
|
||||
timestamp: Date.now(),
|
||||
buster: getQueryPersistenceBuster(scope),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: {
|
||||
mutations: [{ state: { status: "pending" } }],
|
||||
queries: [
|
||||
@@ -198,9 +193,9 @@ describe("widget query persistence", () => {
|
||||
});
|
||||
|
||||
test.each([
|
||||
["expired", 1, getQueryPersistenceBuster("user-a")],
|
||||
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), getQueryPersistenceBuster("user-b")],
|
||||
["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, getQueryPersistenceBuster("user-a")],
|
||||
["expired", 1, queryPersistenceBuster],
|
||||
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"],
|
||||
["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster],
|
||||
])("discards %s cache entries", async (_case, timestamp, storedBuster) => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z"));
|
||||
@@ -217,7 +212,7 @@ describe("widget query persistence", () => {
|
||||
queryClient: restoredClient,
|
||||
persister,
|
||||
maxAge: 5 * 60 * 1_000,
|
||||
buster: getQueryPersistenceBuster(scope),
|
||||
buster: queryPersistenceBuster,
|
||||
});
|
||||
|
||||
expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined();
|
||||
@@ -236,7 +231,7 @@ describe("widget query persistence", () => {
|
||||
});
|
||||
const persistedClient: PersistedClient = {
|
||||
timestamp: Date.now(),
|
||||
buster: getQueryPersistenceBuster("user-a"),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: {
|
||||
mutations: [],
|
||||
queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries],
|
||||
@@ -264,12 +259,12 @@ describe("widget query persistence", () => {
|
||||
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: getQueryPersistenceBuster("user-a"),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(first.queryClient),
|
||||
});
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: getQueryPersistenceBuster("user-a"),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(latest.queryClient),
|
||||
});
|
||||
|
||||
@@ -283,48 +278,6 @@ describe("widget query persistence", () => {
|
||||
expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 });
|
||||
});
|
||||
|
||||
test("refreshes only restored cache data without awaiting the refresh", async () => {
|
||||
vi.useFakeTimers();
|
||||
const scope = "user-a";
|
||||
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
|
||||
const cached = createSuccessfulQuery(queryKey, { temp: 20 }, 10);
|
||||
const persister = createSessionQueryPersister(scope);
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: getQueryPersistenceBuster(scope),
|
||||
clientState: dehydrate(cached.queryClient),
|
||||
});
|
||||
persister.flush();
|
||||
await persister.restoreClient();
|
||||
|
||||
const fresh = createSuccessfulQuery(queryKey, { temp: 21 }, 20);
|
||||
const neverSettles = new Promise<void>(() => undefined);
|
||||
const invalidateQueries = vi.spyOn(fresh.queryClient, "invalidateQueries").mockReturnValue(neverSettles);
|
||||
|
||||
expect(scheduleRestoredDashboardQueryRefresh(fresh.queryClient, persister)).toBeUndefined();
|
||||
expect(invalidateQueries).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(invalidateQueries).toHaveBeenCalledOnce();
|
||||
expect(invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ type: "all", refetchType: "active" }));
|
||||
|
||||
const predicate = invalidateQueries.mock.calls[0]?.[0]?.predicate;
|
||||
expect(predicate?.(cached.query)).toBe(true);
|
||||
expect(predicate?.(fresh.query)).toBe(false);
|
||||
});
|
||||
|
||||
test("does not invalidate fresh queries when no cache was restored", async () => {
|
||||
vi.useFakeTimers();
|
||||
const persister = createSessionQueryPersister("empty");
|
||||
await persister.restoreClient();
|
||||
const fresh = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 });
|
||||
const invalidateQueries = vi.spyOn(fresh.queryClient, "invalidateQueries");
|
||||
|
||||
scheduleRestoredDashboardQueryRefresh(fresh.queryClient, persister);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("treats blocked storage operations as an empty cache", async () => {
|
||||
const blockedStorage = {
|
||||
getItem: () => {
|
||||
@@ -340,17 +293,16 @@ describe("widget query persistence", () => {
|
||||
const persister = createSessionQueryPersister("blocked", blockedStorage);
|
||||
const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 });
|
||||
|
||||
await expect(persister.restoreClient()).resolves.toBeUndefined();
|
||||
expect(persister.restoreClient()).toBeUndefined();
|
||||
expect(() => {
|
||||
persister.persistClient({
|
||||
timestamp: Date.now(),
|
||||
buster: getQueryPersistenceBuster("blocked"),
|
||||
buster: queryPersistenceBuster,
|
||||
clientState: dehydrate(queryClient),
|
||||
});
|
||||
persister.flush();
|
||||
}).not.toThrow();
|
||||
expect(() => persister.removeClient()).not.toThrow();
|
||||
expect(() => removeSessionQueryCache("blocked", blockedStorage)).not.toThrow();
|
||||
});
|
||||
|
||||
test("treats an unavailable session storage getter as an empty cache", async () => {
|
||||
@@ -359,7 +311,6 @@ describe("widget query persistence", () => {
|
||||
});
|
||||
|
||||
expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow();
|
||||
expect(() => removeSessionQueryCache("blocked-getter")).not.toThrow();
|
||||
await expect(createSessionQueryPersister("blocked-getter").restoreClient()).resolves.toBeUndefined();
|
||||
expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
|
||||
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
|
||||
import { parse, stringify } from "superjson";
|
||||
@@ -7,40 +7,22 @@ import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/que
|
||||
|
||||
export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs;
|
||||
export const queryPersistenceMaxDataBytes = 256 * 1024;
|
||||
const queryPersistenceBuster = "v1-session-superjson";
|
||||
export const queryPersistenceBuster = "v2-session-superjson";
|
||||
|
||||
const queryPersistenceStoragePrefix = "homarr:widget-query-cache:v1";
|
||||
|
||||
const hashScope = (scope: string) => {
|
||||
let hash = 2_166_136_261;
|
||||
for (let index = 0; index < scope.length; index += 1) {
|
||||
hash ^= scope.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16_777_619);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
};
|
||||
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
|
||||
|
||||
export const getQueryPersistenceStorageKey = (scope: string | null) =>
|
||||
`${queryPersistenceStoragePrefix}:${hashScope(scope ?? "anonymous")}`;
|
||||
|
||||
export const getQueryPersistenceBuster = (scope: string | null) => `${queryPersistenceBuster}:${scope ?? "anonymous"}`;
|
||||
`${queryPersistenceStoragePrefix}:${encodeURIComponent(scope ?? "anonymous")}`;
|
||||
|
||||
interface PersistableQuery {
|
||||
queryHash?: string;
|
||||
queryKey: QueryKey;
|
||||
state: {
|
||||
data: unknown;
|
||||
dataUpdatedAt: number;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
type RestoredDashboardQueryPredicate = (query: PersistableQuery) => boolean;
|
||||
|
||||
export interface SessionQueryPersister extends Persister {
|
||||
flush: () => void;
|
||||
takeRestoredDashboardQueryPredicate: () => RestoredDashboardQueryPredicate | undefined;
|
||||
}
|
||||
export type SessionQueryPersister = Persister & { flush: () => void };
|
||||
|
||||
export const shouldPersistDashboardQuery = (query: PersistableQuery) => {
|
||||
if (query.state.data === undefined || query.state.dataUpdatedAt <= 0 || !isWidgetDataQueryKey(query.queryKey)) {
|
||||
@@ -66,6 +48,7 @@ const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
|
||||
fetchFailureCount: 0,
|
||||
fetchFailureReason: null,
|
||||
fetchStatus: "idle",
|
||||
isInvalidated: true,
|
||||
status: "success",
|
||||
},
|
||||
});
|
||||
@@ -74,9 +57,6 @@ const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
|
||||
...client,
|
||||
clientState: {
|
||||
mutations: [],
|
||||
// Keep this guard inside the persister as well as in the provider's
|
||||
// dehydration options so a future caller cannot accidentally store an
|
||||
// unrelated query payload.
|
||||
queries: client.clientState.queries
|
||||
.filter((query) => shouldPersistDashboardQuery(query as PersistableQuery))
|
||||
.map(sanitizePersistedQuery),
|
||||
@@ -120,9 +100,7 @@ const getSessionStorage = (): Storage | undefined => {
|
||||
const removeStorageItem = (storage: Storage | undefined, key: string) => {
|
||||
try {
|
||||
storage?.removeItem(key);
|
||||
} catch {
|
||||
// Persistence is optional. Treat blocked or unavailable storage as an empty cache.
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
export const createSessionQueryPersister = (
|
||||
@@ -130,8 +108,6 @@ export const createSessionQueryPersister = (
|
||||
storage: Storage | undefined = getSessionStorage(),
|
||||
): SessionQueryPersister => {
|
||||
const key = getQueryPersistenceStorageKey(scope);
|
||||
const buster = getQueryPersistenceBuster(scope);
|
||||
let restoredQueries = new Map<string, number>();
|
||||
let pendingClient: PersistedClient | undefined;
|
||||
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
@@ -175,15 +151,13 @@ export const createSessionQueryPersister = (
|
||||
if (persistTimer !== undefined) return;
|
||||
persistTimer = setTimeout(flush, 0);
|
||||
},
|
||||
async restoreClient() {
|
||||
restoredQueries = new Map();
|
||||
restoreClient() {
|
||||
try {
|
||||
const storedValue = storage?.getItem(key);
|
||||
if (!storedValue) return undefined;
|
||||
|
||||
const client = sanitizeRestoredClient(parse<unknown>(storedValue));
|
||||
const age = client ? Date.now() - client.timestamp : Number.POSITIVE_INFINITY;
|
||||
if (!client || age < 0 || age > queryPersistenceMaxAgeMs || client.buster !== buster) {
|
||||
if (!client || client.timestamp > Date.now()) {
|
||||
removeStorageItem(storage, key);
|
||||
return undefined;
|
||||
}
|
||||
@@ -193,9 +167,6 @@ export const createSessionQueryPersister = (
|
||||
return undefined;
|
||||
}
|
||||
|
||||
restoredQueries = new Map(
|
||||
client.clientState.queries.map((query) => [query.queryHash, query.state.dataUpdatedAt]),
|
||||
);
|
||||
return client;
|
||||
} catch {
|
||||
removeStorageItem(storage, key);
|
||||
@@ -205,34 +176,8 @@ export const createSessionQueryPersister = (
|
||||
removeClient() {
|
||||
clearPersistTimer();
|
||||
pendingClient = undefined;
|
||||
restoredQueries = new Map();
|
||||
removeStorageItem(storage, key);
|
||||
},
|
||||
flush,
|
||||
takeRestoredDashboardQueryPredicate() {
|
||||
if (restoredQueries.size === 0) return undefined;
|
||||
const restoredSnapshot = restoredQueries;
|
||||
restoredQueries = new Map();
|
||||
return (query) =>
|
||||
query.queryHash !== undefined &&
|
||||
restoredSnapshot.get(query.queryHash) === query.state.dataUpdatedAt &&
|
||||
shouldPersistDashboardQuery(query);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const scheduleRestoredDashboardQueryRefresh = (queryClient: QueryClient, persister: SessionQueryPersister) => {
|
||||
const predicate = persister.takeRestoredDashboardQueryPredicate();
|
||||
if (!predicate) return;
|
||||
|
||||
setTimeout(() => {
|
||||
// Mark restored data stale even if its widget observer has not mounted yet.
|
||||
// TanStack still refetches only active queries; a widget that mounts after
|
||||
// this callback observes stale cached data and refreshes immediately.
|
||||
void queryClient.invalidateQueries({ type: "all", refetchType: "active", predicate });
|
||||
}, 0);
|
||||
};
|
||||
|
||||
export const removeSessionQueryCache = (scope: string | null, storage: Storage | undefined = getSessionStorage()) => {
|
||||
removeStorageItem(storage, getQueryPersistenceStorageKey(scope));
|
||||
};
|
||||
@@ -38,9 +38,8 @@ import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
|
||||
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
|
||||
import {
|
||||
createSessionQueryPersister,
|
||||
getQueryPersistenceBuster,
|
||||
queryPersistenceBuster,
|
||||
queryPersistenceMaxAgeMs,
|
||||
scheduleRestoredDashboardQueryRefresh,
|
||||
shouldPersistDashboardQuery,
|
||||
} from "./query-persistence";
|
||||
import type { SessionQueryPersister } from "./query-persistence";
|
||||
@@ -88,9 +87,7 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
|
||||
currentScope={sessionQueryScope}
|
||||
onScopeChange={handleScopeChange}
|
||||
>
|
||||
<ScopedTRPCReactProvider sessionQueryScope={initialSessionQueryScope} queryPersister={queryPersister}>
|
||||
{children}
|
||||
</ScopedTRPCReactProvider>
|
||||
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
|
||||
</SessionQueryScopeGuard>
|
||||
);
|
||||
}
|
||||
@@ -99,9 +96,8 @@ const reloadPage = () => window.location.reload();
|
||||
|
||||
const ScopedTRPCReactProvider = ({
|
||||
children,
|
||||
sessionQueryScope,
|
||||
queryPersister,
|
||||
}: PropsWithChildren<{ sessionQueryScope: string | null; queryPersister: SessionQueryPersister }>) => {
|
||||
}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => {
|
||||
const wsClient = useMemo(
|
||||
() =>
|
||||
createWSClient({
|
||||
@@ -219,12 +215,9 @@ const ScopedTRPCReactProvider = ({
|
||||
persistOptions={{
|
||||
persister: queryPersister,
|
||||
maxAge: queryPersistenceMaxAgeMs,
|
||||
buster: getQueryPersistenceBuster(sessionQueryScope),
|
||||
buster: queryPersistenceBuster,
|
||||
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
|
||||
}}
|
||||
onSuccess={() => {
|
||||
scheduleRestoredDashboardQueryRefresh(queryClient, queryPersister);
|
||||
}}
|
||||
>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
|
||||
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
|
||||
|
||||
@@ -16,79 +16,39 @@ const collectRuntimeSourceFiles = (directory: string): string[] =>
|
||||
|
||||
const rootWidgetsModule = "@homarr/widgets";
|
||||
|
||||
interface AstNode {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const isAstNode = (value: unknown): value is AstNode =>
|
||||
typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
|
||||
|
||||
const getAstNode = (value: unknown) => (isAstNode(value) ? value : undefined);
|
||||
const getAstNodes = (value: unknown) => (Array.isArray(value) ? value.filter(isAstNode) : []);
|
||||
|
||||
const isRootWidgetsModule = (node: unknown) => {
|
||||
const astNode = getAstNode(node);
|
||||
return astNode?.type === "StringLiteral" && astNode.value === rootWidgetsModule;
|
||||
};
|
||||
|
||||
const hasRuntimeRootWidgetImport = (source: string, fileName: string): boolean => {
|
||||
const sourceFile = parse(source, {
|
||||
const { program } = parse(source, {
|
||||
sourceFilename: fileName,
|
||||
sourceType: "unambiguous",
|
||||
plugins: ["typescript", "jsx", "decorators-legacy", "importAttributes"],
|
||||
});
|
||||
let found = false;
|
||||
const hasStaticImport = program.body.some(
|
||||
(node) =>
|
||||
node.type === "ImportDeclaration" &&
|
||||
node.source.value === rootWidgetsModule &&
|
||||
node.importKind !== "type" &&
|
||||
(node.specifiers.length === 0 ||
|
||||
node.specifiers.some((specifier) => specifier.type !== "ImportSpecifier" || specifier.importKind !== "type")),
|
||||
);
|
||||
return hasStaticImport || hasDynamicRootWidgetImport(program);
|
||||
};
|
||||
|
||||
const visit = (node: AstNode) => {
|
||||
if (found) return;
|
||||
const hasDynamicRootWidgetImport = (value: unknown): boolean => {
|
||||
if (Array.isArray(value)) return value.some(hasDynamicRootWidgetImport);
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
|
||||
if (node.type === "ImportDeclaration" && isRootWidgetsModule(node.source)) {
|
||||
const specifiers = getAstNodes(node.specifiers);
|
||||
if (
|
||||
node.importKind !== "type" &&
|
||||
(specifiers.length === 0 ||
|
||||
specifiers.some((specifier) => specifier.type !== "ImportSpecifier" || specifier.importKind !== "type"))
|
||||
) {
|
||||
found = true;
|
||||
}
|
||||
} else if (
|
||||
(node.type === "ExportNamedDeclaration" || node.type === "ExportAllDeclaration") &&
|
||||
isRootWidgetsModule(node.source)
|
||||
) {
|
||||
const specifiers = getAstNodes(node.specifiers);
|
||||
if (
|
||||
node.exportKind !== "type" &&
|
||||
(node.type === "ExportAllDeclaration" ||
|
||||
specifiers.length === 0 ||
|
||||
specifiers.some((specifier) => specifier.exportKind !== "type"))
|
||||
) {
|
||||
found = true;
|
||||
}
|
||||
} else if (node.type === "TSImportEqualsDeclaration" && node.importKind !== "type") {
|
||||
const moduleReference = getAstNode(node.moduleReference);
|
||||
if (moduleReference?.type === "TSExternalModuleReference" && isRootWidgetsModule(moduleReference.expression)) {
|
||||
found = true;
|
||||
}
|
||||
} else if (node.type === "CallExpression") {
|
||||
const callee = getAstNode(node.callee);
|
||||
const argument = getAstNodes(node.arguments)[0];
|
||||
if (
|
||||
isRootWidgetsModule(argument) &&
|
||||
(callee?.type === "Import" || (callee?.type === "Identifier" && callee.name === "require"))
|
||||
) {
|
||||
found = true;
|
||||
}
|
||||
const node = value as Record<string, unknown>;
|
||||
if (node.type === "CallExpression") {
|
||||
const callee = node.callee as Record<string, unknown> | undefined;
|
||||
const argument = Array.isArray(node.arguments)
|
||||
? (node.arguments[0] as Record<string, unknown> | undefined)
|
||||
: undefined;
|
||||
if (callee?.type === "Import" && argument?.type === "StringLiteral" && argument.value === rootWidgetsModule) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of Object.values(node)) {
|
||||
if (isAstNode(value)) visit(value);
|
||||
else if (Array.isArray(value)) value.filter(isAstNode).forEach(visit);
|
||||
}
|
||||
};
|
||||
|
||||
visit(sourceFile as unknown as AstNode);
|
||||
return found;
|
||||
return Object.values(node).some(hasDynamicRootWidgetImport);
|
||||
};
|
||||
|
||||
describe("widget import boundary", () => {
|
||||
@@ -104,14 +64,11 @@ describe("widget import boundary", () => {
|
||||
test.each([
|
||||
['import type { WidgetDefinition } from "@homarr/widgets";', false],
|
||||
['import { type WidgetDefinition } from "@homarr/widgets";', false],
|
||||
['export type { WidgetDefinition } from "@homarr/widgets";', false],
|
||||
["const example = 'import { widgets } from \"@homarr/widgets\";';", false],
|
||||
['// import { widgets } from "@homarr/widgets";', false],
|
||||
['import { widgets } from "@homarr/widgets";', true],
|
||||
['import { type WidgetDefinition, widgets } from "@homarr/widgets";', true],
|
||||
['export * from "@homarr/widgets";', true],
|
||||
['import /* chunk */ ( /* source */ "@homarr/widgets" );', true],
|
||||
['require("@homarr/widgets");', true],
|
||||
])("detects runtime root imports in %s", (source, expected) => {
|
||||
expect(hasRuntimeRootWidgetImport(source, "fixture.ts")).toBe(expected);
|
||||
});
|
||||
|
||||
@@ -102,9 +102,6 @@ describe("lazy widget application graph", () => {
|
||||
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
const browser = await chromium.launch();
|
||||
// Playwright cannot route requests handled by a service worker. Keep it
|
||||
// blocked in this context so the delayed refresh below is observable, then
|
||||
// verify registration separately in an unblocked context.
|
||||
const context = await browser.newContext({ serviceWorkers: "block" });
|
||||
const page = await context.newPage();
|
||||
const pageErrors: Error[] = [];
|
||||
|
||||
@@ -10,7 +10,7 @@ import { AdvancedClockView } from "./advanced-view";
|
||||
import { clockTimeFormatShowsSeconds, resolveClockTimeFormat } from "./format";
|
||||
import { useCurrentTime } from "./use-current-time";
|
||||
import { ClockWeatherSummary } from "./weather-summary";
|
||||
import { getResolvedLocalTimeZone, isTimeZoneSupported } from "./world-clock";
|
||||
import { isTimeZoneSupported } from "./world-clock";
|
||||
|
||||
dayjs.extend(advancedFormat);
|
||||
|
||||
@@ -18,10 +18,8 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat);
|
||||
const time = useCurrentTime({ showSeconds });
|
||||
const initialLocalTimeZone = useWidgetLocalTimeZone();
|
||||
const requestedTimeZone = options.useCustomTimezone
|
||||
? options.timezone
|
||||
: (initialLocalTimeZone ?? getResolvedLocalTimeZone());
|
||||
const localTimeZone = useWidgetLocalTimeZone();
|
||||
const requestedTimeZone = options.useCustomTimezone ? options.timezone : localTimeZone;
|
||||
const primaryTimeZoneInvalid = !isTimeZoneSupported(requestedTimeZone);
|
||||
const primaryTimeZone = primaryTimeZoneInvalid ? "UTC" : requestedTimeZone;
|
||||
const zonedTime = time === null ? null : dayjs(time).tz(primaryTimeZone);
|
||||
|
||||
@@ -24,9 +24,11 @@ describe("WidgetTimeProvider", () => {
|
||||
vi.useFakeTimers();
|
||||
const serverTimestamp = new Date("2026-08-22T12:34:00.000Z").getTime();
|
||||
const clientTimestamp = serverTimestamp + 60_000;
|
||||
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
const serverTimeZone = browserTimeZone === "UTC" ? "Europe/Paris" : "UTC";
|
||||
vi.setSystemTime(serverTimestamp);
|
||||
const content = (
|
||||
<WidgetTimeProvider initialTimestamp={serverTimestamp} initialTimeZone="UTC">
|
||||
<WidgetTimeProvider initialTimestamp={serverTimestamp} initialTimeZone={serverTimeZone}>
|
||||
<TimeProbe />
|
||||
</WidgetTimeProvider>
|
||||
);
|
||||
@@ -34,15 +36,12 @@ describe("WidgetTimeProvider", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = html;
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({
|
||||
timeZone: "Europe/Paris",
|
||||
} as Intl.ResolvedDateTimeFormatOptions);
|
||||
vi.setSystemTime(clientTimestamp);
|
||||
|
||||
expect(host.textContent).toBe("2026-08-22T12:34:00.000Z|UTC");
|
||||
expect(host.textContent).toBe(`2026-08-22T12:34:00.000Z|${serverTimeZone}`);
|
||||
const root = hydrateRoot(host, content);
|
||||
await act(async () => undefined);
|
||||
expect(host.textContent).toBe("2026-08-22T12:35:00.000Z|Europe/Paris");
|
||||
expect(host.textContent).toBe(`2026-08-22T12:35:00.000Z|${browserTimeZone}`);
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => root.unmount());
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { createContext, useContext, useSyncExternalStore } from "react";
|
||||
|
||||
interface WidgetTimeContextValue {
|
||||
initialTimestamp: number | null;
|
||||
localTimeZone: string | null;
|
||||
}
|
||||
|
||||
const WidgetTimeContext = createContext<WidgetTimeContextValue>({ initialTimestamp: null, localTimeZone: null });
|
||||
const WidgetTimestampContext = createContext<number | null>(null);
|
||||
const WidgetTimeZoneContext = createContext("UTC");
|
||||
|
||||
interface WidgetTimeProviderProps {
|
||||
initialTimestamp: number;
|
||||
@@ -19,19 +15,21 @@ export const WidgetTimeProvider = ({
|
||||
children,
|
||||
initialTimestamp,
|
||||
initialTimeZone,
|
||||
}: PropsWithChildren<WidgetTimeProviderProps>) => {
|
||||
const [localTimeZone, setLocalTimeZone] = useState(() => getSupportedTimeZone(initialTimeZone));
|
||||
}: PropsWithChildren<WidgetTimeProviderProps>) => (
|
||||
<WidgetTimestampContext.Provider value={initialTimestamp}>
|
||||
<WidgetTimeZoneContext.Provider value={getSupportedTimeZone(initialTimeZone)}>
|
||||
{children}
|
||||
</WidgetTimeZoneContext.Provider>
|
||||
</WidgetTimestampContext.Provider>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalTimeZone(getResolvedLocalTimeZone());
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ initialTimestamp, localTimeZone }), [initialTimestamp, localTimeZone]);
|
||||
return <WidgetTimeContext.Provider value={value}>{children}</WidgetTimeContext.Provider>;
|
||||
export const useWidgetInitialTimestamp = () => useContext(WidgetTimestampContext);
|
||||
export const useWidgetLocalTimeZone = () => {
|
||||
const initialTimeZone = useContext(WidgetTimeZoneContext);
|
||||
return useSyncExternalStore(subscribeToTimeZone, getResolvedLocalTimeZone, () => initialTimeZone);
|
||||
};
|
||||
|
||||
export const useWidgetInitialTimestamp = () => useContext(WidgetTimeContext).initialTimestamp;
|
||||
export const useWidgetLocalTimeZone = () => useContext(WidgetTimeContext).localTimeZone;
|
||||
const subscribeToTimeZone = () => () => undefined;
|
||||
|
||||
const getResolvedLocalTimeZone = () => getSupportedTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
|
||||
|
||||
Reference in new issue
Block a user