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:
co-authored by
Claude Opus 5
parent
bf9d6d41f2
commit
934afcb555
+5
-7
@@ -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>
|
||||
|
||||
Vendored
+2
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user