fix(ui): share useColorMode and the relative-time tick instead of one per component (#4994)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Amir Raminfar
2026-09-03 23:44:17 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent bf9d6d41f2
commit 934afcb555
7 changed files with 224 additions and 18 deletions
+5 -7
View File
@@ -3,7 +3,7 @@
</template>
<script lang="ts" setup>
const mode = useColorMode();
const { theme } = useResolvedTheme();
watchEffect(() => {
if (smallerScrollbars.value) {
document.documentElement.classList.add("has-custom-scrollbars");
@@ -11,12 +11,10 @@ watchEffect(() => {
document.documentElement.classList.remove("has-custom-scrollbars");
}
let theme = lightTheme.value;
if (theme === "auto") {
theme = mode.value;
}
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", theme == "dark" ? "#121212" : "#F5F5F5");
document.documentElement.setAttribute("data-theme", theme);
document
.querySelector('meta[name="theme-color"]')
?.setAttribute("content", theme.value == "dark" ? "#121212" : "#F5F5F5");
document.documentElement.setAttribute("data-theme", theme.value);
});
</script>
<style>
+2
View File
@@ -157,6 +157,7 @@ declare global {
const refManualReset: typeof import('@vueuse/core').refManualReset
const refThrottled: typeof import('@vueuse/core').refThrottled
const refWithControl: typeof import('@vueuse/core').refWithControl
const relativeTimeTick: typeof import('./composable/timeTicker').relativeTimeTick
const resolveComponent: typeof import('vue').resolveComponent
const resolveRef: typeof import('@vueuse/core').resolveRef
const scrollContextKey: typeof import('./composable/scrollContext').scrollContextKey
@@ -628,6 +629,7 @@ declare module 'vue' {
readonly refManualReset: UnwrapRef<typeof import('@vueuse/core')['refManualReset']>
readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
readonly relativeTimeTick: UnwrapRef<typeof import('./composable/timeTicker')['relativeTimeTick']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly scrollContextKey: UnwrapRef<typeof import('./composable/scrollContext')['scrollContextKey']>
readonly search: UnwrapRef<typeof import('./stores/settings')['search']>
@@ -0,0 +1,91 @@
/**
* @vitest-environment jsdom
*/
import { mount } from "@vue/test-utils";
import { afterAll, beforeEach, describe, expect, test, vi } from "vitest";
import { defineComponent, h, nextTick } from "vue";
vi.mock("@/stores/config", () => ({
__esModule: true,
default: { hosts: [], base: "", profile: {}, user: null, authProvider: "none" },
withBase: (path: string) => path,
}));
// Order matters. The ticker starts its interval at module scope, so fake timers have to be
// installed before the component is imported for the tick to be advanceable -- and the
// counter has to wrap the fake setInterval that useFakeTimers just installed, since
// wrapping the real one first would just get clobbered.
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-03T12:00:00Z"));
let intervals = 0;
const fakeSetInterval = globalThis.setInterval;
globalThis.setInterval = ((...args: Parameters<typeof setInterval>) => {
intervals++;
return fakeSetInterval(...args);
}) as typeof setInterval;
const RelativeTime = (await import("./RelativeTime.vue")).default;
// The module-level interval is the only one the whole app should ever create.
const intervalsAfterImport = intervals;
afterAll(() => {
vi.useRealTimers();
});
beforeEach(() => {
vi.setSystemTime(new Date("2026-09-03T12:00:00Z"));
});
function mountMany(n: number, date: Date) {
return mount(
defineComponent({
render: () =>
h(
"div",
Array.from({ length: n }, (_, i) => h(RelativeTime, { key: i, date })),
),
}),
);
}
describe("RelativeTime", () => {
test("renders a relative timestamp", () => {
const wrapper = mount(RelativeTime, { props: { date: new Date("2026-09-03T11:00:00Z") } });
expect(wrapper.text()).not.toBe("");
expect(wrapper.find("time").attributes("datetime")).toBe("2026-09-03T11:00:00.000Z");
});
test("re-renders when the date prop changes", async () => {
const wrapper = mount(RelativeTime, { props: { date: new Date("2026-09-03T11:59:00Z") } });
const before = wrapper.text();
await wrapper.setProps({ date: new Date("2026-09-01T12:00:00Z") });
expect(wrapper.text()).not.toBe(before);
});
test("the shared tick refreshes text as time passes", async () => {
const wrapper = mount(RelativeTime, { props: { date: new Date("2026-09-03T12:00:00Z") } });
const before = wrapper.text();
vi.advanceTimersByTime(60 * 60 * 1000);
await nextTick();
expect(wrapper.text()).not.toBe(before);
});
test("all instances share one timer", async () => {
// Regression guard: useIntervalFn used to live in setup, so the container table stood
// up one setInterval per row and every instance woke on its own unaligned schedule.
intervals = 0;
const wrapper = mountMany(50, new Date("2026-09-03T11:00:00Z"));
await nextTick();
expect(intervals).toBe(0);
// ...because the one shared timer was already started when the ticker was imported.
expect(intervalsAfterImport).toBe(1);
wrapper.unmount();
});
});
+6 -7
View File
@@ -7,11 +7,10 @@ const { date } = defineProps<{
date: Date;
}>();
const text = ref<string>();
const updateFromNow = () => {
text.value = toRelativeTime(date, locale.value === "" ? undefined : locale.value);
};
watch(() => date, updateFromNow, { immediate: true });
useIntervalFn(updateFromNow, 30_000);
// Reading the shared tick is what re-evaluates this on the half minute. A timer per
// component put one setInterval behind every row of the container table.
const text = computed(() => {
relativeTimeTick.value;
return toRelativeTime(date, locale.value === "" ? undefined : locale.value);
});
</script>
+93
View File
@@ -0,0 +1,93 @@
/**
* @vitest-environment jsdom
*/
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { defineComponent, h } from "vue";
vi.mock("@/stores/config", () => ({
__esModule: true,
default: { hosts: [], base: "" },
withBase: (path: string) => path,
}));
// useColorMode's updateHTMLAttrs injects a `* { transition: none }` stylesheet into
// <head>, flips html.classList, then reads back a computed style to force a synchronous
// whole-document recalc before removing it again. Counting those three things is how we
// tell one shared instance from one per component. The style element is gone by the time
// the mount returns, so the append has to be counted as it happens.
let styleInjections = 0;
let forcedReflows = 0;
let mediaListeners = 0;
const realAppend = document.head.appendChild.bind(document.head);
document.head.appendChild = ((node: Node) => {
if ((node as Element).tagName === "STYLE") styleInjections++;
return realAppend(node);
}) as typeof document.head.appendChild;
const realGetComputedStyle = window.getComputedStyle.bind(window);
window.getComputedStyle = ((el: Element, ...rest: unknown[]) => {
if (el?.tagName === "STYLE") forcedReflows++;
return (realGetComputedStyle as any)(el, ...rest);
}) as typeof window.getComputedStyle;
window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => mediaListeners++,
removeEventListener: () => {},
addListener: () => mediaListeners++,
removeListener: () => {},
dispatchEvent: () => false,
})) as typeof window.matchMedia;
// Imported after the probes are installed so the module-level useColorMode() is counted.
const { useResolvedTheme } = await import("./theme");
const { lightTheme } = await import("@/stores/settings");
beforeEach(() => {
styleInjections = 0;
forcedReflows = 0;
mediaListeners = 0;
});
const Consumer = defineComponent({
setup() {
const { isDark } = useResolvedTheme();
return () => h("span", isDark.value ? "dark" : "light");
},
});
describe("useResolvedTheme", () => {
test("resolves an explicit preference", () => {
lightTheme.value = "dark";
expect(mount(Consumer).text()).toBe("dark");
lightTheme.value = "light";
expect(mount(Consumer).text()).toBe("light");
});
test("callers share one useColorMode instance", () => {
// Regression guard for #4991. Calling useColorMode() inside the composable gave every
// ContainerIcon -- one per container row, menu entry and search hit -- its own
// matchMedia listener and two forced full-document reflows at mount, so a host with
// 77 containers paid 154 of them on every render pass.
const wrapper = mount(
defineComponent({
render: () =>
h(
"div",
Array.from({ length: 50 }, (_, i) => h(Consumer, { key: i })),
),
}),
);
expect(styleInjections).toBe(0);
expect(forcedReflows).toBe(0);
expect(mediaListeners).toBe(0);
wrapper.unmount();
});
});
+12 -4
View File
@@ -1,13 +1,21 @@
import { lightTheme } from "@/stores/settings";
// Deliberately module scope, not per caller. useColorMode() is expensive to
// instantiate: it registers a matchMedia listener and a localStorage watcher, and its
// immediate watch injects a `* { transition: none }` stylesheet into <head>, mutates
// html.classList, and reads back a computed style to force a synchronous whole-document
// recalc. Instantiating it per component made every ContainerIcon in a table row cost
// two full reflows, so rendering N containers cost 2N of them.
const mode = useColorMode();
const theme = computed(() => (lightTheme.value === "auto" ? mode.value : lightTheme.value));
const isDark = computed(() => theme.value === "dark");
/**
* Resolves the tri-state `lightTheme` preference down to the theme actually painted,
* mirroring what App.vue writes to `data-theme`. Needed anywhere the choice of asset,
* rather than the choice of CSS, depends on the theme.
*/
export function useResolvedTheme() {
const mode = useColorMode();
const theme = computed(() => (lightTheme.value === "auto" ? mode.value : lightTheme.value));
return { theme, isDark: computed(() => theme.value === "dark") };
return { theme, isDark };
}
+15
View File
@@ -0,0 +1,15 @@
const RELATIVE_TIME_INTERVAL = 30_000;
// One timer for every relative timestamp on the page, deliberately at module scope.
// RelativeTime renders twice per container table row, so a useIntervalFn per component
// meant N unaligned wakeups and N separate render passes every half minute. Sharing the
// tick collapses those into one that Vue batches.
const tick = ref(0);
useIntervalFn(() => tick.value++, RELATIVE_TIME_INTERVAL);
/**
* Reactive counter that advances every 30s. Read it inside a computed to have that
* computed re-evaluate on each tick.
*/
export const relativeTimeTick = readonly(tick);